diff --git a/.github/workflows/android_unit_tests.yaml b/.github/workflows/android_unit_tests.yaml new file mode 100644 index 000000000000..f636f88e3e23 --- /dev/null +++ b/.github/workflows/android_unit_tests.yaml @@ -0,0 +1,65 @@ +name: android-unit-tests + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +on: + pull_request: + paths: + - 'packages/**/android/**' + - 'tests/android/**' + - '.github/workflows/android_unit_tests.yaml' + push: + branches: + - main + paths: + - 'packages/**/android/**' + - 'tests/android/**' + - '.github/workflows/android_unit_tests.yaml' + +jobs: + android-unit-tests: + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 + with: + distribution: 'temurin' + java-version: '21' + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 + with: + channel: 'stable' + cache: true + cache-key: "flutter-:os:-:channel:-:version:-:arch:-:hash:" + pub-cache-key: "flutter-pub-:os:-:channel:-:version:-:arch:-:hash:" + - uses: bluefireteam/melos-action@705015c3d2bc4ab94201ac24accb2bbe070cf533 + with: + run-bootstrap: false + melos-version: '5.3.0' + - name: 'Bootstrap tests package' + run: melos bootstrap --scope tests + - name: Gradle cache + uses: gradle/actions/setup-gradle@90ddb51e90a5fd9ba75f40cf85156b7b41bf76a3 + - name: 'Generate Gradle build files' + # The Gradle wrapper and local.properties are gitignored, and Flutter injects + # both. --config-only does that without building any Dart artifacts. + run: cd tests && flutter build apk --debug --config-only + - name: 'Run Android unit tests' + # Tasks are listed per package rather than using the root testDebugUnitTest + # task, which would also build the Dart sources of the aggregate test app. + # Add a task here when a package gains an android/src/test directory. + run: cd tests/android && ./gradlew :firebase_crashlytics:testDebugUnitTest + - name: 'Upload test reports' + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: android-unit-test-reports + path: tests/build/*/reports/tests/ + retention-days: 5 diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/build.gradle b/packages/firebase_crashlytics/firebase_crashlytics/android/build.gradle index 97476950e971..4765c5880a8d 100644 --- a/packages/firebase_crashlytics/firebase_crashlytics/android/build.gradle +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/build.gradle @@ -56,12 +56,17 @@ android { lintOptions { disable 'InvalidPackage' } + + testOptions { + unitTests.returnDefaultValues = true + } } dependencies { implementation platform("com.google.firebase:firebase-bom:${getRootProjectExtOrCoreProperty("FirebaseSDKVersion", firebaseCoreProject)}") implementation 'com.google.firebase:firebase-crashlytics' implementation 'androidx.annotation:annotation:1.7.0' + testImplementation 'junit:junit:4.13.2' } apply from: file("./user-agent.gradle") diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java index 0e6b9561168a..94dedfc7c68c 100644 --- a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java @@ -6,11 +6,15 @@ import android.content.Context; import android.util.Log; +import androidx.annotation.VisibleForTesting; import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; import java.io.InputStream; -import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.Enumeration; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -22,19 +26,68 @@ * {@code .note.gnu.build-id} section) when uploading symbols. To ensure Crashlytics can match crash * reports to uploaded symbols, the plugin must report the same ELF build ID rather than the Dart * VM's internal snapshot build ID (which may differ, especially for AAB + flavor builds). + * + *

Only a bounded prefix of the library is ever read into memory: every allocation here is sized + * by a constant, never by a value read from the file. */ final class ElfBuildIdReader { private static final String TAG = "FLTFirebaseCrashlytics"; private static final byte[] ELF_MAGIC = {0x7f, 'E', 'L', 'F'}; + private static final int ELFCLASS32 = 1; private static final int ELFCLASS64 = 2; + private static final int ELFDATA2LSB = 1; + private static final int ELFDATA2MSB = 2; private static final int PT_NOTE = 4; private static final int NT_GNU_BUILD_ID = 3; private static final String GNU_NOTE_NAME = "GNU"; + /** First attempt: enough for the ELF header, program header table, and typical notes. */ + private static final int INITIAL_PREFIX_BYTES = 4 * 1024; + + /** + * Hard ceiling for a retry if a note sits further into the library. A library whose build ID note + * starts beyond this reports no build ID at all, which costs symbol matching but never memory. + */ + private static final int MAX_PREFIX_BYTES = 256 * 1024; + + /** GNU build IDs are small; this bounds hostile or corrupt descsz values. */ + private static final int MAX_DESC_BYTES = 1024; + + private static final int ELF32_HEADER_BYTES = 52; + private static final int ELF64_HEADER_BYTES = 64; + private static final int ELF32_PHDR_MIN_BYTES = 32; + private static final int ELF64_PHDR_MIN_BYTES = 56; + private static final int NOTE_HEADER_BYTES = 12; + private ElfBuildIdReader() {} + /** Parse outcome: either a build ID, or how many prefix bytes would have been needed. */ + @VisibleForTesting + static final class ParseResult { + final String buildId; + final int bytesNeeded; + + ParseResult(String buildId, int bytesNeeded) { + this.buildId = buildId; + this.bytesNeeded = bytesNeeded; + } + + static final ParseResult NOT_FOUND = new ParseResult(null, 0); + } + + /** + * Opens a stream positioned at the start of the library. + * + *

Retrying with a larger prefix re-reads from the beginning, so the source must be able to + * hand out a fresh stream on each call. + */ + @VisibleForTesting + interface StreamSource { + InputStream open() throws IOException; + } + /** * Reads the ELF build ID from libapp.so. * @@ -49,12 +102,12 @@ static String readBuildId(Context context) { String nativeLibDir = context.getApplicationInfo().nativeLibraryDir; File libApp = new File(nativeLibDir, "libapp.so"); if (libApp.exists()) { - return readBuildIdFromElf(libApp); + return readBuildIdFromFile(libApp); } // Fall back to reading from inside the APK (or split APKs for AAB installs). return readBuildIdFromApk(context); - } catch (Exception e) { + } catch (Exception | OutOfMemoryError e) { Log.d(TAG, "Could not read ELF build ID from libapp.so", e); return null; } @@ -90,237 +143,196 @@ private static String readBuildIdFromZip(String apkPath) throws Exception { while (entries.hasMoreElements()) { ZipEntry entry = entries.nextElement(); if (entry.getName().endsWith("/libapp.so")) { - try (InputStream is = zipFile.getInputStream(entry)) { - byte[] elfData = new byte[(int) entry.getSize()]; - int offset = 0; - while (offset < elfData.length) { - int read = is.read(elfData, offset, elfData.length - offset); - if (read < 0) break; - offset += read; - } - return readBuildIdFromBytes(elfData); - } + return readBuildIdFromSource(() -> zipFile.getInputStream(entry)); } } } return null; } - private static String readBuildIdFromElf(File elfFile) throws Exception { - try (RandomAccessFile raf = new RandomAccessFile(elfFile, "r")) { - return readBuildIdFromRaf(raf); - } + private static String readBuildIdFromFile(File elfFile) throws Exception { + return readBuildIdFromSource(() -> new FileInputStream(elfFile)); } - private static String readBuildIdFromBytes(byte[] data) { - try { - ByteBuffer buf = ByteBuffer.wrap(data); - - // Verify ELF magic bytes. - for (int i = 0; i < 4; i++) { - if (buf.get() != ELF_MAGIC[i]) { - return null; - } + /** + * Parses successively larger prefixes until the build ID is found or the source runs out. + * + *

Each retry strictly increases the prefix size and stops at {@link #MAX_PREFIX_BYTES}, so the + * loop always terminates: a truncated or malformed library returns {@code null} rather than + * asking for bytes the source can never supply. + */ + @VisibleForTesting + static String readBuildIdFromSource(StreamSource source) throws IOException { + int limit = INITIAL_PREFIX_BYTES; + while (true) { + byte[] prefix; + try (InputStream is = source.open()) { + prefix = readPrefix(is, limit); } - int elfClass = buf.get() & 0xFF; // 1 = 32-bit, 2 = 64-bit - boolean is64 = elfClass == ELFCLASS64; - - int dataEncoding = buf.get() & 0xFF; // 1 = little-endian, 2 = big-endian - ByteOrder order = dataEncoding == 1 ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN; - buf.order(order); - - if (is64) { - return readBuildIdFromBuffer64(buf); - } else { - return readBuildIdFromBuffer32(buf); + ParseResult result = readBuildIdFromBytes(prefix); + if (result.buildId != null) { + return result.buildId; } - } catch (Exception e) { - Log.d(TAG, "Could not parse ELF from APK", e); - return null; - } - } - - private static String readBuildIdFromBuffer64(ByteBuffer buf) { - // e_phoff is at offset 32 in the 64-bit ELF header. - buf.position(32); - long phoff = buf.getLong(); - // e_phentsize is at offset 54, e_phnum at offset 56. - buf.position(54); - int phentsize = buf.getShort() & 0xFFFF; - int phnum = buf.getShort() & 0xFFFF; - - for (int i = 0; i < phnum; i++) { - int phdr = (int) (phoff + (long) i * phentsize); - buf.position(phdr); - int type = buf.getInt(); - if (type == PT_NOTE) { - // p_offset is at phdr + 8, p_filesz at phdr + 32 for 64-bit. - buf.position(phdr + 8); - long noteOffset = buf.getLong(); - buf.position(phdr + 32); - long noteSize = buf.getLong(); - - String buildId = findGnuBuildIdInBuffer(buf, noteOffset, noteSize); - if (buildId != null) { - return buildId; - } + // A short read means the source is exhausted, so a larger prefix cannot reveal anything new. + if (result.bytesNeeded <= prefix.length || prefix.length < limit) { + return null; } - } - return null; - } - - private static String readBuildIdFromBuffer32(ByteBuffer buf) { - // e_phoff is at offset 28 in the 32-bit ELF header. - buf.position(28); - long phoff = buf.getInt() & 0xFFFFFFFFL; - - // e_phentsize is at offset 42, e_phnum at offset 44. - buf.position(42); - int phentsize = buf.getShort() & 0xFFFF; - int phnum = buf.getShort() & 0xFFFF; - - for (int i = 0; i < phnum; i++) { - int phdr = (int) (phoff + (long) i * phentsize); - buf.position(phdr); - int type = buf.getInt(); - if (type == PT_NOTE) { - // p_offset is at phdr + 4, p_filesz at phdr + 16 for 32-bit. - buf.position(phdr + 4); - long noteOffset = buf.getInt() & 0xFFFFFFFFL; - buf.position(phdr + 16); - long noteSize = buf.getInt() & 0xFFFFFFFFL; - - String buildId = findGnuBuildIdInBuffer(buf, noteOffset, noteSize); - if (buildId != null) { - return buildId; - } + if (result.bytesNeeded > MAX_PREFIX_BYTES) { + Log.w( + TAG, + "The ELF build ID of libapp.so lies beyond the first " + + MAX_PREFIX_BYTES + + " bytes; Crashlytics may not match symbols for this build."); + return null; } + limit = result.bytesNeeded; } - return null; } - private static String findGnuBuildIdInBuffer(ByteBuffer buf, long offset, long size) { - long end = offset + size; - long pos = offset; - - while (pos + 12 <= end) { - buf.position((int) pos); - int namesz = buf.getInt(); - int descsz = buf.getInt(); - int type = buf.getInt(); - - if (namesz < 0 || descsz < 0 || namesz > 256) { + /** Reads at most {@code limit} bytes from the start of the stream. */ + private static byte[] readPrefix(InputStream is, int limit) throws IOException { + byte[] buffer = new byte[limit]; + int offset = 0; + while (offset < limit) { + int read = is.read(buffer, offset, limit - offset); + if (read < 0) { break; } + offset += read; + } + return offset == limit ? buffer : Arrays.copyOf(buffer, offset); + } - int nameAligned = align4(namesz); - long descPos = pos + 12 + nameAligned; - - if (namesz > 0 && type == NT_GNU_BUILD_ID && descPos + descsz <= end) { - byte[] nameBytes = new byte[namesz]; - buf.get(nameBytes); - String name = - new String( - nameBytes, 0, Math.max(0, namesz - 1), java.nio.charset.StandardCharsets.US_ASCII); + /** + * Parses an ELF prefix. + * + *

Every bound is checked against the buffer length, so a truncated prefix reports how many + * bytes it would have needed instead of throwing. + */ + @VisibleForTesting + static ParseResult readBuildIdFromBytes(byte[] data) { + if (data.length < ELF32_HEADER_BYTES) { + return new ParseResult(null, ELF32_HEADER_BYTES); + } - if (GNU_NOTE_NAME.equals(name) && descsz > 0) { - buf.position((int) descPos); - byte[] desc = new byte[descsz]; - buf.get(desc); - return bytesToHex(desc); - } + for (int i = 0; i < ELF_MAGIC.length; i++) { + if (data[i] != ELF_MAGIC[i]) { + return ParseResult.NOT_FOUND; } - - pos = descPos + align4(descsz); } - return null; - } - private static String readBuildIdFromRaf(RandomAccessFile raf) throws Exception { - // Verify ELF magic bytes. - byte[] magic = new byte[4]; - raf.readFully(magic); - for (int i = 0; i < 4; i++) { - if (magic[i] != ELF_MAGIC[i]) { - return null; + int elfClass = data[4] & 0xFF; + boolean is64; + if (elfClass == ELFCLASS64) { + is64 = true; + if (data.length < ELF64_HEADER_BYTES) { + return new ParseResult(null, ELF64_HEADER_BYTES); } + } else if (elfClass == ELFCLASS32) { + is64 = false; + } else { + return ParseResult.NOT_FOUND; } - int elfClass = raf.read(); // 1 = 32-bit, 2 = 64-bit - boolean is64 = elfClass == ELFCLASS64; + int dataEncoding = data[5] & 0xFF; + ByteOrder order; + if (dataEncoding == ELFDATA2LSB) { + order = ByteOrder.LITTLE_ENDIAN; + } else if (dataEncoding == ELFDATA2MSB) { + order = ByteOrder.BIG_ENDIAN; + } else { + return ParseResult.NOT_FOUND; + } - int dataEncoding = raf.read(); // 1 = little-endian, 2 = big-endian - ByteOrder order = dataEncoding == 1 ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN; + ByteBuffer buf = ByteBuffer.wrap(data).order(order); + long phoff; + int phentsize; + int phnum; + int minPhdrBytes; if (is64) { - return readBuildIdFromElf64(raf, order); + // e_phoff at 32, e_phentsize at 54, e_phnum at 56. + phoff = buf.getLong(32); + phentsize = buf.getShort(54) & 0xFFFF; + phnum = buf.getShort(56) & 0xFFFF; + minPhdrBytes = ELF64_PHDR_MIN_BYTES; } else { - return readBuildIdFromElf32(raf, order); + // e_phoff at 28, e_phentsize at 42, e_phnum at 44. + phoff = buf.getInt(28) & 0xFFFFFFFFL; + phentsize = buf.getShort(42) & 0xFFFF; + phnum = buf.getShort(44) & 0xFFFF; + minPhdrBytes = ELF32_PHDR_MIN_BYTES; } - } - private static String readBuildIdFromElf64(RandomAccessFile raf, ByteOrder order) - throws Exception { - // e_phoff is at offset 32 in the 64-bit ELF header. - raf.seek(32); - long phoff = readLong(raf, order); + if (phoff <= 0 || phentsize < minPhdrBytes || phnum <= 0) { + return ParseResult.NOT_FOUND; + } - // e_phentsize is at offset 54, e_phnum at offset 56. - raf.seek(54); - int phentsize = readUnsignedShort(raf, order); - int phnum = readUnsignedShort(raf, order); + long tableSize = (long) phnum * phentsize; + if (phoff > Long.MAX_VALUE - tableSize) { + return ParseResult.NOT_FOUND; + } + long tableEnd = phoff + tableSize; + if (tableEnd > data.length) { + return new ParseResult(null, saturateToInt(tableEnd)); + } + int bytesNeeded = 0; for (int i = 0; i < phnum; i++) { - long phdr = phoff + (long) i * phentsize; - raf.seek(phdr); - int type = readInt(raf, order); - if (type == PT_NOTE) { - // p_offset is at phdr + 8, p_filesz at phdr + 32 for 64-bit. - raf.seek(phdr + 8); - long noteOffset = readLong(raf, order); - raf.seek(phdr + 32); - long noteSize = readLong(raf, order); - - String buildId = findGnuBuildId(raf, noteOffset, noteSize, order); - if (buildId != null) { - return buildId; - } + long phdrLong = phoff + (long) i * phentsize; + if (phdrLong > Integer.MAX_VALUE) { + return ParseResult.NOT_FOUND; + } + int phdr = (int) phdrLong; + if (buf.getInt(phdr) != PT_NOTE) { + continue; } - } - return null; - } - private static String readBuildIdFromElf32(RandomAccessFile raf, ByteOrder order) - throws Exception { - // e_phoff is at offset 28 in the 32-bit ELF header. - raf.seek(28); - long phoff = readInt(raf, order) & 0xFFFFFFFFL; + long noteOffset; + long noteSize; + if (is64) { + // p_offset at phdr + 8, p_filesz at phdr + 32. + noteOffset = buf.getLong(phdr + 8); + noteSize = buf.getLong(phdr + 32); + } else { + // p_offset at phdr + 4, p_filesz at phdr + 16. + noteOffset = buf.getInt(phdr + 4) & 0xFFFFFFFFL; + noteSize = buf.getInt(phdr + 16) & 0xFFFFFFFFL; + } - // e_phentsize is at offset 42, e_phnum at offset 44. - raf.seek(42); - int phentsize = readUnsignedShort(raf, order); - int phnum = readUnsignedShort(raf, order); + if (noteOffset < 0 || noteSize <= 0 || noteOffset > Long.MAX_VALUE - noteSize) { + continue; + } - for (int i = 0; i < phnum; i++) { - long phdr = phoff + (long) i * phentsize; - raf.seek(phdr); - int type = readInt(raf, order); - if (type == PT_NOTE) { - // p_offset is at phdr + 4, p_filesz at phdr + 16 for 32-bit. - raf.seek(phdr + 4); - long noteOffset = readInt(raf, order) & 0xFFFFFFFFL; - raf.seek(phdr + 16); - long noteSize = readInt(raf, order) & 0xFFFFFFFFL; - - String buildId = findGnuBuildId(raf, noteOffset, noteSize, order); - if (buildId != null) { - return buildId; + long noteEnd = noteOffset + noteSize; + if (noteEnd > data.length) { + // Remember the smallest retry that could still succeed. + int needed = saturateToInt(noteEnd); + if (bytesNeeded == 0 || needed < bytesNeeded) { + bytesNeeded = needed; } + continue; + } + + String buildId = findGnuBuildIdInBuffer(buf, (int) noteOffset, (int) noteSize); + if (buildId != null) { + return new ParseResult(buildId, 0); } } - return null; + return bytesNeeded == 0 ? ParseResult.NOT_FOUND : new ParseResult(null, bytesNeeded); + } + + /** + * Narrows a file offset to an {@code int}, saturating rather than wrapping. + * + *

The caller decides whether the result exceeds {@link #MAX_PREFIX_BYTES}; saturating here + * instead of clamping keeps "needs more than we are willing to read" distinguishable from "needs + * exactly the cap". + */ + private static int saturateToInt(long value) { + return (int) Math.min(value, (long) Integer.MAX_VALUE); } /** @@ -328,35 +340,35 @@ private static String readBuildIdFromElf32(RandomAccessFile raf, ByteOrder order * *

Note format: namesz (4) | descsz (4) | type (4) | name (aligned to 4) | desc (aligned to 4) */ - private static String findGnuBuildId( - RandomAccessFile raf, long offset, long size, ByteOrder order) throws Exception { - long end = offset + size; - long pos = offset; - - while (pos + 12 <= end) { - raf.seek(pos); - int namesz = readInt(raf, order); - int descsz = readInt(raf, order); - int type = readInt(raf, order); - - if (namesz < 0 || descsz < 0 || namesz > 256) { + private static String findGnuBuildIdInBuffer(ByteBuffer buf, int offset, int size) { + int end = offset + size; + int pos = offset; + + while (pos <= end - NOTE_HEADER_BYTES) { + int namesz = buf.getInt(pos); + int descsz = buf.getInt(pos + 4); + int type = buf.getInt(pos + 8); + + if (namesz < 0 || descsz < 0 || namesz > 256 || descsz > MAX_DESC_BYTES) { break; } int nameAligned = align4(namesz); - long descPos = pos + 12 + nameAligned; + int descPos = pos + NOTE_HEADER_BYTES + nameAligned; if (namesz > 0 && type == NT_GNU_BUILD_ID && descPos + descsz <= end) { byte[] nameBytes = new byte[namesz]; - raf.readFully(nameBytes); + for (int i = 0; i < namesz; i++) { + nameBytes[i] = buf.get(pos + NOTE_HEADER_BYTES + i); + } // Name is null-terminated. - String name = - namesz > 0 ? new String(nameBytes, 0, Math.max(0, namesz - 1), "US-ASCII") : ""; + String name = new String(nameBytes, 0, Math.max(0, namesz - 1), StandardCharsets.US_ASCII); if (GNU_NOTE_NAME.equals(name) && descsz > 0) { - raf.seek(descPos); byte[] desc = new byte[descsz]; - raf.readFully(desc); + for (int i = 0; i < descsz; i++) { + desc[i] = buf.get(descPos + i); + } return bytesToHex(desc); } } @@ -370,24 +382,6 @@ private static int align4(int value) { return (value + 3) & ~3; } - private static int readInt(RandomAccessFile raf, ByteOrder order) throws Exception { - byte[] buf = new byte[4]; - raf.readFully(buf); - return ByteBuffer.wrap(buf).order(order).getInt(); - } - - private static long readLong(RandomAccessFile raf, ByteOrder order) throws Exception { - byte[] buf = new byte[8]; - raf.readFully(buf); - return ByteBuffer.wrap(buf).order(order).getLong(); - } - - private static int readUnsignedShort(RandomAccessFile raf, ByteOrder order) throws Exception { - byte[] buf = new byte[2]; - raf.readFully(buf); - return ByteBuffer.wrap(buf).order(order).getShort() & 0xFFFF; - } - private static String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(bytes.length * 2); for (byte b : bytes) { diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReaderTest.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReaderTest.java new file mode 100644 index 000000000000..0f607f06871a --- /dev/null +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/test/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReaderTest.java @@ -0,0 +1,406 @@ +// Copyright 2024 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.firebase.crashlytics; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import org.junit.Test; + +public class ElfBuildIdReaderTest { + + private static final int ELFCLASS32 = 1; + private static final int ELFCLASS64 = 2; + private static final int ELFDATA2LSB = 1; + private static final int ELFDATA2MSB = 2; + private static final int PT_NOTE = 4; + private static final int PT_LOAD = 1; + private static final int NT_GNU_BUILD_ID = 3; + + private static final int ELF32_HEADER_BYTES = 52; + private static final int ELF64_HEADER_BYTES = 64; + private static final int ELF32_PHDR_BYTES = 32; + private static final int ELF64_PHDR_BYTES = 56; + + private static final int INITIAL_PREFIX_BYTES = 4 * 1024; + private static final int MAX_PREFIX_BYTES = 256 * 1024; + + private static final byte[] BUILD_ID = { + 0x01, + 0x02, + (byte) 0xab, + (byte) 0xff, + 0x10, + 0x20, + 0x30, + 0x40, + 0x50, + 0x60, + 0x70, + (byte) 0x80, + (byte) 0x90, + (byte) 0xa0, + (byte) 0xb0, + (byte) 0xc0, + 0x0d, + 0x0e, + 0x0f, + 0x00 + }; + private static final String BUILD_ID_HEX = "0102abff102030405060708090a0b0c00d0e0f00"; + + // --- Parser: happy paths across ELF classes and byte orders --- + + @Test + public void readBuildIdFromBytes_readsElf64LittleEndian() { + byte[] image = new byte[512]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 1); + writePhdr64(buffer, 0, PT_NOTE, 256, 36); + writeNote(buffer, 256, "GNU", NT_GNU_BUILD_ID, BUILD_ID); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertEquals(BUILD_ID_HEX, result.buildId); + assertEquals(0, result.bytesNeeded); + } + + @Test + public void readBuildIdFromBytes_readsElf64BigEndian() { + byte[] image = new byte[512]; + ByteBuffer buffer = newElf64(image, ByteOrder.BIG_ENDIAN, 1); + writePhdr64(buffer, 0, PT_NOTE, 256, 36); + writeNote(buffer, 256, "GNU", NT_GNU_BUILD_ID, BUILD_ID); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertEquals(BUILD_ID_HEX, result.buildId); + } + + @Test + public void readBuildIdFromBytes_readsElf32LittleEndian() { + byte[] image = new byte[512]; + ByteBuffer buffer = newElf32(image, ByteOrder.LITTLE_ENDIAN, 1); + writePhdr32(buffer, 0, PT_NOTE, 200, 36); + writeNote(buffer, 200, "GNU", NT_GNU_BUILD_ID, BUILD_ID); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertEquals(BUILD_ID_HEX, result.buildId); + } + + @Test + public void readBuildIdFromBytes_readsElf32BigEndian() { + byte[] image = new byte[512]; + ByteBuffer buffer = newElf32(image, ByteOrder.BIG_ENDIAN, 1); + writePhdr32(buffer, 0, PT_NOTE, 200, 36); + writeNote(buffer, 200, "GNU", NT_GNU_BUILD_ID, new byte[] {0x10, 0x20}); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertEquals("1020", result.buildId); + } + + // --- Parser: note and segment traversal --- + + @Test + public void readBuildIdFromBytes_skipsNonGnuNotePrecedingGnuNote() { + byte[] image = new byte[512]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 1); + // Both notes live in the same PT_NOTE segment; the parser must walk past the first. + int next = writeNote(buffer, 256, "Go", 4, new byte[] {0x11, 0x22, 0x33, 0x44}); + writeNote(buffer, next, "GNU", NT_GNU_BUILD_ID, BUILD_ID); + writePhdr64(buffer, 0, PT_NOTE, 256, 128); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertEquals(BUILD_ID_HEX, result.buildId); + } + + @Test + public void readBuildIdFromBytes_findsGnuNoteInLaterProgramHeader() { + byte[] image = new byte[512]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 3); + writePhdr64(buffer, 0, PT_LOAD, 0, 512); + // The first PT_NOTE carries something else, so the search must continue to the second. + writePhdr64(buffer, 1, PT_NOTE, 256, 24); + writeNote(buffer, 256, "Go", 4, new byte[] {0x11, 0x22, 0x33, 0x44}); + writePhdr64(buffer, 2, PT_NOTE, 320, 36); + writeNote(buffer, 320, "GNU", NT_GNU_BUILD_ID, BUILD_ID); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertEquals(BUILD_ID_HEX, result.buildId); + } + + @Test + public void readBuildIdFromBytes_usesReachableNoteWhenEarlierSegmentIsOutOfRange() { + byte[] image = new byte[512]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 2); + writePhdr64(buffer, 0, PT_NOTE, 1000, 64); + writePhdr64(buffer, 1, PT_NOTE, 256, 36); + writeNote(buffer, 256, "GNU", NT_GNU_BUILD_ID, BUILD_ID); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertEquals(BUILD_ID_HEX, result.buildId); + assertEquals(0, result.bytesNeeded); + } + + @Test + public void readBuildIdFromBytes_ignoresBuildIdNoteWithForeignName() { + byte[] image = new byte[512]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 1); + writePhdr64(buffer, 0, PT_NOTE, 256, 36); + writeNote(buffer, 256, "Go", NT_GNU_BUILD_ID, BUILD_ID); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertNull(result.buildId); + assertEquals(0, result.bytesNeeded); + } + + // --- Parser: truncation reporting --- + + @Test + public void readBuildIdFromBytes_reportsBytesNeededForTruncatedNoteSegment() { + byte[] image = new byte[128]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 1); + writePhdr64(buffer, 0, PT_NOTE, 400, 56); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertNull(result.buildId); + assertEquals(456, result.bytesNeeded); + } + + @Test + public void readBuildIdFromBytes_reportsSmallestReachableNote() { + byte[] image = new byte[256]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 2); + writePhdr64(buffer, 0, PT_NOTE, 4000, 100); + writePhdr64(buffer, 1, PT_NOTE, 400, 56); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertEquals(456, result.bytesNeeded); + } + + @Test + public void readBuildIdFromBytes_reportsBytesNeededForProgramHeaderTableBeyondBuffer() { + byte[] image = new byte[512]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 1); + buffer.putLong(32, 4000); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertNull(result.buildId); + assertEquals(4056, result.bytesNeeded); + } + + @Test + public void readBuildIdFromBytes_reportsHeaderSizeForUndersizedBuffer() { + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(new byte[16]); + + assertNull(result.buildId); + assertEquals(ELF32_HEADER_BYTES, result.bytesNeeded); + } + + @Test + public void readBuildIdFromBytes_reportsElf64HeaderSizeForPartialHeader() { + byte[] image = new byte[56]; + writeIdent(image, ELFCLASS64, ByteOrder.LITTLE_ENDIAN); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertNull(result.buildId); + assertEquals(ELF64_HEADER_BYTES, result.bytesNeeded); + } + + // --- Parser: malformed input --- + + @Test + public void readBuildIdFromBytes_returnsNotFoundForGarbage() { + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(new byte[512]); + + assertNull(result.buildId); + assertEquals(0, result.bytesNeeded); + } + + @Test + public void readBuildIdFromBytes_returnsNotFoundForUnsupportedElfClass() { + byte[] image = new byte[512]; + writeIdent(image, 7, ByteOrder.LITTLE_ENDIAN); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertNull(result.buildId); + assertEquals(0, result.bytesNeeded); + } + + @Test + public void readBuildIdFromBytes_returnsNotFoundForUnsupportedDataEncoding() { + byte[] image = new byte[512]; + writeIdent(image, ELFCLASS64, ByteOrder.LITTLE_ENDIAN); + image[5] = 7; + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertNull(result.buildId); + assertEquals(0, result.bytesNeeded); + } + + @Test + public void readBuildIdFromBytes_returnsNotFoundForUndersizedProgramHeaderEntry() { + byte[] image = new byte[512]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 1); + // An ELF64 program header cannot be shorter than 56 bytes; reading one would overrun. + buffer.putShort(54, (short) 40); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertNull(result.buildId); + assertEquals(0, result.bytesNeeded); + } + + @Test + public void readBuildIdFromBytes_rejectsOversizedDescription() { + byte[] image = new byte[4096]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 1); + writePhdr64(buffer, 0, PT_NOTE, 256, 2064); + + buffer.putInt(256, 4); + buffer.putInt(260, 2048); + buffer.putInt(264, NT_GNU_BUILD_ID); + buffer.put(268, (byte) 'G'); + buffer.put(269, (byte) 'N'); + buffer.put(270, (byte) 'U'); + buffer.put(271, (byte) 0); + + ElfBuildIdReader.ParseResult result = ElfBuildIdReader.readBuildIdFromBytes(image); + + assertNull(result.buildId); + assertEquals(0, result.bytesNeeded); + } + + // --- Prefix loop --- + + @Test + public void readBuildIdFromSource_readsBuildIdWithinInitialPrefix() throws IOException { + byte[] image = new byte[512]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 1); + writePhdr64(buffer, 0, PT_NOTE, 256, 36); + writeNote(buffer, 256, "GNU", NT_GNU_BUILD_ID, BUILD_ID); + + assertEquals(BUILD_ID_HEX, ElfBuildIdReader.readBuildIdFromSource(sourceOf(image))); + } + + @Test + public void readBuildIdFromSource_retriesWithLargerPrefixWhenNoteIsBeyondInitialLimit() + throws IOException { + byte[] image = new byte[2 * INITIAL_PREFIX_BYTES]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 1); + int noteOffset = INITIAL_PREFIX_BYTES + 904; + writePhdr64(buffer, 0, PT_NOTE, noteOffset, 36); + writeNote(buffer, noteOffset, "GNU", NT_GNU_BUILD_ID, BUILD_ID); + + assertEquals(BUILD_ID_HEX, ElfBuildIdReader.readBuildIdFromSource(sourceOf(image))); + } + + /** Regression test: a note that points past the end of a truncated library must not loop. */ + @Test(timeout = 5000) + public void readBuildIdFromSource_returnsNullForTruncatedLibrary() throws IOException { + byte[] image = new byte[128]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 1); + writePhdr64(buffer, 0, PT_NOTE, 400, 56); + + assertNull(ElfBuildIdReader.readBuildIdFromSource(sourceOf(image))); + } + + @Test(timeout = 5000) + public void readBuildIdFromSource_returnsNullWhenNoteIsBeyondMaxPrefix() throws IOException { + byte[] image = new byte[INITIAL_PREFIX_BYTES]; + ByteBuffer buffer = newElf64(image, ByteOrder.LITTLE_ENDIAN, 1); + writePhdr64(buffer, 0, PT_NOTE, MAX_PREFIX_BYTES + 1024, 36); + + assertNull(ElfBuildIdReader.readBuildIdFromSource(sourceOf(image))); + } + + @Test(timeout = 5000) + public void readBuildIdFromSource_returnsNullForEmptyStream() throws IOException { + assertNull(ElfBuildIdReader.readBuildIdFromSource(sourceOf(new byte[0]))); + } + + // --- Helpers --- + + private static ElfBuildIdReader.StreamSource sourceOf(byte[] image) { + return () -> new ByteArrayInputStream(image); + } + + private static void writeIdent(byte[] image, int elfClass, ByteOrder order) { + image[0] = 0x7f; + image[1] = 'E'; + image[2] = 'L'; + image[3] = 'F'; + image[4] = (byte) elfClass; + image[5] = (byte) (order == ByteOrder.LITTLE_ENDIAN ? ELFDATA2LSB : ELFDATA2MSB); + } + + private static ByteBuffer newElf64(byte[] image, ByteOrder order, int phnum) { + writeIdent(image, ELFCLASS64, order); + ByteBuffer buffer = ByteBuffer.wrap(image).order(order); + buffer.putLong(32, ELF64_HEADER_BYTES); // e_phoff + buffer.putShort(54, (short) ELF64_PHDR_BYTES); // e_phentsize + buffer.putShort(56, (short) phnum); // e_phnum + return buffer; + } + + private static ByteBuffer newElf32(byte[] image, ByteOrder order, int phnum) { + writeIdent(image, ELFCLASS32, order); + ByteBuffer buffer = ByteBuffer.wrap(image).order(order); + buffer.putInt(28, ELF32_HEADER_BYTES); // e_phoff + buffer.putShort(42, (short) ELF32_PHDR_BYTES); // e_phentsize + buffer.putShort(44, (short) phnum); // e_phnum + return buffer; + } + + private static void writePhdr64(ByteBuffer buffer, int index, int type, long offset, long size) { + int phdr = ELF64_HEADER_BYTES + index * ELF64_PHDR_BYTES; + buffer.putInt(phdr, type); // p_type + buffer.putLong(phdr + 8, offset); // p_offset + buffer.putLong(phdr + 32, size); // p_filesz + } + + private static void writePhdr32(ByteBuffer buffer, int index, int type, int offset, int size) { + int phdr = ELF32_HEADER_BYTES + index * ELF32_PHDR_BYTES; + buffer.putInt(phdr, type); // p_type + buffer.putInt(phdr + 4, offset); // p_offset + buffer.putInt(phdr + 16, size); // p_filesz + } + + /** Writes one ELF note and returns the offset just past it. */ + private static int writeNote(ByteBuffer buffer, int offset, String name, int type, byte[] desc) { + byte[] nameBytes = (name + "\0").getBytes(StandardCharsets.US_ASCII); + buffer.putInt(offset, nameBytes.length); // namesz + buffer.putInt(offset + 4, desc.length); // descsz + buffer.putInt(offset + 8, type); // type + for (int i = 0; i < nameBytes.length; i++) { + buffer.put(offset + 12 + i, nameBytes[i]); + } + int descPos = offset + 12 + align4(nameBytes.length); + for (int i = 0; i < desc.length; i++) { + buffer.put(descPos + i, desc[i]); + } + return descPos + align4(desc.length); + } + + private static int align4(int value) { + return (value + 3) & ~3; + } +}