Files
rea/scripts/verify-flutter.mjs
morluto 131fd5ddcd fix(filesystem): retain stable hash cleanup owners across callers
Preserve completed digests and primary failures when descriptor close fails.
Carry the exact handle owner through provider, readiness, cache, doctor and
verifier boundaries, and retry cleanup without rehashing or retrying a raw fd.
Close candidate-owned resources even when no target client was opened.
Keep verifier workspaces when provider shutdown remains incomplete.

Refs #4266
2026-10-16 06:46:25 +08:00

289 lines
9.9 KiB
JavaScript

import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, readdir, readFile, rmdir } 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 { OwnedRegularFileCleanupFailure } from "../dist/filesystem/RegularFile.js";
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
import { closeVerifierResources } from "./lib/verifier-cleanup.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" });
let primaryError;
const hashOwners = new Map();
const hashOwned = async (...args) => {
try {
return await hashStableFile(...args);
} catch (cause) {
if (cause instanceof OwnedRegularFileCleanupFailure)
hashOwners.set(cause.owner, cause.path);
throw cause;
}
};
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 hashOwned(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 hashOwned(plain)),
});
assert.equal(negative.flutter_detected, false);
assert.equal(negative.coverage, "complete");
}
assert.deepEqual(
await hashOwned(apk),
identity,
"Verification must preserve its input",
);
} catch (cause) {
primaryError = cause;
throw cause;
} finally {
const cleanup = [];
for (const [owner, path] of hashOwners) {
cleanup.push(async () => {
try {
await owner.close();
hashOwners.delete(owner);
} catch (cause) {
throw new Error(`Could not close hash file handle for ${path}`, {
cause,
});
}
});
}
cleanup.push(async () => {
await closeVerifierResources([
() => client.close(),
() => transport.close(),
]);
assert.deepEqual(
await readdir(root),
[],
`Provider snapshots remain under ${root}`,
);
await rmdir(root);
});
await closeVerifierResources(cleanup, primaryError);
}
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;
}