mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(elf): distinguish compressed note storage from decoded notes
Refuse compressed note sections with typed unsupported-target semantics before framing or decoding, preserving stored-range checks and physical descriptor-offset provenance. Flutter retains independent build IDs with partial coverage when compressed notes are skipped. Verify the existing shared alignment fix with prefixed notes across classes and table kinds. Fixes #6869 Closes #6563
This commit is contained in:
@@ -58,5 +58,13 @@ numbering (`PN_XNUM`, including a section count stored when `e_shnum` is 0) is
|
||||
unsupported and returns an unsupported-target outcome rather than invalid-input
|
||||
recovery.
|
||||
|
||||
`SHF_COMPRESSED` note sections return `unsupported_target` before their payload
|
||||
is interpreted or decompressed. Their compression header is storage metadata,
|
||||
not a note header. The current result requires physical file offsets for each
|
||||
note and descriptor, which decoded compressed notes cannot provide. This refusal
|
||||
does not label compressed note storage as malformed or allocate its declared
|
||||
decoded size. Compressed section-name tables remain supported within the limits
|
||||
above.
|
||||
|
||||
This workflow does not execute the image or verify GNU build-id uniqueness
|
||||
against package catalogs.
|
||||
|
||||
@@ -111,6 +111,9 @@ server; older servers keep the complete request after schema discovery.
|
||||
Nonempty GNU build-ID descriptors up to 64 bytes are retained as lowercase
|
||||
hexadecimal, including IDs shorter than four bytes.
|
||||
Matching bytes outside declared note ranges cannot supply a build ID.
|
||||
Compressed `SHT_NOTE` sections are skipped with an unsupported-compression
|
||||
limitation and partial coverage; IDs from other note ranges remain candidates.
|
||||
Encoded note storage is never interpreted as note headers or inflated.
|
||||
- Labeled strings must fit their complete grammar. `dart_version_capped`
|
||||
and `toolchain_capped` independently report labels omitted by grammar or
|
||||
projection bounds; either makes identification coverage partial. Values
|
||||
|
||||
@@ -71,6 +71,7 @@ export const readElfBuildIds = async (
|
||||
string,
|
||||
{ offset: number; size: number; alignment: number }
|
||||
>();
|
||||
let compressedNotes = false;
|
||||
tables: for (const table of [
|
||||
{
|
||||
offset: h.e_phoff,
|
||||
@@ -117,6 +118,20 @@ export const readElfBuildIds = async (
|
||||
size > totalBytes - offset
|
||||
)
|
||||
return failure("ELF note range is not file-backed");
|
||||
if (!table.program) {
|
||||
const flags = h.is64
|
||||
? bytes.readBigUInt64LE(base + 8)
|
||||
: BigInt(bytes.readUInt32LE(base + 8));
|
||||
if ((flags & 0x800n) !== 0n) {
|
||||
if (!compressedNotes) {
|
||||
failures.push({
|
||||
reason: "Compressed ELF note sections are unsupported",
|
||||
});
|
||||
compressedNotes = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (size === 0) continue;
|
||||
const alignmentAt =
|
||||
base + (table.program ? (h.is64 ? 48 : 28) : h.is64 ? 48 : 32);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { BinaryFormatFailure } from "../metadata/BinaryContainer.js";
|
||||
import {
|
||||
compressedElfNoteFixture,
|
||||
compressedElfNoteSectionFixture,
|
||||
elfNote,
|
||||
elfNoteFixture,
|
||||
elfNoteTablePastEnd,
|
||||
@@ -103,6 +104,50 @@ it("reads the same notes from an SHT_NOTE section including its name", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[32, true],
|
||||
[32, false],
|
||||
[64, true],
|
||||
[64, false],
|
||||
] as const)(
|
||||
"refuses compressed ELF%i %s-endian note storage without parsing its compression header",
|
||||
(bits, little) => {
|
||||
expect(() => parse(compressedElfNoteSectionFixture(bits, little))).toThrow(
|
||||
expect.objectContaining({
|
||||
kind: "unsupported",
|
||||
message: expect.stringContaining(
|
||||
"Compressed ELF note sections are unsupported",
|
||||
),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not allocate the declared decoded note size, but still validates stored ranges", () => {
|
||||
const bytes = compressedElfNoteSectionFixture();
|
||||
const noteSection = 64 + 64;
|
||||
const storedOffset = Number(bytes.readBigUInt64LE(noteSection + 24));
|
||||
bytes.writeBigUInt64LE(0xffffffffffffffffn, storedOffset + 8);
|
||||
expect(() => parse(bytes)).toThrow(
|
||||
expect.objectContaining({ kind: "unsupported" }),
|
||||
);
|
||||
bytes.writeBigUInt64LE(0n, noteSection + 32);
|
||||
expect(() => parse(bytes)).toThrow(
|
||||
expect.objectContaining({ kind: "unsupported" }),
|
||||
);
|
||||
bytes.writeBigUInt64LE(BigInt(bytes.length), noteSection + 32);
|
||||
expect(() => parse(bytes)).toThrow(ArtifactReaderFailure);
|
||||
});
|
||||
|
||||
it("preserves unknown ELF64 section flag bits without treating them as file sizes", () => {
|
||||
const bytes = elfNoteFixture({ ownership: "section" });
|
||||
bytes.writeBigUInt64LE(0x8000000000000000n, 64 + 64 + 8);
|
||||
bytes.writeBigUInt64LE(0x8000000000000000n, 64 + 2 * 64 + 8);
|
||||
expect(parse(bytes).entries[0]?.gnu?.build_id_hex).toBe(
|
||||
"0123456789abcdef0123456789abcdef01234567",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[32, true],
|
||||
[64, false],
|
||||
|
||||
@@ -19,6 +19,7 @@ import { decodeElfSectionNameTable } from "../metadata/ElfSectionNameTable.js";
|
||||
const PT_NOTE = 4;
|
||||
const SHT_NOTE = 7;
|
||||
const SHT_STRTAB = 3;
|
||||
const SHF_COMPRESSED = 0x800n;
|
||||
const HEX_DESCRIPTOR_LIMIT = 64;
|
||||
|
||||
interface TextBudget {
|
||||
@@ -111,13 +112,17 @@ class ElfReader {
|
||||
: this.bytes.readUInt32BE(offset);
|
||||
}
|
||||
|
||||
word(offset: number): number {
|
||||
wordBits(offset: number): bigint {
|
||||
const width = this.bits === 64 ? 8 : 4;
|
||||
this.require(offset, width);
|
||||
if (this.bits === 32) return this.u32(offset);
|
||||
const value = this.little
|
||||
if (this.bits === 32) return BigInt(this.u32(offset));
|
||||
return this.little
|
||||
? this.bytes.readBigUInt64LE(offset)
|
||||
: this.bytes.readBigUInt64BE(offset);
|
||||
}
|
||||
|
||||
word(offset: number): number {
|
||||
const value = this.wordBits(offset);
|
||||
if (value > BigInt(Number.MAX_SAFE_INTEGER))
|
||||
fail("ELF offset or size exceeds a safe integer.");
|
||||
return Number(value);
|
||||
@@ -353,10 +358,10 @@ export const parseElfNotes = (
|
||||
const strOff = reader.word(namesHeader + (wide ? 24 : 16));
|
||||
const strSize = reader.word(namesHeader + (wide ? 32 : 20));
|
||||
const rawNames = reader.slice(strOff, strSize);
|
||||
const flags = reader.word(namesHeader + 8);
|
||||
const flags = reader.wordBits(namesHeader + 8);
|
||||
const decoded = decodeElfSectionNameTable(
|
||||
rawNames,
|
||||
BigInt(flags),
|
||||
flags,
|
||||
header.elfClass,
|
||||
reader.little,
|
||||
input.max_file_bytes - bytes.length,
|
||||
@@ -418,6 +423,12 @@ export const parseElfNotes = (
|
||||
const nameOffset = reader.u32(at);
|
||||
const fileOffset = reader.word(at + (wide ? 24 : 16));
|
||||
const fileSize = reader.word(at + (wide ? 32 : 20));
|
||||
reader.require(fileOffset, fileSize);
|
||||
if ((reader.wordBits(at + 8) & SHF_COMPRESSED) !== 0n)
|
||||
throw new BinaryFormatFailure(
|
||||
"unsupported",
|
||||
"Compressed ELF note sections are unsupported; descriptor file offsets require uncompressed note storage.",
|
||||
);
|
||||
const alignment = noteAlignment(reader.word(at + (wide ? 48 : 32)));
|
||||
tables.push({
|
||||
kind: "section",
|
||||
|
||||
@@ -209,6 +209,33 @@ export const elfNoteFixture = (options: ElfNoteImageOptions = {}): Buffer => {
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** A non-allocated SHT_NOTE whose stored bytes include an ELF compression header. */
|
||||
export const compressedElfNoteSectionFixture = (
|
||||
bits: 32 | 64 = 64,
|
||||
little = true,
|
||||
): Buffer => {
|
||||
const notes = concat(
|
||||
gnuBuildIdNote(undefined, little),
|
||||
gnuAbiTagNote(little),
|
||||
);
|
||||
const header = Buffer.alloc(bits === 64 ? 24 : 12);
|
||||
write32(header, 0, 1, little); // ELFCOMPRESS_ZLIB
|
||||
writeWord(header, bits === 64 ? 8 : 4, notes.length, bits, little);
|
||||
writeWord(header, bits === 64 ? 16 : 8, 4, bits, little);
|
||||
const bytes = elfNoteFixture({
|
||||
bits,
|
||||
little,
|
||||
type: 1,
|
||||
ownership: "section",
|
||||
sectionName: ".debug.notes",
|
||||
alignment: bits === 64 ? 8 : 4,
|
||||
notes: concat(header, deflateSync(notes)),
|
||||
});
|
||||
const noteSection = (bits === 64 ? 64 : 52) + (bits === 64 ? 64 : 40);
|
||||
writeWord(bytes, noteSection + 8, 0x800, bits, little);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** Valid ELF64 section count stored in section header zero per the gABI. */
|
||||
export const elfExtendedSectionCountFixture = (): Buffer => {
|
||||
const count = 0xff00;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { expect } from "vitest";
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import {
|
||||
compressedElfNoteFixture,
|
||||
compressedElfNoteSectionFixture,
|
||||
elfExtendedSectionCountFixture,
|
||||
elfNoteFixture,
|
||||
} from "../../src/native/elf/ElfNotes.fixture.js";
|
||||
@@ -85,6 +86,27 @@ cliTest("reports a truncated ELF as invalid input", async ({ cli }) => {
|
||||
});
|
||||
});
|
||||
|
||||
cliTest(
|
||||
"reports compressed note storage as unsupported without mutation",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-elf-notes-storage-cli-");
|
||||
const path = join(root, "debug-notes.o");
|
||||
const bytes = compressedElfNoteSectionFixture();
|
||||
await writeFile(path, bytes);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-elf-notes", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
message: expect.stringContaining(
|
||||
"Compressed ELF note sections are unsupported",
|
||||
),
|
||||
});
|
||||
expect(await readFile(path)).toEqual(bytes);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"reports valid extended numbering as unsupported without mutation",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { gnuBuildIdNote } from "../../src/native/elf/ElfNotes.fixture.js";
|
||||
import {
|
||||
gnuBuildIdNote,
|
||||
compressedElfNoteSectionFixture,
|
||||
} from "../../src/native/elf/ElfNotes.fixture.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { expect } from "vitest";
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
@@ -343,3 +346,55 @@ cliTest(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"CLI and stdio MCP retain Flutter build-ID candidates with unsupported compressed notes",
|
||||
async ({ cli }) => {
|
||||
const compressed = appendElfRanges(
|
||||
compressedElfNoteSectionFixture(),
|
||||
"program",
|
||||
[{ type: 1, offset: 0, size: 0 }],
|
||||
);
|
||||
const note = gnuBuildIdNote("0123456789abcdef");
|
||||
const engine = appendElfRanges(
|
||||
Buffer.concat([compressed, note]),
|
||||
"section",
|
||||
[{ type: 7, offset: compressed.length, size: note.length }],
|
||||
);
|
||||
const path = await createFlutterApk("compressed-notes.apk", [
|
||||
{ path: "lib/arm64-v8a/libapp.so", bytes: aotFixtureImage() },
|
||||
{ path: "lib/arm64-v8a/libflutter.so", bytes: engine },
|
||||
]);
|
||||
const output = await cli.run({
|
||||
arguments: ["identify-flutter-build", path, "--json"],
|
||||
});
|
||||
expect(output.exitCode, output.stderr).toBe(0);
|
||||
const cliEvidence = parseEvidence(output.json);
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const response = await call("identify_flutter_build", { path });
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
const mcpEvidence = parseEvidence(response.structuredContent);
|
||||
expect(mcpEvidence.normalized_result).toEqual(
|
||||
cliEvidence.normalized_result,
|
||||
);
|
||||
const result = flutterResultSchemas.identify_flutter_build.parse(
|
||||
cliEvidence.normalized_result,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
coverage: "partial",
|
||||
abis: [
|
||||
{
|
||||
libflutter: {
|
||||
sha256: createHash("sha256").update(engine).digest("hex"),
|
||||
build_id: null,
|
||||
build_id_candidates: ["0123456789abcdef"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const evidence of [cliEvidence, mcpEvidence])
|
||||
expect(evidence.limitations.join("\n")).toContain(
|
||||
"Compressed ELF note sections are unsupported",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { readElfBuildIds } from "../../../src/flutter/ElfBuildId.js";
|
||||
import {
|
||||
elfNote,
|
||||
elfNoteFixture,
|
||||
compressedElfNoteSectionFixture,
|
||||
} from "../../../src/native/elf/ElfNotes.fixture.js";
|
||||
import {
|
||||
appendElfRanges,
|
||||
@@ -27,11 +28,12 @@ it.each([32, 64] as const)(
|
||||
bits,
|
||||
alignment,
|
||||
ownership: table === "program" ? "program" : "both",
|
||||
notes: Buffer.concat(
|
||||
ids.map((id) =>
|
||||
notes: Buffer.concat([
|
||||
elfNote("Example", 1, Buffer.from([1]), true, alignment),
|
||||
...ids.map((id) =>
|
||||
elfNote("GNU", 3, Buffer.from(id, "hex"), true, alignment),
|
||||
),
|
||||
),
|
||||
]),
|
||||
});
|
||||
if (table === "section") image.writeUInt32LE(1, bits === 64 ? 64 : 52);
|
||||
const root = await createTestTempDirectory("rea-flutter-alignment-");
|
||||
@@ -51,6 +53,55 @@ it.each([32, 64] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
([32, 64] as const).flatMap((bits) => [
|
||||
{ bits, empty: false },
|
||||
{ bits, empty: true },
|
||||
]),
|
||||
)(
|
||||
"retains other ELF$bits note IDs with compressed storage (empty: $empty)",
|
||||
async ({ bits, empty }) => {
|
||||
const encoded = compressedElfNoteSectionFixture(bits);
|
||||
const noteSection = (bits === 64 ? 64 : 52) + (bits === 64 ? 64 : 40);
|
||||
if (empty) {
|
||||
if (bits === 64) encoded.writeBigUInt64LE(0n, noteSection + 32);
|
||||
else encoded.writeUInt32LE(0, noteSection + 20);
|
||||
}
|
||||
// Engine metadata parsing also requires a program directory.
|
||||
const compressed = appendElfRanges(encoded, "program", [
|
||||
{ type: 1, offset: 0, size: 0 },
|
||||
]);
|
||||
const bytes = appendElfRanges(
|
||||
Buffer.concat([compressed, note]),
|
||||
"section",
|
||||
[{ type: 7, offset: compressed.length, size: note.length }],
|
||||
);
|
||||
const root = await createTestTempDirectory("rea-flutter-compressed-notes-");
|
||||
const path = join(root, "engine.so");
|
||||
await writeFile(path, bytes);
|
||||
const file = await open(path, "r");
|
||||
const read = vi.spyOn(file, "read");
|
||||
try {
|
||||
expect(await readElfBuildIds(file, bytes.length, undefined)).toEqual({
|
||||
candidates: ["0123456789abcdef"],
|
||||
failures: [{ reason: "Compressed ELF note sections are unsupported" }],
|
||||
});
|
||||
const storedOffset =
|
||||
bits === 64
|
||||
? Number(compressed.readBigUInt64LE(noteSection + 24))
|
||||
: compressed.readUInt32LE(noteSection + 16);
|
||||
expect(read).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
storedOffset,
|
||||
);
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
([32, 64] as const).flatMap((bits) => [
|
||||
{ bits, program: 8, section: 4, reads: 5 },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import {
|
||||
elfExtendedSectionCountFixture,
|
||||
compressedElfNoteSectionFixture,
|
||||
elfNoteFixture,
|
||||
} from "../../../src/native/elf/ElfNotes.fixture.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
@@ -72,6 +73,33 @@ mcpTest("requires an absolute path for inspect_elf_notes", async ({ mcp }) => {
|
||||
}
|
||||
});
|
||||
|
||||
mcpTest(
|
||||
"reports compressed note storage as unsupported without mutation",
|
||||
async ({ mcp }) => {
|
||||
const { client, session } = await connect(mcp);
|
||||
try {
|
||||
const root = await createTestTempDirectory("rea-elf-notes-storage-mcp-");
|
||||
const path = join(root, "debug-notes.o");
|
||||
const bytes = compressedElfNoteSectionFixture();
|
||||
await writeFile(path, bytes);
|
||||
const response = await client.callTool({
|
||||
name: "inspect_elf_notes",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
expect(parseMcpToolError(response).error).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
message: expect.stringContaining(
|
||||
"Compressed ELF note sections are unsupported",
|
||||
),
|
||||
});
|
||||
expect(await readFile(path)).toEqual(bytes);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
mcpTest(
|
||||
"reports valid extended numbering as unsupported without mutation",
|
||||
async ({ mcp }) => {
|
||||
|
||||
Reference in New Issue
Block a user