fix(flutter): reject ambiguous ELF mappings and bound note reads (#2334)

* fix(flutter): reject ambiguous mappings and bound ELF notes

Resolve PT_LOAD interpretations once for symbols and dynamic tables, including partial overlaps. Preserve equivalent mappings and leave conflicting or invalid alternate definitions unresolved.

Bound distinct nonempty ELF note ranges before retention and reads, deduplicate shared ranges, preserve observed build-ID candidates and report partial coverage on exhaustion. Correct shared verifier workspace ownership and structured Windows path assertions.

Addresses the two post-merge review findings on PR #1582. Validation: 147 focused cases across Linux, macOS Sonoma 14.8.9 and non-administrator Windows 11 build 26300.9457; real Gallery CLI/MCP checks on all three hosts; six compiler-produced ELF32/ELF64 hash-layout cases; static, docs, discovery and duplication checks. Full string-pool projections remain explicitly bounded.

* test(flutter): parse complete facts through named result schemas

---------

Co-authored-by: me <me@medz.org>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 22:13:22 +08:00
1 parent fd65e278be
commit e3a012d6c9
10 files changed
+510 -68

No files matched your search

+15 -2
View File
@@ -60,6 +60,10 @@ server; older servers keep the complete request after schema discovery.
[Dart 2.14 layout](https://github.com/dart-lang/sdk/blob/2.14.0/runtime/vm/snapshot.h).
Kind numbers vary across Dart releases; other values are preserved with
partial coverage, without guessing their interpretation.
Address resolution examines every overlapping `PT_LOAD` interpretation,
including partial overlaps. Equivalent mappings are coalesced; conflicting
mappings leave affected symbols unresolved and qualify coverage. Dynamic
hash, symbol and string tables use the same mapping checks.
A payload without exported snapshot symbols is refused rather
than guessed at.
- **The snapshot hash** from the header at the start of each exported data
@@ -133,8 +137,11 @@ server; older servers keep the complete request after schema discovery.
`resource_constraint`.
- **Engine notes.** Engine libraries are spooled into the owned root. ELF
headers, note tables and note payloads share an 8 MiB metadata budget;
up to 64 distinct build IDs are retained. Exceeding either bound preserves
the candidates already examined and reports partial coverage.
up to 64 distinct build IDs and 1024 distinct nonempty note ranges are retained.
The range cap bounds bookkeeping and separate positional reads even for tiny
overlapping declarations. Exact duplicates share one read and empty ranges
consume no inventory slots. Exceeding a bound preserves the candidates examined
from retained ranges and reports partial coverage with `build_id: null`.
- **Metadata and candidate capacity.** Both operations refuse duplicate
relevant library paths. Inventory is limited to 32 ABIs and 1024 bytes per
relevant library path, independently of compressed size. Identification
@@ -174,3 +181,9 @@ engine build-ids, no labeled Dart version (as expected for a release
engine), and the Android clang toolchain line; a plain aapt2-built APK
reports not-detected with complete coverage. The suite uses self-contained
synthetic ELF images and scanner payloads; it does not read private local files.
The Gallery workflow and all 147 focused Flutter tests also pass on macOS Sonoma
14.8.9 x86-64 and Windows 11 x64 build 26300.9457 guests with Node 24.18.0,
including the conflicting-load and note-range-limit CLI/MCP regressions. Windows
verification runs under a non-administrator account. Verifiers use a shared
temporary parent so each CLI/MCP process can own and release its own snapshots.
+16 -14
View File
@@ -1,12 +1,12 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { toolContract } from "../dist/contracts/toolContracts.js";
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
import { hashStableFile } from "../dist/filesystem/StableFileHash.js";
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
@@ -27,17 +27,16 @@ const expectations =
options.expectations === null
? null
: JSON.parse(await readFile(options.expectations, "utf8"));
const root = await PrivateRuntimeRoot.create({
prefix: "rea-flutter-verifier-",
});
// This is a shared test parent, not a leased provider-owned Windows runtime.
const root = await mkdtemp(join(tmpdir(), "rea-flutter-verifier-"));
const entrypoint = resolve("scripts/rea.mjs");
const environment = Object.fromEntries(
Object.entries(process.env).filter(([, value]) => typeof value === "string"),
);
Object.assign(environment, {
TMPDIR: root.path,
TMP: root.path,
TEMP: root.path,
TMPDIR: root,
TMP: root,
TEMP: root,
});
const client = new Client({ name: "flutter-verifier", version: "1" });
const transport = new StdioClientTransport({
@@ -190,15 +189,18 @@ try {
} finally {
// Server shutdown retries retained provider cleanup and must settle before success.
try {
await client.close();
await transport.close();
try {
await client.close();
} finally {
await transport.close();
}
assert.deepEqual(
await readdir(root.path),
await readdir(root),
[],
"Provider snapshots must be released before verification succeeds",
`Provider snapshots remain under ${root}`,
);
} finally {
await root.close();
await rm(root, { recursive: true, force: true });
}
}
process.stdout.write(
+91
View File
@@ -6,6 +6,7 @@ import {
buildElf64Image,
buildStrippedElf64Image,
snapshotSection,
appendElfRanges,
} from "./FlutterBuild.fixture.js";
const readElfSymbol = async (bytes: Buffer, name: string) => {
@@ -143,6 +144,96 @@ describe("readElfSymbol", () => {
});
describe("ELF symbol-table ownership", () => {
it.each([
{ address: 0x400, offset: 0x700, size: 52 },
{ address: 0x410, offset: 0x710, size: 4 },
{ address: 0x3f0, offset: 0x6f0, size: 20 },
])(
"rejects conflicting interpretations of any symbol byte: %j",
async (range) => {
const image = appendElfRanges(imageWithSections(), "program", [
{ type: 1, ...range },
]);
const names = ["_kDartVmSnapshotData", "_kDartIsolateSnapshotData"];
const result = await readElfSymbols(image, names);
expect(result.symbols).toEqual([
{ name: names[1], offset: 0x800, size: 52 },
]);
expect(result.failures).toContainEqual({
reason: expect.stringContaining("conflicting PT_LOAD"),
});
const phoff = Number(image.readBigUInt64LE(0x20));
const first = Buffer.from(image.subarray(phoff, phoff + 56));
image.copy(image, phoff, phoff + 56, phoff + 112);
first.copy(image, phoff + 56);
expect(await readElfSymbols(image, names)).toEqual(result);
},
);
it("accepts equivalent overlaps and excludes adjacent unrelated mappings", async () => {
const image = appendElfRanges(imageWithSections(), "program", [
{ type: 1, address: 0x410, offset: 0x410, size: 4 },
{ type: 1, address: 0x400 + 52, offset: 0x700, size: 4 },
]);
expect(await readElfSymbols(image, ["_kDartVmSnapshotData"])).toEqual({
symbols: [{ name: "_kDartVmSnapshotData", offset: 0x400, size: 52 }],
failures: [],
});
});
it("does not publish an earlier definition when another definition has unresolved mapping", async () => {
const image = imageWithSections();
const shoff = Number(image.readBigUInt64LE(0x28));
const symbols = Number(image.readBigUInt64LE(shoff + 64 + 0x18));
image.writeUInt32LE(image.readUInt32LE(symbols), symbols + 24);
const conflict = appendElfRanges(image, "program", [
{ type: 1, address: 0x810, offset: 0x710, size: 4 },
]);
const result = await readElfSymbols(conflict, ["_kDartVmSnapshotData"]);
expect(result.symbols).toEqual([]);
expect(result.failures).toEqual([
{
reason: "Symbol _kDartVmSnapshotData has conflicting PT_LOAD mappings",
},
]);
});
it("handles zero-width symbols at a unique segment end without choosing a conflicting shared endpoint", async () => {
const image = imageWithSections();
const shoff = Number(image.readBigUInt64LE(0x28));
const symbols = Number(image.readBigUInt64LE(shoff + 64 + 0x18));
const at = Number(image.readBigUInt64LE(64 + 0x20));
image.writeBigUInt64LE(BigInt(at), symbols + 8);
image.writeBigUInt64LE(0n, symbols + 16);
expect(
(await readElfSymbols(image, ["_kDartVmSnapshotData"])).symbols,
).toEqual([{ name: "_kDartVmSnapshotData", offset: at, size: 0 }]);
const ambiguous = appendElfRanges(image, "program", [
{ type: 1, address: at, offset: 0x700, size: 4 },
]);
expect(
(await readElfSymbols(ambiguous, ["_kDartVmSnapshotData"])).symbols,
).toEqual([]);
});
it.each(["hash", "symbols", "strings"] as const)(
"rejects partially conflicting dynamic %s mappings",
async (table) => {
const image = strippedImageWithSections();
const dynamic = Number(image.readBigUInt64LE(64 + 56 + 8));
const tagIndex = table === "hash" ? 4 : table === "symbols" ? 0 : 1;
const at = Number(image.readBigUInt64LE(dynamic + tagIndex * 16 + 8));
const conflict = appendElfRanges(image, "program", [
{ type: 1, address: at + 1, offset: 0x700, size: 1 },
]);
const result = await readElfSymbols(conflict, ["_kDartVmSnapshotData"]);
expect(result.symbols).toEqual([]);
expect(result.failures).toContainEqual({
reason: expect.stringContaining("conflicting PT_LOAD"),
});
},
);
it.each([false, true])(
"keeps a symbol terminator inside dynstr (stripped: %s)",
async (stripped) => {
+48 -44
View File
@@ -1,5 +1,6 @@
import { Buffer } from "node:buffer";
import { setImmediate as yieldToEventLoop } from "node:timers/promises";
import { createElfLoadMap } from "./ElfLoadMap.js";
import {
SNAPSHOT_HASH_LENGTH,
SNAPSHOT_HASH_OFFSET,
@@ -205,6 +206,7 @@ export const readElfSymbols = async (
vaddr === null ||
offset === null ||
filesz === null ||
!Number.isSafeInteger(vaddr + filesz) ||
offset > bytes.length ||
filesz > bytes.length - offset
)
@@ -214,17 +216,7 @@ export const readElfSymbols = async (
return failure("Multiple PT_DYNAMIC segments are ambiguous");
else dynamicSegment = { offset, filesz };
}
const toOffset = (vaddr: number, size: number): number | null => {
const segment = segments.find(
(candidate) =>
vaddr >= candidate.vaddr &&
vaddr - candidate.vaddr <= candidate.filesz &&
size <= candidate.filesz - (vaddr - candidate.vaddr),
);
return segment === undefined
? null
: segment.offset + vaddr - segment.vaddr;
};
const toOffset = await createElfLoadMap(segments, signal);
if (segments.length === 0)
return failure("ELF image has no PT_LOAD segments");
const tables: {
@@ -335,26 +327,25 @@ export const readElfSymbols = async (
const gnu = tags.get(0x6ffffef5);
if (sysv !== undefined) {
const at = toOffset(sysv, 8);
if (at === null) return failure("DT_HASH header is not file-backed");
const buckets = reader.u32(at)!;
count = reader.u32(at + 4)!;
if (toOffset(sysv, 8 + 4 * (buckets + count)) === null)
return failure("DT_HASH table is truncated");
if (!at.ok) return failure(`DT_HASH header ${at.error.reason}`);
const buckets = reader.u32(at.value)!;
count = reader.u32(at.value + 4)!;
const table = toOffset(sysv, 8 + 4 * (buckets + count));
if (!table.ok) return failure(`DT_HASH table ${table.error.reason}`);
} else if (gnu !== undefined) {
const at = toOffset(gnu, 16);
if (at === null) return failure("DT_GNU_HASH header is not file-backed");
const buckets = reader.u32(at)!;
const firstSymbol = reader.u32(at + 4)!;
const bloomWords = reader.u32(at + 8)!;
if (!at.ok) return failure(`DT_GNU_HASH header ${at.error.reason}`);
const buckets = reader.u32(at.value)!;
const firstSymbol = reader.u32(at.value + 4)!;
const bloomWords = reader.u32(at.value + 8)!;
const prefixBytes = 16 + bloomWords * (is64 ? 8 : 4) + buckets * 4;
if (
buckets === 0 ||
bloomWords === 0 ||
toOffset(gnu, prefixBytes) === null
)
if (buckets === 0 || bloomWords === 0)
return failure("DT_GNU_HASH buckets are malformed");
const prefix = toOffset(gnu, prefixBytes);
if (!prefix.ok)
return failure(`DT_GNU_HASH buckets ${prefix.error.reason}`);
let last = 0;
const bucketsAt = at + 16 + bloomWords * (is64 ? 8 : 4);
const bucketsAt = at.value + 16 + bloomWords * (is64 ? 8 : 4);
for (let index = 0; index < buckets; index += 1) {
if (index % 4096 === 0) {
await yieldToEventLoop();
@@ -374,9 +365,10 @@ export const readElfSymbols = async (
signal?.throwIfAborted();
}
const span = prefixBytes + (index - firstSymbol + 1) * 4;
if (toOffset(gnu, span) === null)
return failure("DT_GNU_HASH chain is unterminated");
const chain = reader.u32(at + span - 4)!;
const range = toOffset(gnu, span);
if (!range.ok)
return failure(`DT_GNU_HASH chain ${range.error.reason}`);
const chain = reader.u32(at.value + span - 4)!;
index += 1;
if ((chain & 1) !== 0) {
count = index;
@@ -388,14 +380,22 @@ export const readElfSymbols = async (
return failure("Stripped ELF has no DT_HASH or DT_GNU_HASH symbol count");
const offset = toOffset(sym, count * entrySize);
const stringOffset = toOffset(str, stringSize);
if (offset === null || stringOffset === null)
return failure("Dynamic symbol or string table is not file-backed");
tables.push({ offset, count, stringOffset, stringSize });
if (!offset.ok)
return failure(`Dynamic symbol table ${offset.error.reason}`);
if (!stringOffset.ok)
return failure(`Dynamic string table ${stringOffset.error.reason}`);
tables.push({
offset: offset.value,
count,
stringOffset: stringOffset.value,
stringSize,
});
}
const wanted = new Set(symbolNames);
const symbols = new Map<string, ElfSymbol>();
const ambiguous = new Set<string>();
const failures: ElfParseFailure[] = [];
const unresolved = new Set<string>();
const failures = new Set<string>();
for (const table of tables) {
for (let index = 0; index < table.count; index += 1) {
if (index % 4096 === 0) {
@@ -419,33 +419,37 @@ export const readElfSymbols = async (
const value = is64 ? reader.u64(base + 8) : reader.u32(base + 4);
const size = is64 ? reader.u64(base + 16) : reader.u32(base + 8);
if (value === null || size === null) {
failures.push({ reason: `Symbol ${name} has unrepresentable bounds` });
failures.add(`Symbol ${name} has unrepresentable bounds`);
unresolved.add(name);
continue;
}
const offset = toOffset(value, size);
if (offset === null) {
failures.push({ reason: `Symbol ${name} has no file-backed range` });
if (!offset.ok) {
failures.add(`Symbol ${name} ${offset.error.reason}`);
unresolved.add(name);
continue;
}
const previous = symbols.get(name);
if (
previous !== undefined &&
(previous.offset !== offset || previous.size !== size)
(previous.offset !== offset.value || previous.size !== size)
)
ambiguous.add(name);
else symbols.set(name, { name, offset, size });
else symbols.set(name, { name, offset: offset.value, size });
}
}
for (const name of ambiguous) {
symbols.delete(name);
failures.push({ reason: `Symbol ${name} has conflicting definitions` });
failures.add(`Symbol ${name} has conflicting definitions`);
}
for (const name of unresolved) symbols.delete(name);
for (const name of wanted)
if (!symbols.has(name))
failures.push({
reason: `symbol ${name} is not in the dynamic symbol table`,
});
return { symbols: [...symbols.values()], failures };
if (!symbols.has(name) && !ambiguous.has(name) && !unresolved.has(name))
failures.add(`symbol ${name} is not in the dynamic symbol table`);
return {
symbols: [...symbols.values()],
failures: [...failures].map((reason) => ({ reason })),
};
};
/** Read one Dart snapshot section header from its file offset. */
+18 -4
View File
@@ -8,6 +8,9 @@ import {
const MAX_METADATA_BYTES = 8 * 1024 * 1024;
const MAX_BUILD_IDS = 64;
// Range bookkeeping and separate positional reads need a budget independent
// of payload bytes: tiny/overlapping declarations can otherwise fan out I/O.
const MAX_NOTE_RANGES = 1024;
export interface ElfBuildIds {
readonly candidates: readonly string[];
@@ -21,9 +24,10 @@ export const readElfBuildIds = async (
signal: AbortSignal | undefined,
): Promise<ElfBuildIds> => {
const candidates = new Set<string>();
const failures: ElfParseFailure[] = [];
const failure = (reason: string): ElfBuildIds => ({
candidates: [...candidates],
failures: [{ reason }],
failures: [...failures, { reason }],
});
let examined = 0;
const read = async (offset: number, size: number): Promise<Buffer | null> => {
@@ -59,7 +63,7 @@ export const readElfBuildIds = async (
if (parsed.failure !== undefined) return failure(parsed.failure.reason);
const h = parsed.header;
const ranges = new Map<string, { offset: number; size: number }>();
for (const table of [
tables: for (const table of [
{
offset: h.e_phoff,
width: h.e_phentsize,
@@ -105,7 +109,17 @@ export const readElfBuildIds = async (
size > totalBytes - offset
)
return failure("ELF note range is not file-backed");
ranges.set(`${offset}:${size}`, { offset, size });
if (size === 0) continue;
const key = `${offset}:${size}`;
if (ranges.has(key)) continue;
if (ranges.size === MAX_NOTE_RANGES) {
failures.push({
reason:
"ELF notes exceed the 1024-range note-range inventory/read budget",
});
break tables;
}
ranges.set(key, { offset, size });
}
}
const align4 = (value: number): number => Math.ceil(value / 4) * 4;
@@ -154,5 +168,5 @@ export const readElfBuildIds = async (
}
}
signal?.throwIfAborted();
return { candidates: [...candidates], failures: [] };
return { candidates: [...candidates], failures };
};
+101
View File
@@ -0,0 +1,101 @@
import { setImmediate as yieldToEventLoop } from "node:timers/promises";
import { err, ok, type Result } from "../domain/result.js";
interface LoadSegment {
readonly vaddr: number;
readonly offset: number;
readonly filesz: number;
}
/** Normalize overlapping file-backed interpretations once, before any lookups. */
export const createElfLoadMap = async (
segments: readonly LoadSegment[],
signal?: AbortSignal,
): Promise<
(address: number, size: number) => Result<number, { reason: string }>
> => {
const events = segments
.filter((segment) => segment.filesz > 0)
.flatMap((segment) => [
{ at: segment.vaddr, delta: segment.offset - segment.vaddr, change: 1 },
{
at: segment.vaddr + segment.filesz,
delta: segment.offset - segment.vaddr,
change: -1,
},
])
.sort((left, right) => left.at - right.at);
const active = new Map<number, number>();
const spans: { start: number; end: number; delta: number | null }[] = [];
let previous = 0;
for (let index = 0; index < events.length; index += 1) {
if (index % 4096 === 0) {
await yieldToEventLoop();
signal?.throwIfAborted();
}
const event = events[index]!;
if (event.at > previous && active.size > 0) {
const delta = active.size === 1 ? active.keys().next().value! : null;
const last = spans.at(-1);
if (last?.end === previous && last.delta === delta) last.end = event.at;
else spans.push({ start: previous, end: event.at, delta });
}
const count = (active.get(event.delta) ?? 0) + event.change;
if (count === 0) active.delete(event.delta);
else active.set(event.delta, count);
previous = event.at;
}
// Prefix counts keep growing GNU-hash range checks logarithmic even with
// many load segments. Null denotes conflicting interpretations, not a hole.
const conflicts = [0];
for (const span of spans)
conflicts.push(conflicts.at(-1)! + Number(span.delta === null));
const locate = (address: number): number => {
let low = 0,
high = spans.length;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (spans[middle]!.start <= address) low = middle + 1;
else high = middle;
}
return low - 1;
};
signal?.throwIfAborted();
return (address, size) => {
const missing = () =>
err({ reason: "has no contiguous file-backed PT_LOAD range" });
const ambiguous = () => err({ reason: "has conflicting PT_LOAD mappings" });
if (
!Number.isSafeInteger(address) ||
!Number.isSafeInteger(size) ||
address < 0 ||
size < 0 ||
!Number.isSafeInteger(address + size)
)
return missing();
const first = locate(address);
const span = spans[first];
if (span === undefined || (size > 0 && address >= span.end))
return missing();
if (size === 0) {
// A zero-width symbol at a shared endpoint still needs one file offset.
if (address > span.end) return missing();
const left = spans[first - 1];
if (
span.delta === null ||
(span.start === address &&
left?.end === address &&
left.delta !== span.delta)
)
return ambiguous();
return ok(address + span.delta);
}
const last = locate(address + size - 1);
if (conflicts[last + 1]! !== conflicts[first]!) return ambiguous();
return span.delta !== null &&
address < span.end &&
size <= span.end - address
? ok(address + span.delta)
: missing();
};
};
+47
View File
@@ -265,3 +265,50 @@ export const engineFixtureImage = (options: {
labels.copy(image, noteAt + options.notes.length);
return image;
};
/** Relocate one table and append declarations without disturbing payload bytes. */
export const appendElfRanges = (
image: Buffer,
table: "program" | "section",
ranges: readonly {
type: number;
offset: number;
size: number;
address?: number;
}[],
): Buffer => {
const is64 = image[4] === 2;
const program = table === "program";
const offsetAt = program ? (is64 ? 0x20 : 0x1c) : is64 ? 0x28 : 0x20;
const widthAt = program ? (is64 ? 0x36 : 0x2a) : is64 ? 0x3a : 0x2e;
const countAt = widthAt + 2;
const oldOffset = is64
? Number(image.readBigUInt64LE(offsetAt))
: image.readUInt32LE(offsetAt);
const oldWidth = image.readUInt16LE(widthAt);
const oldCount = image.readUInt16LE(countAt);
const width = oldWidth || (is64 ? 64 : 40);
const count = oldCount || 1; // A section table starts with its null entry.
const offset = Math.ceil(image.length / 8) * 8;
const result = Buffer.alloc(offset + (count + ranges.length) * width);
image.copy(result);
image.copy(result, offset, oldOffset, oldOffset + oldCount * oldWidth);
const word = (value: number, at: number): void => {
if (is64) result.writeBigUInt64LE(BigInt(value), at);
else result.writeUInt32LE(value, at);
};
word(offset, offsetAt);
result.writeUInt16LE(width, widthAt);
result.writeUInt16LE(count + ranges.length, countAt);
ranges.forEach((range, index) => {
const at = offset + (count + index) * width;
result.writeUInt32LE(range.type, at + (program ? 0 : 4));
word(range.offset, at + (program ? (is64 ? 8 : 4) : is64 ? 0x18 : 0x10));
word(
range.size,
at + (program ? (is64 ? 0x20 : 0x10) : is64 ? 0x20 : 0x14),
);
if (program) word(range.address ?? 0, at + (is64 ? 0x10 : 8));
});
return result;
};
+86
View File
@@ -7,6 +7,7 @@ import {
aotFixtureImage,
engineFixtureImage,
REFERENCE_HASH,
appendElfRanges,
} from "../../src/flutter/FlutterBuild.fixture.js";
import {
createFlutterApk,
@@ -159,3 +160,88 @@ cliTest(
).toEqual([4, 4]);
},
);
cliTest(
"CLI and MCP qualify conflicting load mappings and bounded ELF notes",
async ({ cli }) => {
const note = Buffer.from(
"040000000800000003000000474e55000123456789abcdef",
"hex",
);
const engine = engineFixtureImage({
notes: Buffer.concat([note, Buffer.alloc(2000)]),
});
const denseEngine = appendElfRanges(
engine,
"program",
Array.from({ length: 2000 }, (_, index) => ({
type: 4,
offset: 0x400 + note.length + index,
size: 1,
})),
);
const { call } = await connectLocalToolsMcp();
for (const conflict of [false, true]) {
const image = appendElfRanges(aotFixtureImage(), "program", [
{
type: 1,
address: 0x2010,
offset: conflict ? 0x1010 : 0x2010,
size: 4,
},
]);
const path = await createFlutterApk(`load-${String(conflict)}.apk`, [
{ path: "lib/x86_64/libapp.so", bytes: image },
{
path: "lib/x86_64/libflutter.so",
bytes: conflict ? denseEngine : engine,
},
]);
for (const name of [
"inspect_dart_aot",
"identify_flutter_build",
] as const) {
const result = await cli.run({
arguments: [name.replaceAll("_", "-"), path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = toolContract(name).outputSchema.parse(result.json);
const response = await call(name, { path });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const mcp = parseEvidence(response.structuredContent);
expect(mcp.normalized_result).toEqual(evidence.normalized_result);
expect(mcp.evidence_id).toBe(evidence.evidence_id);
expect(mcp.subject).toEqual(evidence.subject);
expect(mcp.limitations).toEqual(evidence.limitations);
expect(evidence.normalized_result.coverage).toBe(
conflict ? "partial" : "complete",
);
if (name === "inspect_dart_aot") {
const facts = flutterResultSchemas[name].parse(
evidence.normalized_result,
);
expect(facts.libapp.sections).toHaveLength(conflict ? 3 : 4);
expect(facts.string_pool.coverage).toBe(
conflict ? "partial" : "complete",
);
if (conflict) {
expect(facts.string_pool.package_uris).toEqual([]);
expect(evidence.limitations.join("\n")).toContain(
"conflicting PT_LOAD",
);
}
} else {
const facts = flutterResultSchemas[name].parse(
evidence.normalized_result,
);
expect(facts.abis[0]?.libflutter).toMatchObject({
build_id: conflict ? null : "0123456789abcdef",
build_id_candidates: ["0123456789abcdef"],
});
if (conflict)
expect(evidence.limitations.join("\n")).toContain("note-range");
}
}
}
},
);
@@ -1123,11 +1123,16 @@ it.each(["identify_flutter_build", "inspect_dart_aot"] as const)(
if (result.ok) throw new Error("Duplicate libraries must be refused");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
message: expect.stringContaining(
`duplicate Flutter library members: ${path}:`,
),
},
],
},
});
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(
"duplicate Flutter library members",
);
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(path);
expect(extract).not.toHaveBeenCalled();
},
);
@@ -0,0 +1,79 @@
import { open, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { readElfBuildIds } from "../../../src/flutter/ElfBuildId.js";
import {
appendElfRanges,
engineFixtureImage,
} from "../../../src/flutter/FlutterBuild.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const note = Buffer.from(
"040000000800000003000000474e55000123456789abcdef",
"hex",
);
it.each([
{ bits: 32, table: "program" },
{ bits: 64, table: "section" },
] as const)(
"bounds dense $bits-bit $table note ranges while retaining examined IDs",
async ({ bits, table }) => {
const count = 30_000;
const image = appendElfRanges(
engineFixtureImage({
bits,
notes: Buffer.concat([note, Buffer.alloc(count)]),
ownership: "both",
}),
table,
Array.from({ length: count }, (_, index) => ({
type: table === "program" ? 4 : 7,
offset: 0x400 + note.length + index,
size: 1,
})),
);
const root = await createTestTempDirectory("rea-flutter-notes-");
const path = join(root, "engine.so");
await writeFile(path, image);
const file = await open(path, "r");
const read = vi.spyOn(file, "read");
try {
const result = await readElfBuildIds(file, image.length, undefined);
expect(result.candidates).toEqual(["0123456789abcdef"]);
expect(result.failures).toContainEqual({
reason: expect.stringContaining("note-range"),
});
expect(read.mock.calls.length).toBeLessThanOrEqual(1027);
} finally {
await file.close();
}
},
);
it("deduplicates shared program/section notes and skips empty ranges before charging inventory", async () => {
const ranges = Array.from({ length: 2000 }, (_, index) => ({
type: 7,
offset: index % 2 === 0 ? 0x400 : index,
size: index % 2 === 0 ? note.length : 0,
}));
const image = appendElfRanges(
engineFixtureImage({ notes: note, ownership: "both" }),
"section",
ranges,
);
const root = await createTestTempDirectory("rea-flutter-notes-");
const path = join(root, "engine.so");
await writeFile(path, image);
const file = await open(path, "r");
const read = vi.spyOn(file, "read");
try {
expect(await readElfBuildIds(file, image.length, undefined)).toEqual({
candidates: ["0123456789abcdef"],
failures: [],
});
expect(read.mock.calls.length).toBe(4);
} finally {
await file.close();
}
});