fix(flutter): preserve short GNU build IDs through public workflows

Reuse the reader correction from PR #6705, and share the byte projection budget with output validation. Verify actual APK parsing through compiled CLI and stdio MCP at 0, 1, 2, 3, 64 and 65 descriptor bytes.

Co-authored-by: Ezra <64943463+ezrabrilliant@users.noreply.github.com>

Fixes #6697
This commit is contained in:
morlutoandEzra committed 2026-10-24 00:11:41 +08:00
1 parent 866c72c907
commit 678c4cfa68
4 files changed
+57 -6

No files matched your search

+2
View File
@@ -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
+10 -4
View File
@@ -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",
+3 -2
View File
@@ -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)
+42
View File
@@ -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",
);
}
},
);