262 lines
9.2 KiB
JavaScript
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;
|
|
}
|