diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md index dab4efad9e..cff9a1d258 100644 --- a/docs/flutter-analysis.md +++ b/docs/flutter-analysis.md @@ -108,6 +108,8 @@ server; older servers keep the complete request after schema discovery. `build_id_candidates` preserves distinct IDs. `build_id` is populated only when all declared note ranges were examined and one ID was found; conflicting IDs or malformed/bounded note metadata make coverage partial. + 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. - Labeled strings must fit their complete grammar. `dart_version_capped` and `toolchain_capped` independently report labels omitted by grammar or diff --git a/src/domain/flutter/flutterBuildAnalysis.ts b/src/domain/flutter/flutterBuildAnalysis.ts index f50dfdf934..30939f3afa 100644 --- a/src/domain/flutter/flutterBuildAnalysis.ts +++ b/src/domain/flutter/flutterBuildAnalysis.ts @@ -5,6 +5,14 @@ import { digestSchema } from "../digests.js"; /** Full logical ZIP path budget, enforced by Flutter library inventory. */ export const MAX_FLUTTER_LIBRARY_PATH_BYTES = 1024; +/** Retained GNU build-ID descriptor budget, in bytes. */ +export const MAX_FLUTTER_BUILD_ID_BYTES = 64; +const gnuBuildIdSchema = z + .string() + .min(2) + .max(MAX_FLUTTER_BUILD_ID_BYTES * 2) + .regex(/^(?:[0-9a-f]{2})+$/u); + /** * A libapp label can occupy every remaining byte of an admitted path. Its * Unicode character count cannot exceed that byte count. The query permits @@ -118,15 +126,13 @@ export const flutterResultSchemas = { present: z.boolean(), bytes: z.int().nonnegative().nullable(), sha256: digestSchema.nullable(), - build_id: z - .string() - .regex(/^[0-9a-f]{8,128}$/u) + build_id: gnuBuildIdSchema .nullable() .describe( "The single GNU build ID when ELF note coverage is complete and unambiguous", ), build_id_candidates: z - .array(z.string().regex(/^[0-9a-f]{8,128}$/u)) + .array(gnuBuildIdSchema) .max(64) .describe( "Distinct GNU build IDs observed in ELF-owned note ranges; conflicting IDs remain explicit", diff --git a/src/flutter/ElfBuildId.ts b/src/flutter/ElfBuildId.ts index 91a6ac1c3c..9d1cccd961 100644 --- a/src/flutter/ElfBuildId.ts +++ b/src/flutter/ElfBuildId.ts @@ -1,3 +1,4 @@ +import { MAX_FLUTTER_BUILD_ID_BYTES } from "../domain/flutter/flutterBuildAnalysis.js"; import type { FileHandle } from "node:fs/promises"; import { setImmediate as yieldToEventLoop } from "node:timers/promises"; import { @@ -167,9 +168,9 @@ export const readElfBuildIds = async ( type === 3 && notes.subarray(at + 12, at + 16).equals(Buffer.from("GNU\0")) ) { - if (descsz < 4 || descsz > 64) + if (descsz < 1 || descsz > MAX_FLUTTER_BUILD_ID_BYTES) return failure( - "GNU build-id descriptor exceeds the 4–64 byte projection", + `GNU build-id descriptor is outside the 1–${MAX_FLUTTER_BUILD_ID_BYTES} byte projection`, ); const candidate = notes.subarray(descriptor, end).toString("hex"); if (!candidates.has(candidate) && candidates.size === MAX_BUILD_IDS) diff --git a/tests/acceptance/flutterAnalysis.test.ts b/tests/acceptance/flutterAnalysis.test.ts index 5e9a8889b8..7d268a9999 100644 --- a/tests/acceptance/flutterAnalysis.test.ts +++ b/tests/acceptance/flutterAnalysis.test.ts @@ -1,3 +1,4 @@ +import { gnuBuildIdNote } from "../../src/native/elf/ElfNotes.fixture.js"; import { createHash } from "node:crypto"; import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; @@ -245,3 +246,44 @@ cliTest( } }, ); + +cliTest( + "CLI and stdio MCP preserve byte-sized GNU build IDs and descriptor coverage", + async ({ cli }) => { + const { call } = await connectLocalToolsMcp(); + for (const bytes of [0, 1, 2, 3, 64, 65]) { + const hex = "ab".repeat(bytes); + const path = await createFlutterApk(`engine-${bytes}.apk`, [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotFixtureImage() }, + { + path: "lib/arm64-v8a/libflutter.so", + bytes: engineFixtureImage({ notes: gnuBuildIdNote(hex) }), + }, + ]); + const output = await cli.run({ + arguments: ["identify-flutter-build", path, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const cliEvidence = parseEvidence(output.json); + 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, + ); + const admitted = bytes > 0 && bytes <= 64; + expect(result.abis[0]?.libflutter).toMatchObject({ + build_id: admitted ? hex : null, + build_id_candidates: admitted ? [hex] : [], + }); + expect(result.coverage).toBe(admitted ? "complete" : "partial"); + if (!admitted) + expect(cliEvidence.limitations.join("\n")).toContain( + "1–64 byte projection", + ); + } + }, +);