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; }