diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md index ebf0ccd844..df68ee9ccc 100644 --- a/docs/flutter-analysis.md +++ b/docs/flutter-analysis.md @@ -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. diff --git a/scripts/verify-flutter.mjs b/scripts/verify-flutter.mjs index 061ad8b20e..76b5a1fdaf 100644 --- a/scripts/verify-flutter.mjs +++ b/scripts/verify-flutter.mjs @@ -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( diff --git a/src/flutter/DartElf.test.ts b/src/flutter/DartElf.test.ts index b2b777de36..ff2724695b 100644 --- a/src/flutter/DartElf.test.ts +++ b/src/flutter/DartElf.test.ts @@ -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) => { diff --git a/src/flutter/DartElf.ts b/src/flutter/DartElf.ts index a9bf00fa58..b1c6fc584f 100644 --- a/src/flutter/DartElf.ts +++ b/src/flutter/DartElf.ts @@ -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(); const ambiguous = new Set(); - const failures: ElfParseFailure[] = []; + const unresolved = new Set(); + const failures = new Set(); 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. */ diff --git a/src/flutter/ElfBuildId.ts b/src/flutter/ElfBuildId.ts index 3c04d0473a..a7eb6145de 100644 --- a/src/flutter/ElfBuildId.ts +++ b/src/flutter/ElfBuildId.ts @@ -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 => { const candidates = new Set(); + 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 => { @@ -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(); - 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 }; }; diff --git a/src/flutter/ElfLoadMap.ts b/src/flutter/ElfLoadMap.ts new file mode 100644 index 0000000000..541e2dfdfc --- /dev/null +++ b/src/flutter/ElfLoadMap.ts @@ -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 +> => { + 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(); + 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(); + }; +}; diff --git a/src/flutter/FlutterBuild.fixture.ts b/src/flutter/FlutterBuild.fixture.ts index 635ea04fb0..27c43c3bb2 100644 --- a/src/flutter/FlutterBuild.fixture.ts +++ b/src/flutter/FlutterBuild.fixture.ts @@ -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; +}; diff --git a/tests/acceptance/flutterAnalysis.test.ts b/tests/acceptance/flutterAnalysis.test.ts index 11671785eb..5e9a8889b8 100644 --- a/tests/acceptance/flutterAnalysis.test.ts +++ b/tests/acceptance/flutterAnalysis.test.ts @@ -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"); + } + } + } + }, +); diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts index 68e0328c4b..3773378f20 100644 --- a/tests/boundary/filesystem/flutterBuildProvider.test.ts +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -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(); }, ); diff --git a/tests/boundary/filesystem/flutterElfNotes.test.ts b/tests/boundary/filesystem/flutterElfNotes.test.ts new file mode 100644 index 0000000000..bdf4611177 --- /dev/null +++ b/tests/boundary/filesystem/flutterElfNotes.test.ts @@ -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(); + } +});