Files
rea/scripts/verify-flutter.mjs
e3a012d6c9 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>
2026-10-13 22:13:22 +08:00

262 lines
9.2 KiB
JavaScript

import assert from "node:assert/strict";
import { execFile } from "node:child_process";
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 { hashStableFile } from "../dist/filesystem/StableFileHash.js";
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
const options = parseArguments(process.argv.slice(2));
if (options.help) {
process.stdout.write(
"Usage: npm run verify:flutter -- --apk PATH [--plain-apk PATH] [--expectations JSON]\n" +
"Checks real APK parsing through compiled CLI and stdio MCP. Optional\n" +
"expectations bind exact engine labels, URI counts and snapshot hash to an APK digest.\n",
);
process.exit(0);
}
if (options.apk === null) {
process.stderr.write("No Flutter APK was provided; pass --apk PATH.\n");
process.exit(2);
}
const expectations =
options.expectations === null
? null
: JSON.parse(await readFile(options.expectations, "utf8"));
// 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,
TMP: root,
TEMP: root,
});
const client = new Client({ name: "flutter-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: environment,
stderr: "pipe",
});
const execute = promisify(execFile);
try {
await client.connect(transport);
const apk = resolve(options.apk);
const identity = await hashStableFile(apk);
const identified = await run("identify_flutter_build", { path: apk });
assert.deepEqual(identified.target, { path: apk, ...identity });
assert.equal(identified.flutter_detected, true);
assert.equal(identified.coverage, "complete");
const abis = identified.abis.filter((abi) => abi.libapp.present);
assert.ok(abis.length > 0);
const hashes = new Set();
for (const abi of abis) {
assert.equal(abi.libapp.elf_header_valid, true);
assert.equal(abi.libapp.snapshot_hash_sources, 2);
assert.ok(abi.libapp.snapshot_hash);
hashes.add(abi.libapp.snapshot_hash);
assert.equal(abi.libflutter.present, true);
assert.match(abi.libflutter.build_id ?? "", /^(?:[0-9a-f]{2}){4,64}$/u);
assert.deepEqual(abi.libflutter.build_id_candidates, [
abi.libflutter.build_id,
]);
}
assert.equal(hashes.size, 1, "Every ABI must agree on the snapshot hash");
if (expectations !== null) {
assert.equal(identity.sha256, expectations.target_sha256);
assert.deepEqual([...hashes], [expectations.snapshot_hash]);
assert.deepEqual(
abis.map((abi) => abi.abi).sort(),
Object.keys(expectations.engines).sort(),
);
for (const abi of abis) {
assert.deepEqual(
{
dart_version: abi.libflutter.dart_version,
toolchain_lines: abi.libflutter.toolchain_lines,
},
expectations.engines[abi.abi],
);
assert.equal(abi.libflutter.dart_version_capped, false);
assert.equal(abi.libflutter.toolchain_capped, false);
}
process.stdout.write(
"[ok] Exact per-ABI engine labels match digest-bound expectations\n",
);
} else {
process.stdout.write(
"[unverified] Exact engine labels and their absence need --expectations JSON\n",
);
}
// The same checks apply to the default selection and each remaining ABI.
const preferred = await run("inspect_dart_aot", { path: apk });
for (const abi of abis) {
const aot =
abi.abi === preferred.abi
? preferred
: await run("inspect_dart_aot", { path: apk, abi: abi.abi });
assert.equal(aot.abi, abi.abi);
assert.equal(
aot.libapp.coverage,
"complete",
`Snapshot decoding must be complete on ${abi.abi}`,
);
assert.equal(aot.coverage, aot.string_pool.coverage);
if (aot.coverage !== "complete") {
process.stdout.write(
`[unverified] ${abi.abi}: full string-pool projection (returned pool is bounded and partial)\n`,
);
}
assert.deepEqual(aot.target, identified.target);
assert.equal(aot.libapp.sha256, abi.libapp.sha256);
assert.equal(aot.libapp.bytes, abi.libapp.bytes);
assert.equal(aot.libapp.snapshot_hash_sources, 2);
assert.equal(aot.libapp.snapshot_hash, abi.libapp.snapshot_hash);
assert.deepEqual(
aot.libapp.sections.map((section) => section.name).sort(),
[
"_kDartVmSnapshotData",
"_kDartVmSnapshotInstructions",
"_kDartIsolateSnapshotData",
"_kDartIsolateSnapshotInstructions",
].sort(),
);
for (const section of aot.libapp.sections) {
assert.ok(section.offset + section.size <= aot.libapp.bytes);
if (!section.name.endsWith("Data")) continue;
assert.equal(section.magic_valid, true);
assert.equal(
section.kind,
3,
"This verifier requires an AOT app snapshot",
);
assert.ok(
section.header_length >= 48 &&
section.header_length + 4 <= section.size,
`Snapshot extent must fit ${section.name} on ${abi.abi}`,
);
}
assert.ok(aot.string_pool.package_uri_count_at_least > 0);
assert.ok(aot.string_pool.dart_uri_count_at_least > 0);
if (expectations?.string_pools !== undefined) {
const expected = expectations.string_pools[abi.abi];
assert.ok(
expected,
`Pinned string-pool counts are required on ${abi.abi}`,
);
assert.equal(
aot.string_pool.package_uri_count_at_least,
expected.package_uri_count_at_least,
);
assert.equal(
aot.string_pool.dart_uri_count_at_least,
expected.dart_uri_count_at_least,
);
process.stdout.write(
`[ok] ${abi.abi}: exact pinned package and Dart URI counts\n`,
);
} else {
process.stdout.write(
`[unverified] ${abi.abi}: exact string-pool counts need --expectations JSON\n`,
);
}
process.stdout.write(
`[ok] ${abi.abi}: four symbols, bounded AOT headers, agreeing hashes and string pool\n`,
);
}
if (options.plainApk !== null) {
const plain = resolve(options.plainApk);
const negative = await run("identify_flutter_build", { path: plain });
assert.deepEqual(negative.target, {
path: plain,
...(await hashStableFile(plain)),
});
assert.equal(negative.flutter_detected, false);
assert.equal(negative.coverage, "complete");
}
assert.deepEqual(
await hashStableFile(apk),
identity,
"Verification must preserve its input",
);
} finally {
// Server shutdown retries retained provider cleanup and must settle before success.
try {
try {
await client.close();
} finally {
await transport.close();
}
assert.deepEqual(
await readdir(root),
[],
`Provider snapshots remain under ${root}`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
}
process.stdout.write(
"all asserted Flutter CLI/MCP checks passed; unverified facets are reported above\n",
);
async function run(operation, input) {
const contract = toolContract(operation);
const args = [
entrypoint,
operation.replaceAll("_", "-"),
input.path,
"--json",
];
if (input.abi !== undefined) args.push("--abi", input.abi);
const { stdout } = await execute(process.execPath, args, {
env: environment,
timeout: 60_000,
maxBuffer: 8 * 1024 * 1024,
});
const cli = contract.outputSchema.parse(JSON.parse(stdout));
const response = await client.callTool(
{ name: operation, arguments: input },
{ timeout: 60_000 },
);
assert.notEqual(response.isError, true, mcpTextValue(response));
const mcp = contract.outputSchema.parse(JSON.parse(mcpTextValue(response)));
assert.deepEqual(cli.normalized_result, mcp.normalized_result);
assert.deepEqual(cli.subject, mcp.subject);
assert.equal(cli.subject.local_path, input.path);
assert.equal(cli.subject.digest.sha256, cli.normalized_result.target.sha256);
assert.deepEqual(cli.limitations, mcp.limitations);
process.stdout.write(
`[ok] ${operation}${input.abi === undefined ? "" : ` ${input.abi}`}: CLI/MCP Evidence parity\n`,
);
return cli.normalized_result;
}
function parseArguments(argv) {
const parsed = { help: false, apk: null, plainApk: null, expectations: null };
const names = {
"--apk": "apk",
"--plain-apk": "plainApk",
"--expectations": "expectations",
};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--help" || argument === "-h") parsed.help = true;
else if (
Object.hasOwn(names, argument) &&
typeof argv[index + 1] === "string" &&
!argv[index + 1].startsWith("--")
) {
parsed[names[argument]] = argv[++index];
} else throw new Error(`Unknown argument or missing value: ${argument}`);
}
return parsed;
}