From 4082375e736f2f6d15cb1bdfd63b30fd43a883da Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:03:59 -0500 Subject: [PATCH] fix(verifiers): preserve results and own cleanup through completion Attempt independent resource cleanup without losing primary failures, retain unresolved owners, and publish completion after release. Bound existing producer commands, preserve helper return values, and settle startup and fixture children on failure. Validation: 29 focused boundary tests passed; actual helper-value and import-bound filesystem cleanup controls passed. The real inert-child termination control and targeted JavaScript binding checks passed, alongside typecheck, lint and formatting. Live Ghidra, Cutter, Electron GUI and Windows addon qualification was not run. --- scripts/lib/release-package-canary.mjs | 59 +- scripts/verify-published-package.mjs | 45 +- scripts/verify-qlog-captures.mjs | 18 +- scripts/verify-real-cutter.mjs | 40 +- scripts/verify-real-ghidra-6502.mjs | 707 +++++---- scripts/verify-real-ghidra-checkpoints.mjs | 35 +- scripts/verify-real-ghidra-com.mjs | 755 ++++----- scripts/verify-real-ghidra-dos.mjs | 831 +++++----- scripts/verify-real-ghidra-nativeaot.mjs | 478 +++--- scripts/verify-real-ghidra-raw.mjs | 940 +++++------ scripts/verify-real-ghidra-swift.mjs | 44 +- scripts/verify-real-ghidra-switch.mjs | 33 +- scripts/verify-release-artifact.mjs | 25 +- scripts/verify-windows-native.mjs | 1410 +++++++++-------- scripts/verify/apple/macos-bundle.mjs | 23 +- scripts/verify/apple/native-provenance.mjs | 23 +- scripts/verify/electron/tool-contracts.mjs | 445 +++--- scripts/verify/firmware/devicetree.mjs | 45 +- .../verify/inspector/runtime-observation.mjs | 78 +- scripts/verify/javascript/recovery.mjs | 20 +- .../process/inspectorVerifierStartup.test.ts | 76 + tests/fixtures/buildComputerMcp.ts | 12 +- tests/fixtures/contractPresentation.mjs | 16 +- tests/fixtures/hopperCleanupTimeout.py | 46 +- 24 files changed, 3303 insertions(+), 2901 deletions(-) create mode 100644 tests/boundary/process/inspectorVerifierStartup.test.ts diff --git a/scripts/lib/release-package-canary.mjs b/scripts/lib/release-package-canary.mjs index 9af44c06dc..7783cc10a6 100644 --- a/scripts/lib/release-package-canary.mjs +++ b/scripts/lib/release-package-canary.mjs @@ -1,19 +1,22 @@ import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { execFile } from "node:child_process"; +import { createVerifierMcpOwner } from "./verifier-mcp-owner.mjs"; +import { withVerifierCleanup } from "./verifier-cleanup.mjs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { promisify } from "node:util"; import { FULL_CATALOG_IDENTITY } from "../../dist/catalogIdentity.js"; import { verifyCompleteToolCatalog } from "./verify-package-core.mjs"; -const execute = promisify(execFile); - -/** Install the selected archive or registry specifier before any protocol deadline. */ -export async function installCanaryPackage(specifier, workspace, environment) { +/** Install the selected archive or registry specifier with an owned ten-minute deadline. */ +export async function installCanaryPackage( + specifier, + workspace, + environment, + commands, +) { const prefix = join(workspace, "installed"); - await execute( + await commands.exec( "npm", [ "install", @@ -26,7 +29,12 @@ export async function installCanaryPackage(specifier, workspace, environment) { prefix, specifier, ], - { cwd: workspace, env: environment, maxBuffer: 16 * 1024 * 1024 }, + { + cwd: workspace, + env: environment, + timeout: 600000, + maxBuffer: 16 * 1024 * 1024, + }, ); return join(prefix, "node_modules", "rea-agents"); } @@ -36,7 +44,7 @@ export async function verifyInstalledCanaryPackage( packageRoot, version, workspace, - environment, + { environment, commands }, ) { const installed = JSON.parse( await readFile(join(packageRoot, "package.json"), "utf8"), @@ -47,9 +55,10 @@ export async function verifyInstalledCanaryPackage( ); const launcher = join(packageRoot, "scripts", "rea.mjs"); for (const option of ["--help", "--llms"]) - await execute(process.execPath, [launcher, option], { + await commands.exec(process.execPath, [launcher, option], { cwd: workspace, env: environment, + timeout: 30000, maxBuffer: 16 * 1024 * 1024, }); const transport = new StdioClientTransport({ @@ -65,22 +74,20 @@ export async function verifyInstalledCanaryPackage( if (stderr.length < 16_384) stderr += chunk.toString("utf8").slice(0, 16_384 - stderr.length); }); - try { - await client.connect(transport); - const toolNames = await verifyCompleteToolCatalog(client); - return { - mcpTools: toolNames.length, - canonicalMcpTools: FULL_CATALOG_IDENTITY.counts.mcp_tools, - }; - } catch (cause) { - throw new Error(`Installed package MCP verification failed: ${stderr}`, { - cause, - }); - } finally { + const owner = createVerifierMcpOwner(); + commands.adopt(owner, "installed package MCP transport"); + return withVerifierCleanup(async () => { try { - await client.close(); - } finally { - await transport.close(); + await owner.open(client, transport); + const toolNames = await verifyCompleteToolCatalog(client); + return { + mcpTools: toolNames.length, + canonicalMcpTools: FULL_CATALOG_IDENTITY.counts.mcp_tools, + }; + } catch (cause) { + throw new Error(`Installed package MCP verification failed: ${stderr}`, { + cause, + }); } - } + }, [() => owner.close()]); } diff --git a/scripts/verify-published-package.mjs b/scripts/verify-published-package.mjs index 646b62e25c..1a7352c48d 100644 --- a/scripts/verify-published-package.mjs +++ b/scripts/verify-published-package.mjs @@ -1,8 +1,8 @@ -import { execFile } from "node:child_process"; +import { createPackageVerifierCommands } from "./lib/verify-package-commands.mjs"; +import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; -import { promisify } from "node:util"; import { pathToFileURL } from "node:url"; import { lt, prerelease, rcompare, valid } from "semver"; @@ -15,7 +15,7 @@ import { import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; const verifierRun = createVerifierRun(); -const execFileAsync = promisify(execFile); + const version = process.argv[2]; if ( typeof version !== "string" || @@ -30,28 +30,33 @@ const serverEnvironment = { npm_config_cache: join(canaryRoot, "npm-cache"), }; delete serverEnvironment.HOPPER_TARGET_PATH; +const commands = await createPackageVerifierCommands({ + workspace: { + path: canaryRoot, + close: () => rm(canaryRoot, { recursive: true, force: true }), + }, +}); let surface; let update; -try { +await withVerifierCleanup(async () => { const publishedRoot = await installCanaryPackage( `rea-agents@${version}`, canaryRoot, serverEnvironment, + commands, ); surface = await verifyInstalledCanaryPackage( publishedRoot, version, canaryRoot, - serverEnvironment, + { environment: serverEnvironment, commands }, ); update = await verifyPublishedUpdate({ version, canaryRoot, updaterRoot: publishedRoot, }); -} finally { - await rm(canaryRoot, { recursive: true, force: true }); -} +}, [() => commands.close()]); process.stdout.write( `${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), package: "rea-agents", version, ...surface, update })}\n`, ); @@ -94,7 +99,7 @@ async function verifyPublishedUpdate({ environment, ); - await execFileAsync( + await commands.exec( "npm", [ "install", @@ -104,13 +109,20 @@ async function verifyPublishedUpdate({ prefix, `rea-agents@${previousVersion}`, ], - { cwd: canaryRoot, env: environment, maxBuffer: 16 * 1024 * 1024 }, + { + cwd: canaryRoot, + env: environment, + timeout: 600000, + maxBuffer: 16 * 1024 * 1024, + }, ); const npmRoot = ( - await execFileAsync("npm", ["root", "--global"], { + await commands.exec("npm", ["root", "--global"], { cwd: canaryRoot, env: environment, + timeout: 30000, + maxBuffer: 16 * 1024 * 1024, }) ).stdout.trim(); // Older releases may predate the update command. Exercise the new published @@ -126,9 +138,10 @@ process.stdout.write(JSON.stringify(result)); if (result.status === "failed") process.exitCode = 1; `, ); - const { stdout } = await execFileAsync(process.execPath, [harness], { + const { stdout } = await commands.exec(process.execPath, [harness], { cwd: canaryRoot, env: environment, + timeout: 600000, maxBuffer: 16 * 1024 * 1024, }); const result = JSON.parse(stdout); @@ -152,9 +165,11 @@ if (result.status === "failed") process.exitCode = 1; ); const help = ( - await execFileAsync(join(prefix, "bin", "rea-agents"), ["--help"], { + await commands.exec(join(prefix, "bin", "rea-agents"), ["--help"], { cwd: canaryRoot, env: environment, + timeout: 30000, + maxBuffer: 16 * 1024 * 1024, }) ).stdout; if (!/^\s{2}update\s/mu.test(help) || /^\s{2}upgrade\s/mu.test(help)) @@ -171,10 +186,10 @@ if (result.status === "failed") process.exitCode = 1; } async function findPreviousPublishedVersion(targetVersion, cwd, environment) { - const { stdout } = await execFileAsync( + const { stdout } = await commands.exec( "npm", ["view", "rea-agents", "versions", "--json"], - { cwd, env: environment, maxBuffer: 16 * 1024 * 1024 }, + { cwd, env: environment, timeout: 30000, maxBuffer: 16 * 1024 * 1024 }, ); const versions = JSON.parse(stdout); const previous = versions diff --git a/scripts/verify-qlog-captures.mjs b/scripts/verify-qlog-captures.mjs index 3e82414b8d..9085d75d78 100644 --- a/scripts/verify-qlog-captures.mjs +++ b/scripts/verify-qlog-captures.mjs @@ -1,4 +1,8 @@ #!/usr/bin/env node +import { + closeVerifierResources, + withVerifierCleanup, +} from "./lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; @@ -46,7 +50,7 @@ transport.stderr?.on("data", (bytes) => { const ajv = new Ajv2020({ strict: false, validateFormats: false }); const measurements = []; const contract = toolContract("inspect_web_network_capture"); -try { +await withVerifierCleanup(async () => { try { await client.connect(transport); } catch (cause) { @@ -213,7 +217,11 @@ try { report: reportPath, }), ); -} finally { - await client.close(); - await transport.close(); -} +}, [ + async () => { + await closeVerifierResources([ + () => client.close(), + () => transport.close(), + ]); + }, +]); diff --git a/scripts/verify-real-cutter.mjs b/scripts/verify-real-cutter.mjs index aa7ee19bab..c5be50c7a0 100644 --- a/scripts/verify-real-cutter.mjs +++ b/scripts/verify-real-cutter.mjs @@ -1,8 +1,12 @@ #!/usr/bin/env node +import { + closeVerifierResources, + withVerifierCleanup, +} from "./lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { execFile } from "node:child_process"; +import { createPackageVerifierCommands } from "./lib/verify-package-commands.mjs"; import { createHash, randomUUID } from "node:crypto"; import { access, @@ -14,17 +18,16 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { promisify } from "node:util"; + import { createServer } from "../dist/server/createServer.js"; -const execFileAsync = promisify(execFile); const cli = resolve("scripts/rea.mjs"); const runCli = async (...arguments_) => { - const { stdout } = await execFileAsync( + const { stdout } = await commands.exec( process.execPath, [cli, ...arguments_, "--format", "json"], { - encoding: "utf8", + timeout: 150_000, maxBuffer: 4 * 1024 * 1024, env: process.env, }, @@ -56,8 +59,14 @@ if (process.env.EXPECTED_PLATFORM !== undefined) { ); } const projectRoot = await mkdtemp(join(tmpdir(), "rea-cutter-smoke-")); +const commands = await createPackageVerifierCommands({ + workspace: { + path: projectRoot, + close: () => rm(projectRoot, { recursive: true, force: true }), + }, +}); const projectPath = join(projectRoot, "smoke.rzdb"); -try { +await withVerifierCleanup(async () => { const discovery = await runCli("list-cutter-sessions"); assert.equal( discovery.discovery_status, @@ -111,7 +120,15 @@ try { }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - try { + const mcpOwner = { + close: () => + closeVerifierResources([ + () => mcpClient.close(), + () => mcpServer.close(), + ]), + }; + commands.adopt(mcpOwner, "Cutter verifier MCP server"); + await withVerifierCleanup(async () => { await mcpServer.connect(serverTransport); await mcpClient.connect(clientTransport); const mcpDiscovery = await mcpClient.callTool({ @@ -138,10 +155,7 @@ try { inspection.normalized_result, "Cutter CLI and MCP returned different analysis results.", ); - } finally { - await mcpClient.close(); - await mcpServer.close(); - } + }, [() => mcpOwner.close()]); const annotation = `rea-smoke-${randomUUID()}`; await runCli( @@ -212,6 +226,4 @@ try { mode: 0o600, }); console.log(JSON.stringify(report)); -} finally { - await rm(projectRoot, { recursive: true, force: true }); -} +}, [() => commands.close()]); diff --git a/scripts/verify-real-ghidra-6502.mjs b/scripts/verify-real-ghidra-6502.mjs index 3417a9c7ff..b5303e10b7 100644 --- a/scripts/verify-real-ghidra-6502.mjs +++ b/scripts/verify-real-ghidra-6502.mjs @@ -1,3 +1,7 @@ +import { + closeVerifierResources, + withVerifierCleanup, +} from "./lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; @@ -67,307 +71,116 @@ let stderr = ""; transport.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString()).slice(-65536); }); +let connected = false; +let verifierRun; let report; -try { - await exec( - ca65, - [join(root, "tests/conformance/ghidra/6502.s"), "-o", object], - { timeout: 10000 }, - ); - await exec( - ld65, - ["-C", join(root, "tests/conformance/ghidra/6502.cfg"), "-o", path, object], - { timeout: 10000 }, - ); - const bytes = await readFile(path); - // Independently known opcodes and link addresses from the source fixture. - assert.equal(bytes.toString("hex"), "a907201080c90ad003a92a60a9006018690360"); - const sha256 = createHash("sha256").update(bytes).digest("hex"); - await client.connect(transport); - const opened = await call("open_binary", { - path, - format: "raw-binary", - architecture: "6502", - entry_point: "0x008001", - provider_id: "ghidra", - }); - assert.equal(opened.architecture, "6502"); - assert.equal(opened.entry_point, "0x8001"); - const status = await call("binary_session"); - const image = evidence(await call("inspect_native_load_image"), sha256); - assert.equal( - image.normalized_result.status, - "verified", - JSON.stringify(image.normalized_result), - ); - assert.equal(image.normalized_result.architecture, "6502"); - assert.equal(image.normalized_result.base_address, "0x8001"); - assert.equal(image.normalized_result.language_id, "6502:LE:16:default"); - assert.equal(image.normalized_result.compiler_spec_id, "default"); - assert.equal(image.normalized_result.file_bytes, bytes.length); - const memory = evidence( - await call("read_bytes", { address: "0x8001", length: bytes.length }), - sha256, - ); - assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex")); - const procedures = evidence(await call("list_procedures"), sha256); - for (const address of ["0x8001", "0x8010"]) - assert.ok( - procedures.normalized_result.some( - (procedure) => procedure.address === address, - ), +await withVerifierCleanup(async () => { + try { + await exec( + ca65, + [join(root, "tests/conformance/ghidra/6502.s"), "-o", object], + { timeout: 10000 }, ); - const instruction = evidence( - await call("inspect_native_instruction", { address: "0x8003" }), - sha256, - ); - assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR"); - assert.ok( - instruction.normalized_result.references.some( - (reference) => reference.call && reference.target_address === "0x8010", - ), - ); - const pseudo = evidence( - await call("procedure_pseudo_code", { procedure: "0x8010" }), - sha256, - ); - assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u); - const filePseudo = evidence( - await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }), - sha256, - ); - assert.equal(filePseudo.normalized_result, pseudo.normalized_result); - assert.ok( - filePseudo.locations.some( - (location) => location.kind === "file-offset" && location.offset === 15, - ), - ); - const rejected = await client.callTool({ - name: "open_binary", - arguments: { - path, - format: "raw-binary", - architecture: "6502", - entry_point: "0xffff", - }, - }); - assert.equal(rejected.isError, true); - assert.match(JSON.stringify(rejected.content), /6502 address width/u); - const preserved = await call("binary_session"); - assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id); - assert.equal(preserved.entry_point, "0x8001"); - await call("close_binary"); - await client.ping(); - const { stdout } = await exec( - process.execPath, - [ - entrypoint, - "inspect-native-load-image", - path, - "--target-format", - "raw-binary", - "--architecture", - "6502", - "--entry-point", - "0x8001", - "--provider", - "ghidra", - "--json", - ], - { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, - ); - const cli = parseEvidence(JSON.parse(stdout)); - assert.deepEqual(cli.normalized_result, image.normalized_result); - assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest); - assert.equal(cli.subject.digest.sha256, sha256); - const fileCli = await exec( - process.execPath, - [ - entrypoint, - "decompile", - path, - "--file-offset=0xf", - "--target-format", - "raw-binary", - "--architecture", - "6502", - "--entry-point", - "0x8001", - "--provider", - "ghidra", - "--json", - ], - { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, - ); - const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout)); - assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result); - assert.deepEqual(fileCliEvidence.locations, filePseudo.locations); - assert.equal(fileCliEvidence.subject.digest.sha256, sha256); - assert.deepEqual(await readFile(path), bytes); - assert.deepEqual(await readdir(runtime), []); - const lowMemory = []; - // BinaryLoader maps file bytes before trying processor-defined blocks. Prove - // that overlapping zero-page/stack definitions never overlay the selected image. - for (const [entry, fullAddressSpace] of [ - [0xff, false], - [0, true], - ]) { - const lowPath = join(workspace, `low-${entry}.bin`); await exec( ld65, [ "-C", join(root, "tests/conformance/ghidra/6502.cfg"), - "--start-addr", - String(entry), "-o", - lowPath, + path, object, ], { timeout: 10000 }, ); - const linked = await readFile(lowPath); - const expectedCode = Buffer.from( - "a907200000c90ad003a92a60a9006018690360", - "hex", + const bytes = await readFile(path); + // Independently known opcodes and link addresses from the source fixture. + assert.equal( + bytes.toString("hex"), + "a907201080c90ad003a92a60a9006018690360", ); - expectedCode.writeUInt16LE(entry + 15, 3); - assert.deepEqual(linked, expectedCode); - const lowBytes = fullAddressSpace - ? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)]) - : linked; - await writeFile(lowPath, lowBytes); - const lowSha256 = createHash("sha256").update(lowBytes).digest("hex"); - const lowEntry = `0x${entry.toString(16)}`; - const lowOpened = await call("open_binary", { - path: lowPath, + const sha256 = createHash("sha256").update(bytes).digest("hex"); + await client.connect(transport); + connected = true; + const opened = await call("open_binary", { + path, format: "raw-binary", architecture: "6502", - entry_point: lowEntry, + entry_point: "0x008001", provider_id: "ghidra", }); - assert.equal(lowOpened.entry_point, lowEntry); - const lowImage = evidence( - await call("inspect_native_load_image"), - lowSha256, - ); + assert.equal(opened.architecture, "6502"); + assert.equal(opened.entry_point, "0x8001"); + const status = await call("binary_session"); + const image = evidence(await call("inspect_native_load_image"), sha256); assert.equal( - lowImage.normalized_result.status, + image.normalized_result.status, "verified", - JSON.stringify(lowImage.normalized_result), + JSON.stringify(image.normalized_result), ); - assert.equal(lowImage.normalized_result.base_address, lowEntry); - assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length); - const fileMappings = - lowImage.normalized_result.observations.mappings.filter( - (mapping) => mapping.source_file_index !== null, + assert.equal(image.normalized_result.architecture, "6502"); + assert.equal(image.normalized_result.base_address, "0x8001"); + assert.equal(image.normalized_result.language_id, "6502:LE:16:default"); + assert.equal(image.normalized_result.compiler_spec_id, "default"); + assert.equal(image.normalized_result.file_bytes, bytes.length); + const memory = evidence( + await call("read_bytes", { address: "0x8001", length: bytes.length }), + sha256, + ); + assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex")); + const procedures = evidence(await call("list_procedures"), sha256); + for (const address of ["0x8001", "0x8010"]) + assert.ok( + procedures.normalized_result.some( + (procedure) => procedure.address === address, + ), ); - assert.equal(fileMappings.length, 1); - assert.equal( - fileMappings[0].address_space, - lowImage.normalized_result.observations.default_address_space, + const instruction = evidence( + await call("inspect_native_instruction", { address: "0x8003" }), + sha256, ); - assert.equal(fileMappings[0].overlay, false); - assert.equal(fileMappings[0].start, lowEntry); - assert.equal(fileMappings[0].length, lowBytes.length); - const lowRead = evidence( - await call("read_bytes", { address: lowEntry, length: linked.length }), - lowSha256, - ); - assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex")); - const lowCall = evidence( - await call("inspect_native_instruction", { - address: `0x${(entry + 2).toString(16)}`, - }), - lowSha256, - ); - assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR"); + assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR"); assert.ok( - lowCall.normalized_result.references.some( - (reference) => - reference.call && - reference.target_address === `0x${(entry + 15).toString(16)}`, + instruction.normalized_result.references.some( + (reference) => reference.call && reference.target_address === "0x8010", ), ); + const pseudo = evidence( + await call("procedure_pseudo_code", { procedure: "0x8010" }), + sha256, + ); + assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u); + const filePseudo = evidence( + await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }), + sha256, + ); + assert.equal(filePseudo.normalized_result, pseudo.normalized_result); + assert.ok( + filePseudo.locations.some( + (location) => location.kind === "file-offset" && location.offset === 15, + ), + ); + const rejected = await client.callTool({ + name: "open_binary", + arguments: { + path, + format: "raw-binary", + architecture: "6502", + entry_point: "0xffff", + }, + }); + assert.equal(rejected.isError, true); + assert.match(JSON.stringify(rejected.content), /6502 address width/u); + const preserved = await call("binary_session"); + assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id); + assert.equal(preserved.entry_point, "0x8001"); await call("close_binary"); - const lowCli = await exec( + await client.ping(); + const { stdout } = await exec( process.execPath, [ entrypoint, "inspect-native-load-image", - lowPath, - "--target-format", - "raw-binary", - "--architecture", - "6502", - "--entry-point", - lowEntry, - "--provider", - "ghidra", - "--json", - ], - { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, - ); - const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256); - assert.deepEqual( - lowCliEvidence.normalized_result, - lowImage.normalized_result, - ); - assert.equal( - lowCliEvidence.analysis_profile.digest, - lowImage.analysis_profile.digest, - ); - assert.deepEqual(await readFile(lowPath), lowBytes); - assert.deepEqual(await readdir(runtime), []); - lowMemory.push({ - entry: lowEntry, - bytes: lowBytes.length, - sha256: lowSha256, - }); - } - let windowsReparseAdmission = "not-applicable"; - if (process.platform === "win32") { - const junction = join(workspace, "linked"); - await symlink(workspace, junction, "junction"); - try { - const linkedPath = join(junction, "image.bin"); - // Reopening the same bytes through a junction must replace the direct session. - await call("open_binary", { path, - format: "raw-binary", - architecture: "6502", - entry_point: "0x8001", - provider_id: "ghidra", - }); - const directImage = evidence( - await call("inspect_native_load_image"), - sha256, - ); - assert.equal(directImage.normalized_result.status, "verified"); - // Canonical file identity must not bypass admission of the new path. - await call("open_binary", { - path: linkedPath, - format: "raw-binary", - architecture: "6502", - entry_point: "0x8001", - provider_id: "ghidra", - }); - // open_binary selects a lazy session; the first analysis admits the snapshot. - const linkedImage = await client.callTool( - { name: "inspect_native_load_image", arguments: {} }, - { timeout: 240000 }, - ); - assert.equal(linkedImage.isError, true); - assert.match( - JSON.stringify(linkedImage), - /Reparse path component is unsupported/u, - ); - await call("close_binary"); - const snapshotPath = join(workspace, "direct-snapshot.json"); - const cliArgs = (selectedPath) => [ - entrypoint, - "analyze", - selectedPath, "--target-format", "raw-binary", "--architecture", @@ -376,88 +189,310 @@ try { "0x8001", "--provider", "ghidra", - "--snapshot", - snapshotPath, "--json", - ]; - const saved = await exec(process.execPath, cliArgs(path), { - env, - timeout: 240000, - maxBuffer: 16 * 1024 * 1024, - }); - assert.equal( - evidence(JSON.parse(saved.stdout), sha256).operation, - "binary_overview", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const cli = parseEvidence(JSON.parse(stdout)); + assert.deepEqual(cli.normalized_result, image.normalized_result); + assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest); + assert.equal(cli.subject.digest.sha256, sha256); + const fileCli = await exec( + process.execPath, + [ + entrypoint, + "decompile", + path, + "--file-offset=0xf", + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + "0x8001", + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout)); + assert.equal( + fileCliEvidence.normalized_result, + filePseudo.normalized_result, + ); + assert.deepEqual(fileCliEvidence.locations, filePseudo.locations); + assert.equal(fileCliEvidence.subject.digest.sha256, sha256); + assert.deepEqual(await readFile(path), bytes); + assert.deepEqual(await readdir(runtime), []); + const lowMemory = []; + // BinaryLoader maps file bytes before trying processor-defined blocks. Prove + // that overlapping zero-page/stack definitions never overlay the selected image. + for (const [entry, fullAddressSpace] of [ + [0xff, false], + [0, true], + ]) { + const lowPath = join(workspace, `low-${entry}.bin`); + await exec( + ld65, + [ + "-C", + join(root, "tests/conformance/ghidra/6502.cfg"), + "--start-addr", + String(entry), + "-o", + lowPath, + object, + ], + { timeout: 10000 }, ); - const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8")); + const linked = await readFile(lowPath); + const expectedCode = Buffer.from( + "a907200000c90ad003a92a60a9006018690360", + "hex", + ); + expectedCode.writeUInt16LE(entry + 15, 3); + assert.deepEqual(linked, expectedCode); + const lowBytes = fullAddressSpace + ? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)]) + : linked; + await writeFile(lowPath, lowBytes); + const lowSha256 = createHash("sha256").update(lowBytes).digest("hex"); + const lowEntry = `0x${entry.toString(16)}`; + const lowOpened = await call("open_binary", { + path: lowPath, + format: "raw-binary", + architecture: "6502", + entry_point: lowEntry, + provider_id: "ghidra", + }); + assert.equal(lowOpened.entry_point, lowEntry); + const lowImage = evidence( + await call("inspect_native_load_image"), + lowSha256, + ); + assert.equal( + lowImage.normalized_result.status, + "verified", + JSON.stringify(lowImage.normalized_result), + ); + assert.equal(lowImage.normalized_result.base_address, lowEntry); + assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length); + const fileMappings = + lowImage.normalized_result.observations.mappings.filter( + (mapping) => mapping.source_file_index !== null, + ); + assert.equal(fileMappings.length, 1); + assert.equal( + fileMappings[0].address_space, + lowImage.normalized_result.observations.default_address_space, + ); + assert.equal(fileMappings[0].overlay, false); + assert.equal(fileMappings[0].start, lowEntry); + assert.equal(fileMappings[0].length, lowBytes.length); + const lowRead = evidence( + await call("read_bytes", { address: lowEntry, length: linked.length }), + lowSha256, + ); + assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex")); + const lowCall = evidence( + await call("inspect_native_instruction", { + address: `0x${(entry + 2).toString(16)}`, + }), + lowSha256, + ); + assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR"); assert.ok( - savedSnapshot.workflow_entries.some( - ({ operation }) => operation === "binary_overview", + lowCall.normalized_result.references.some( + (reference) => + reference.call && + reference.target_address === `0x${(entry + 15).toString(16)}`, ), ); + await call("close_binary"); + const lowCli = await exec( + process.execPath, + [ + entrypoint, + "inspect-native-load-image", + lowPath, + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + lowEntry, + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256); + assert.deepEqual( + lowCliEvidence.normalized_result, + lowImage.normalized_result, + ); + assert.equal( + lowCliEvidence.analysis_profile.digest, + lowImage.analysis_profile.digest, + ); + assert.deepEqual(await readFile(lowPath), lowBytes); assert.deepEqual(await readdir(runtime), []); - // Replaying a matching snapshot must still reach native path admission. - await assert.rejects( - exec(process.execPath, cliArgs(linkedPath), { + lowMemory.push({ + entry: lowEntry, + bytes: lowBytes.length, + sha256: lowSha256, + }); + } + let windowsReparseAdmission = "not-applicable"; + if (process.platform === "win32") { + const junction = join(workspace, "linked"); + await symlink(workspace, junction, "junction"); + try { + const linkedPath = join(junction, "image.bin"); + // Reopening the same bytes through a junction must replace the direct session. + await call("open_binary", { + path, + format: "raw-binary", + architecture: "6502", + entry_point: "0x8001", + provider_id: "ghidra", + }); + const directImage = evidence( + await call("inspect_native_load_image"), + sha256, + ); + assert.equal(directImage.normalized_result.status, "verified"); + // Canonical file identity must not bypass admission of the new path. + await call("open_binary", { + path: linkedPath, + format: "raw-binary", + architecture: "6502", + entry_point: "0x8001", + provider_id: "ghidra", + }); + // open_binary selects a lazy session; the first analysis admits the snapshot. + const linkedImage = await client.callTool( + { name: "inspect_native_load_image", arguments: {} }, + { timeout: 240000 }, + ); + assert.equal(linkedImage.isError, true); + assert.match( + JSON.stringify(linkedImage), + /Reparse path component is unsupported/u, + ); + await call("close_binary"); + const snapshotPath = join(workspace, "direct-snapshot.json"); + const cliArgs = (selectedPath) => [ + entrypoint, + "analyze", + selectedPath, + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + "0x8001", + "--provider", + "ghidra", + "--snapshot", + snapshotPath, + "--json", + ]; + const saved = await exec(process.execPath, cliArgs(path), { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024, - }), - (cause) => { - assert.match( - cause.stdout + cause.stderr, - /Reparse path component is unsupported/u, - ); - return true; - }, - ); - assert.deepEqual(await readFile(path), bytes); - assert.deepEqual(await readdir(runtime), []); - windowsReparseAdmission = "cli-snapshot-mcp-rejected"; - } finally { - await unlink(junction); + }); + assert.equal( + evidence(JSON.parse(saved.stdout), sha256).operation, + "binary_overview", + ); + const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8")); + assert.ok( + savedSnapshot.workflow_entries.some( + ({ operation }) => operation === "binary_overview", + ), + ); + assert.deepEqual(await readdir(runtime), []); + // Replaying a matching snapshot must still reach native path admission. + await assert.rejects( + exec(process.execPath, cliArgs(linkedPath), { + env, + timeout: 240000, + maxBuffer: 16 * 1024 * 1024, + }), + (cause) => { + assert.match( + cause.stdout + cause.stderr, + /Reparse path component is unsupported/u, + ); + return true; + }, + ); + assert.deepEqual(await readFile(path), bytes); + assert.deepEqual(await readdir(runtime), []); + windowsReparseAdmission = "cli-snapshot-mcp-rejected"; + } finally { + await unlink(junction); + } } + report = { + ok: true, + mocked: false, + lane: "ghidra-6502", + host: process.platform, + toolchain, + provider: image.provider, + target: { + sha256, + bytes: bytes.length, + architecture: "6502", + entry: "0x8001", + }, + cli_mcp_load_image_parity: true, + instructions_and_call_reference: true, + decompilation: true, + file_offset_decompilation_cli_mcp: true, + invalid_mapping_preserves_session: true, + low_memory_cli_mcp_parity: lowMemory, + windows_reparse_admission: windowsReparseAdmission, + source_unchanged: true, + runtime_cleanup: "complete", + }; + } catch (cause) { + if (stderr) process.stderr.write(stderr); + throw cause; } - report = { - ok: true, - mocked: false, - lane: "ghidra-6502", - host: process.platform, - toolchain, - provider: image.provider, - target: { - sha256, - bytes: bytes.length, - architecture: "6502", - entry: "0x8001", - }, - cli_mcp_load_image_parity: true, - instructions_and_call_reference: true, - decompilation: true, - file_offset_decompilation_cli_mcp: true, - invalid_mapping_preserves_session: true, - low_memory_cli_mcp_parity: lowMemory, - windows_reparse_admission: windowsReparseAdmission, - source_unchanged: true, - runtime_cleanup: "complete", - }; -} catch (cause) { - if (stderr) process.stderr.write(stderr); - throw cause; -} finally { - try { - await client.close(); - } finally { - await transport.close(); - } -} -const verifierRun = await completeVerifierRun(run); +}, [ + async () => { + await closeVerifierResources([ + async () => { + if (connected) await call("close_binary", {}); + }, + () => client.close(), + () => transport.close(), + ]); + assert.deepEqual( + (await readdir(runtime)).filter((name) => name.startsWith("rea-ghidra-")), + [], + "Owned Ghidra runtime survived close", + ); + verifierRun = await completeVerifierRun(run); + if (process.platform !== "win32") { + assert.equal(verifierRun.process_lineage.status, "verified"); + assert.deepEqual(verifierRun.process_lineage.descendants, []); + } + await rm(workspace, { recursive: true, force: true }); + }, +]); +verifierRun = await completeVerifierRun(run); if (process.platform !== "win32") { assert.equal(verifierRun.process_lineage.status, "verified"); assert.deepEqual(verifierRun.process_lineage.descendants, []); } // Keep the workspace for diagnosis unless verification and cleanup both pass. -await rm(workspace, { recursive: true, force: true }); process.stdout.write( JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n", ); diff --git a/scripts/verify-real-ghidra-checkpoints.mjs b/scripts/verify-real-ghidra-checkpoints.mjs index 395ebe85a7..3b5788fce4 100644 --- a/scripts/verify-real-ghidra-checkpoints.mjs +++ b/scripts/verify-real-ghidra-checkpoints.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { createVerifierCommands } from "./lib/verify-commands.mjs"; +import { createVerifierMcpOwner } from "./lib/verifier-mcp-owner.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; @@ -61,6 +63,7 @@ for (const name of [ "REA_GHIDRA_DISABLED_ANALYZERS", ]) delete env[name]; +const compilerCommands = await createVerifierCommands(); const report = { verifier: "ghidra-owned-checkpoints", phases: [], @@ -119,6 +122,7 @@ async function refuseCli(procedure, flags, expected) { return refusal; } async function connect(environment = {}) { + const owner = createVerifierMcpOwner(); const client = new Client({ name: "rea-ghidra-checkpoint-verifier", version: "1", @@ -130,23 +134,15 @@ async function connect(environment = {}) { stderr: "pipe", }); transport.stderr?.on("data", () => {}); - try { - await client.connect(transport); - } catch (cause) { - await client.close(); - await transport.close(); - throw cause; - } + compilerCommands.adopt(owner, "checkpoint MCP connection"); + await owner.open(client, transport); const raw = (name, args = {}) => client.callTool({ name, arguments: args }, { timeout: 240000 }); return { raw, call: async (name, args = {}) => requireMcpOperationResult(await raw(name, args), name), - close: async () => { - await client.close(); - await transport.close(); - }, + close: () => owner.close(), }; } let manifestPath; @@ -160,15 +156,11 @@ __attribute__((noinline)) int rea_checkpoint_leaf(CheckpointRecord *record) { re int main(void) { CheckpointRecord record = { 3, 11 }; return rea_checkpoint_leaf(&record); } `, ); - await exec("cc", [ - "-O0", - "-g", - "-gdwarf-4", - "-fno-inline", - sourcePath, - "-o", - target, - ]); + await compilerCommands.exec( + "cc", + ["-O0", "-g", "-gdwarf-4", "-fno-inline", sourcePath, "-o", target], + { timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }, + ); const targetDigest = await sha(target); report.target = { sha256: targetDigest, @@ -466,9 +458,10 @@ int main(void) { CheckpointRecord record = { 3, 11 }; return rea_checkpoint_leaf report.failure_stderr = cause.stderr.slice(-4000); process.exitCode = 1; } finally { - report.run = await completeVerifierRun(run); let cleanupVerified = false; try { + await compilerCommands.close(); + report.run = await completeVerifierRun(run); assertVerifierCleanupComplete(report.run.process_lineage); cleanupVerified = true; } catch (cause) { diff --git a/scripts/verify-real-ghidra-com.mjs b/scripts/verify-real-ghidra-com.mjs index 5d0b18f81c..83605e20a8 100644 --- a/scripts/verify-real-ghidra-com.mjs +++ b/scripts/verify-real-ghidra-com.mjs @@ -1,4 +1,8 @@ #!/usr/bin/env node +import { + closeVerifierResources, + withVerifierCleanup, +} from "./lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { requireMcpLifecycleResult, @@ -7,7 +11,14 @@ import { } from "./lib/mcp-verifier-results.mjs"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -88,386 +99,404 @@ const transport = new StdioClientTransport({ stderr: "pipe", }); const client = new Client({ name: "rea-real-com-proof", version: "1" }); -let opened = false; let stderr = ""; +let connected = false; +let verifierRun; let report; transport.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString()).slice(-65536); }); -try { - await client.connect(transport); +await withVerifierCleanup(async () => { + try { + await client.connect(transport); + connected = true; - const untyped = await client.callTool({ - name: "open_binary", - arguments: { path: targetPath, provider_id: "ghidra" }, - }); - assert.equal( - untyped.isError, - true, - "Headerless bytes were guessed as COM without explicit interpretation", - ); - const target = await call("open_binary", { - path: targetPath, - provider_id: "ghidra", - format: "dos-com", - }); - opened = true; - assert.equal(target.format, "dos-com"); - assert.equal(target.architecture, "x86"); - assert.equal(target.sha256, sha256); - const image = await call("inspect_native_load_image"); - assert.equal( - image.status, - "verified", - JSON.stringify(image.checks?.filter((c) => !c.matched)), - ); - assert.equal(image.format, "dos-com"); - assert.equal(image.header_bytes, 0); - assert.equal(image.module_bytes, fixture.bytes.length); - assert.equal(image.entry.linear_address, fixture.entry); - assert.equal(image.entry.offset, 256); - assert.equal(image.observations.entry_context[0].registers.length, 4); - const memory = await call("read_bytes", { - address: fixture.entry, - length: fixture.bytes.length, - }); - assert.equal(memory.bytes_hex, fixture.bytes.toString("hex")); - assert.equal(memory.complete, true); - const mapping = await call("address_to_file_offset", { - address: fixture.near, - }); - assert.equal(mapping.file_offset, 32); - const psp = await call("read_bytes", { address: "0x10000", length: 16 }); - assert.equal(psp.returned_bytes, 0); - assert.equal(psp.complete, false); - const tail = await call("read_bytes", { address: "0x1013e", length: 8 }); - assert.equal(tail.returned_bytes, 2); - assert.equal(tail.complete, false); - const procedures = await call("list_procedures"); - assert.ok(procedures.some((p) => p.address === fixture.entry)); - assert.ok(procedures.some((p) => p.address === fixture.near)); - const dossier = functionDossierSchema.parse( - await call("analyze_function", { - procedure: fixture.entry, - detail: "complete", - }), - ); - assert.ok(dossier.pseudocode.trim().length > 0); - assert.equal(dossier.procedure.body.available, true); - const instruction = await call("inspect_native_instruction", { - address: fixture.entry, - }); - assert.equal( - instruction.bytes.replaceAll(" ", "").toLowerCase(), - fixture.entry_hex, - ); - // This NOP follows the entry's terminating INT 20h and is not reached by - // either function. It remains valid instruction bytes outside the listing. - const unlistedAddress = "0x1013f"; - const unlistedBytes = await call("read_bytes", { - address: unlistedAddress, - length: 1, - }); - assert.equal(unlistedBytes.bytes_hex, "90"); - const unlistedResponse = await client.callTool({ - name: "inspect_native_instruction", - arguments: { address: unlistedAddress }, - }); - const unlistedEvidence = parseEvidence( - requireMcpOutput(unlistedResponse, "inspect_native_instruction"), - ); - const unlisted = unlistedEvidence.normalized_result; - assert.equal(unlisted.status, "not-in-listing"); - assert.equal(unlisted.bytes, null); - assert.ok( - unlisted.limitations.some((item) => - item.includes("did not attempt a decode"), - ), - ); - const tools = (await client.listTools()).tools; - const advertised = tools.find( - (tool) => tool.name === "inspect_native_instruction", - ); - assert.ok(advertised?.outputSchema); - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - assert.equal(ajv.validateSchema(advertised.outputSchema), true); - assert.equal( - ajv.validate(advertised.outputSchema, unlistedResponse.structuredContent), - true, - JSON.stringify(ajv.errors), - ); - assert.ok( - nativeInstructionSchema.safeParse({ ...unlisted, status: "undecodable" }) - .success, - ); - const contextDefinition = tools.find( - (tool) => tool.name === "inspect_address_context", - ); - assert.ok(contextDefinition?.outputSchema); - const documents = await call("list_documents"); - assert.equal(documents.length, 1); - const document = documents[0]; - for (const input of [ - { address: fixture.entry, document: `${document}-not-a-program` }, - { address: "not-an-address", document }, - ]) { - const direct = requireMcpToolError( - await client.callTool({ name: "address_name", arguments: input }), + const untyped = await client.callTool({ + name: "open_binary", + arguments: { path: targetPath, provider_id: "ghidra" }, + }); + assert.equal( + untyped.isError, + true, + "Headerless bytes were guessed as COM without explicit interpretation", ); - const response = await client.callTool( - { name: "inspect_address_context", arguments: input }, - { timeout: 240000, toolDefinition: contextDefinition }, + const target = await call("open_binary", { + path: targetPath, + provider_id: "ghidra", + format: "dos-com", + }); + assert.equal(target.format, "dos-com"); + assert.equal(target.architecture, "x86"); + assert.equal(target.sha256, sha256); + const image = await call("inspect_native_load_image"); + assert.equal( + image.status, + "verified", + JSON.stringify(image.checks?.filter((c) => !c.matched)), ); - const context = requireMcpLifecycleResult( - response, - "inspect_address_context", - ); - assert.equal(context.name.state, "unavailable"); - assert.equal(context.name.error.code, "invalid_request"); - assert.deepEqual(context.name.error.details.issues, direct.details.issues); - assert.equal(context.name.reason, direct.message); - assert.equal(context.name.remediation, direct.remediation.action); - } - assert.equal( - (await call("resolve_native_call_targets", { address: unlistedAddress })) - .status, - "unavailable", - ); - assert.deepEqual( - (await cli("inspect-native-instruction", unlistedAddress)) - .normalized_result, - unlisted, - ); - const cliImage = await cli("inspect-native-load-image"); - const stable = (x) => ({ - ...x, - observations: { - ...x.observations, - source_files: x.observations.source_files.map( - ({ name: _name, ...file }) => file, - ), - }, - }); - assert.deepEqual(stable(cliImage.normalized_result), stable(image)); - assert.deepEqual( - ( - await cli("read-bytes", fixture.entry, [ - "--length", - String(fixture.bytes.length), - ]) - ).normalized_result, - memory, - ); - assert.deepEqual( - (await cli("address-to-file-offset", fixture.near)).normalized_result, - mapping, - ); - const changes = { - procedure: fixture.entry, - name: "rea_entry", - comment: "Regular\nUnicode 註記", - inline_comment: "Inline finding", - }; - // Prime provider inventories and the session snapshot before edits. - await call("list_names"); - const updated = await call("annotate_native_function", changes); - assert.equal(updated.annotations.name, changes.name); - assert.equal(updated.annotations.comment, changes.comment); - assert.equal(updated.annotations.inline_comment, changes.inline_comment); - assert.equal(updated.dossier.procedure.name, changes.name); - assert.ok(updated.dossier.pseudocode.includes(changes.name)); - // Exercise the documented recovery through the public MCP catalog on real - // Ghidra, rather than assuming the provider exposes direct comment methods. - const contextCall = async (name, args) => { - const definition = tools.find((tool) => tool.name === name); - assert.ok(definition?.outputSchema, `${name} must be advertised`); - const response = await client.callTool( - { name, arguments: args }, - { timeout: 240000, toolDefinition: definition }, - ); - return requireMcpOutput(response, name); - }; - const context = ( - await contextCall("inspect_address_context", { + assert.equal(image.format, "dos-com"); + assert.equal(image.header_bytes, 0); + assert.equal(image.module_bytes, fixture.bytes.length); + assert.equal(image.entry.linear_address, fixture.entry); + assert.equal(image.entry.offset, 256); + assert.equal(image.observations.entry_context[0].registers.length, 4); + const memory = await call("read_bytes", { address: fixture.entry, - }) - ).result; - assert.equal(context.procedure.state, "available"); - assert.equal(context.procedure.value.found, true); - for (const facet of [context.comment, context.inline_comment]) { - assert.equal(facet.state, "unavailable"); - assert.match( - facet.remediation, - /analyze_function.*parent_evidence_id.*inspect_analysis_view/u, - ); - } - assert.match(context.bookmarks.remediation, /binary_session/u); - const summary = parseEvidence( - await contextCall("analyze_function", { - procedure: context.procedure.value.procedure.address, - }), - ).normalized_result; - const comments = parseEvidence( - await contextCall("inspect_analysis_view", { - source: { - kind: "retained-evidence", - evidence_id: summary.parent_evidence_id, - }, - view: { kind: "native", facet: "comments" }, - }), - ).normalized_result; - for (const [kind, text] of [ - ["comment", changes.comment], - ["inline", changes.inline_comment], - ]) - assert.ok( - comments.item.some( - (item) => - item.address === context.procedure.value.query_address && - item.kind === kind && - item.text === text, - ), - ); - const outside = ( - await contextCall("inspect_address_context", { - address: unlistedAddress, - }) - ).result; - assert.equal(outside.procedure.state, "available"); - assert.equal(outside.procedure.value.found, false); - assert.equal(outside.comment.state, "unavailable"); - assert.match(outside.comment.remediation, /binary_session/u); - assert.equal( - await call("procedure_address", { procedure: changes.name }), - fixture.entry, - ); - assert.equal( - await call("address_name", { address: fixture.entry }), - changes.name, - ); - assert.ok( - (await call("list_procedures")).some((p) => p.value === changes.name), - ); - assert.ok((await call("list_names")).some((p) => p.value === changes.name)); - assert.equal( - ( + length: fixture.bytes.length, + }); + assert.equal(memory.bytes_hex, fixture.bytes.toString("hex")); + assert.equal(memory.complete, true); + const mapping = await call("address_to_file_offset", { + address: fixture.near, + }); + assert.equal(mapping.file_offset, 32); + const psp = await call("read_bytes", { address: "0x10000", length: 16 }); + assert.equal(psp.returned_bytes, 0); + assert.equal(psp.complete, false); + const tail = await call("read_bytes", { address: "0x1013e", length: 8 }); + assert.equal(tail.returned_bytes, 2); + assert.equal(tail.complete, false); + const procedures = await call("list_procedures"); + assert.ok(procedures.some((p) => p.address === fixture.entry)); + assert.ok(procedures.some((p) => p.address === fixture.near)); + const dossier = functionDossierSchema.parse( await call("analyze_function", { procedure: fixture.entry, detail: "complete", - }) - ).procedure.name, - changes.name, - ); - const rejected = await client.callTool({ - name: "annotate_native_function", - arguments: { + }), + ); + assert.ok(dossier.pseudocode.trim().length > 0); + assert.equal(dossier.procedure.body.available, true); + const instruction = await call("inspect_native_instruction", { + address: fixture.entry, + }); + assert.equal( + instruction.bytes.replaceAll(" ", "").toLowerCase(), + fixture.entry_hex, + ); + // This NOP follows the entry's terminating INT 20h and is not reached by + // either function. It remains valid instruction bytes outside the listing. + const unlistedAddress = "0x1013f"; + const unlistedBytes = await call("read_bytes", { + address: unlistedAddress, + length: 1, + }); + assert.equal(unlistedBytes.bytes_hex, "90"); + const unlistedResponse = await client.callTool({ + name: "inspect_native_instruction", + arguments: { address: unlistedAddress }, + }); + const unlistedEvidence = parseEvidence( + requireMcpOutput(unlistedResponse, "inspect_native_instruction"), + ); + const unlisted = unlistedEvidence.normalized_result; + assert.equal(unlisted.status, "not-in-listing"); + assert.equal(unlisted.bytes, null); + assert.ok( + unlisted.limitations.some((item) => + item.includes("did not attempt a decode"), + ), + ); + const tools = (await client.listTools()).tools; + const advertised = tools.find( + (tool) => tool.name === "inspect_native_instruction", + ); + assert.ok(advertised?.outputSchema); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + assert.equal(ajv.validateSchema(advertised.outputSchema), true); + assert.equal( + ajv.validate(advertised.outputSchema, unlistedResponse.structuredContent), + true, + JSON.stringify(ajv.errors), + ); + assert.ok( + nativeInstructionSchema.safeParse({ ...unlisted, status: "undecodable" }) + .success, + ); + const contextDefinition = tools.find( + (tool) => tool.name === "inspect_address_context", + ); + assert.ok(contextDefinition?.outputSchema); + const documents = await call("list_documents"); + assert.equal(documents.length, 1); + const document = documents[0]; + for (const input of [ + { address: fixture.entry, document: `${document}-not-a-program` }, + { address: "not-an-address", document }, + ]) { + const direct = requireMcpToolError( + await client.callTool({ name: "address_name", arguments: input }), + ); + const response = await client.callTool( + { name: "inspect_address_context", arguments: input }, + { timeout: 240000, toolDefinition: contextDefinition }, + ); + const context = requireMcpLifecycleResult( + response, + "inspect_address_context", + ); + assert.equal(context.name.state, "unavailable"); + assert.equal(context.name.error.code, "invalid_request"); + assert.deepEqual( + context.name.error.details.issues, + direct.details.issues, + ); + assert.equal(context.name.reason, direct.message); + assert.equal(context.name.remediation, direct.remediation.action); + } + assert.equal( + (await call("resolve_native_call_targets", { address: unlistedAddress })) + .status, + "unavailable", + ); + assert.deepEqual( + (await cli("inspect-native-instruction", unlistedAddress)) + .normalized_result, + unlisted, + ); + const cliImage = await cli("inspect-native-load-image"); + const stable = (x) => ({ + ...x, + observations: { + ...x.observations, + source_files: x.observations.source_files.map( + ({ name: _name, ...file }) => file, + ), + }, + }); + assert.deepEqual(stable(cliImage.normalized_result), stable(image)); + assert.deepEqual( + ( + await cli("read-bytes", fixture.entry, [ + "--length", + String(fixture.bytes.length), + ]) + ).normalized_result, + memory, + ); + assert.deepEqual( + (await cli("address-to-file-offset", fixture.near)).normalized_result, + mapping, + ); + const changes = { procedure: fixture.entry, - comment: "MUST ROLL BACK", - name: "invalid name\n", - }, - }); - assert.equal( - rejected.isError, - true, - "Ghidra accepted an invalid function name", - ); - const retained = await call("annotate_native_function", { - procedure: fixture.entry, - name: changes.name, - }); - assert.equal( - retained.annotations.comment, - changes.comment, - "Failed edit leaked its earlier comment write", - ); - assert.equal(retained.annotations.inline_comment, changes.inline_comment); - assert.equal(retained.dossier.procedure.name, changes.name); - await verifyGhidraAddressBoundaries(client, fixture.entry, cli); - const cleared = await call("annotate_native_function", { - procedure: fixture.entry, - comment: "", - inline_comment: "", - }); - assert.equal(cleared.annotations.comment, null); - assert.equal(cleared.annotations.inline_comment, null); - assert.ok(!cleared.dossier.comments.some((c) => c.address === fixture.entry)); - assert.equal( - ( - await call("read_bytes", { + name: "rea_entry", + comment: "Regular\nUnicode 註記", + inline_comment: "Inline finding", + }; + // Prime provider inventories and the session snapshot before edits. + await call("list_names"); + const updated = await call("annotate_native_function", changes); + assert.equal(updated.annotations.name, changes.name); + assert.equal(updated.annotations.comment, changes.comment); + assert.equal(updated.annotations.inline_comment, changes.inline_comment); + assert.equal(updated.dossier.procedure.name, changes.name); + assert.ok(updated.dossier.pseudocode.includes(changes.name)); + // Exercise the documented recovery through the public MCP catalog on real + // Ghidra, rather than assuming the provider exposes direct comment methods. + const contextCall = async (name, args) => { + const definition = tools.find((tool) => tool.name === name); + assert.ok(definition?.outputSchema, `${name} must be advertised`); + const response = await client.callTool( + { name, arguments: args }, + { timeout: 240000, toolDefinition: definition }, + ); + return requireMcpOutput(response, name); + }; + const context = ( + await contextCall("inspect_address_context", { address: fixture.entry, - length: fixture.bytes.length, }) - ).bytes_hex, - fixture.bytes.toString("hex"), - ); - assert.equal((await call("inspect_native_load_image")).status, "verified"); - const cliAnnotated = await cli("annotate-native-function", fixture.entry, [ - "--name", - changes.name, - "--comment", - changes.comment, - "--inline-comment", - changes.inline_comment, - ]); - assert.deepEqual(cliAnnotated.normalized_result, updated); - if (captureDirectory !== undefined) - await writeFile( - join(captureDirectory, "image.json"), - JSON.stringify(image, null, 2), - { mode: 0o600 }, + ).result; + assert.equal(context.procedure.state, "available"); + assert.equal(context.procedure.value.found, true); + for (const facet of [context.comment, context.inline_comment]) { + assert.equal(facet.state, "unavailable"); + assert.match( + facet.remediation, + /analyze_function.*parent_evidence_id.*inspect_analysis_view/u, + ); + } + assert.match(context.bookmarks.remediation, /binary_session/u); + const summary = parseEvidence( + await contextCall("analyze_function", { + procedure: context.procedure.value.procedure.address, + }), + ).normalized_result; + const comments = parseEvidence( + await contextCall("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { kind: "native", facet: "comments" }, + }), + ).normalized_result; + for (const [kind, text] of [ + ["comment", changes.comment], + ["inline", changes.inline_comment], + ]) + assert.ok( + comments.item.some( + (item) => + item.address === context.procedure.value.query_address && + item.kind === kind && + item.text === text, + ), + ); + const outside = ( + await contextCall("inspect_address_context", { + address: unlistedAddress, + }) + ).result; + assert.equal(outside.procedure.state, "available"); + assert.equal(outside.procedure.value.found, false); + assert.equal(outside.comment.state, "unavailable"); + assert.match(outside.comment.remediation, /binary_session/u); + assert.equal( + await call("procedure_address", { procedure: changes.name }), + fixture.entry, ); - await call("close_binary"); - opened = false; - assert.equal(digest(await readFile(targetPath)), sha256); - report = { - ok: true, - mocked: false, - lane: "ghidra-com", - packaged_artifact: packageRoot !== undefined, - host: process.platform, - provider: { id: "ghidra", version: installation.providerVersion }, - target: { format: "dos-com", sha256, bytes: fixture.bytes.length }, - load_image: { - status: image.status, - checks: image.checks.length, - entry: fixture.entry, - entry_context: image.observations.entry_context, - }, - transport: { cli: true, stdio_mcp: true }, - memory_evidence: { - complete_file_read: true, - source_offset: 32, - psp_unmapped: true, - partial_read_bytes: 2, - }, - functions: { entry: true, near: true, pseudocode_nonempty: true }, - annotations: { - atomic_rollback: true, - fresh_inventories: true, - fresh_decompiler: true, - clear_comments: true, - cli_mcp_parity: true, - address_context_comment_recovery: true, - }, - context_input_errors_preserved: true, - original_unchanged: true, - address_truncation_rejected: true, - limitations: image.limitations, - }; -} catch (error) { - if (stderr.trim()) process.stderr.write(stderr); - throw error; -} finally { - if (opened) - await call("close_binary").catch((error) => - process.stderr.write(String(error)), + assert.equal( + await call("address_name", { address: fixture.entry }), + changes.name, ); - await client.close(); - await transport.close(); - await rm(workspace, { recursive: true, force: true }); -} + assert.ok( + (await call("list_procedures")).some((p) => p.value === changes.name), + ); + assert.ok((await call("list_names")).some((p) => p.value === changes.name)); + assert.equal( + ( + await call("analyze_function", { + procedure: fixture.entry, + detail: "complete", + }) + ).procedure.name, + changes.name, + ); + const rejected = await client.callTool({ + name: "annotate_native_function", + arguments: { + procedure: fixture.entry, + comment: "MUST ROLL BACK", + name: "invalid name\n", + }, + }); + assert.equal( + rejected.isError, + true, + "Ghidra accepted an invalid function name", + ); + const retained = await call("annotate_native_function", { + procedure: fixture.entry, + name: changes.name, + }); + assert.equal( + retained.annotations.comment, + changes.comment, + "Failed edit leaked its earlier comment write", + ); + assert.equal(retained.annotations.inline_comment, changes.inline_comment); + assert.equal(retained.dossier.procedure.name, changes.name); + await verifyGhidraAddressBoundaries(client, fixture.entry, cli); + const cleared = await call("annotate_native_function", { + procedure: fixture.entry, + comment: "", + inline_comment: "", + }); + assert.equal(cleared.annotations.comment, null); + assert.equal(cleared.annotations.inline_comment, null); + assert.ok( + !cleared.dossier.comments.some((c) => c.address === fixture.entry), + ); + assert.equal( + ( + await call("read_bytes", { + address: fixture.entry, + length: fixture.bytes.length, + }) + ).bytes_hex, + fixture.bytes.toString("hex"), + ); + assert.equal((await call("inspect_native_load_image")).status, "verified"); + const cliAnnotated = await cli("annotate-native-function", fixture.entry, [ + "--name", + changes.name, + "--comment", + changes.comment, + "--inline-comment", + changes.inline_comment, + ]); + assert.deepEqual(cliAnnotated.normalized_result, updated); + if (captureDirectory !== undefined) + await writeFile( + join(captureDirectory, "image.json"), + JSON.stringify(image, null, 2), + { mode: 0o600 }, + ); + await call("close_binary"); + assert.equal(digest(await readFile(targetPath)), sha256); + report = { + ok: true, + mocked: false, + lane: "ghidra-com", + packaged_artifact: packageRoot !== undefined, + host: process.platform, + provider: { id: "ghidra", version: installation.providerVersion }, + target: { format: "dos-com", sha256, bytes: fixture.bytes.length }, + load_image: { + status: image.status, + checks: image.checks.length, + entry: fixture.entry, + entry_context: image.observations.entry_context, + }, + transport: { cli: true, stdio_mcp: true }, + memory_evidence: { + complete_file_read: true, + source_offset: 32, + psp_unmapped: true, + partial_read_bytes: 2, + }, + functions: { entry: true, near: true, pseudocode_nonempty: true }, + annotations: { + atomic_rollback: true, + fresh_inventories: true, + fresh_decompiler: true, + clear_comments: true, + cli_mcp_parity: true, + address_context_comment_recovery: true, + }, + context_input_errors_preserved: true, + original_unchanged: true, + address_truncation_rejected: true, + limitations: image.limitations, + }; + } catch (error) { + if (stderr.trim()) process.stderr.write(stderr); + throw error; + } +}, [ + async () => { + await closeVerifierResources([ + async () => { + if (connected) await call("close_binary", {}); + }, + () => client.close(), + () => transport.close(), + ]); + assert.deepEqual( + (await readdir(runtime)).filter((name) => name.startsWith("rea-ghidra-")), + [], + "Owned Ghidra runtime survived close", + ); + verifierRun = await completeVerifierRun(run); + assert.equal(verifierRun.process_lineage.status, "verified"); + assert.deepEqual(verifierRun.process_lineage.descendants, []); + await rm(workspace, { recursive: true, force: true }); + }, +]); assert.ok(report); -const verifierRun = await completeVerifierRun(run); +verifierRun = await completeVerifierRun(run); assert.equal(verifierRun.process_lineage.status, "verified"); assert.deepEqual(verifierRun.process_lineage.descendants, []); process.stdout.write( diff --git a/scripts/verify-real-ghidra-dos.mjs b/scripts/verify-real-ghidra-dos.mjs index fdfe11fa02..061f97b35d 100644 --- a/scripts/verify-real-ghidra-dos.mjs +++ b/scripts/verify-real-ghidra-dos.mjs @@ -1,4 +1,8 @@ #!/usr/bin/env node +import { + closeVerifierResources, + withVerifierCleanup, +} from "./lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { requireMcpOperationResult, @@ -99,427 +103,440 @@ const transport = new StdioClientTransport({ stderr: "pipe", }); const client = new Client({ name: "rea-real-dos-proof", version: "1" }); -let opened = false; let stderr = ""; +let connected = false; +let verifierRun; let report; -try { - await client.connect(transport); - transport.stderr?.on("data", (chunk) => { - stderr = (stderr + chunk.toString()).slice(-65536); - }); - const target = await call("open_binary", { - path: targetPath, - provider_id: "ghidra", - }); - opened = true; - assert.equal(target.format, "dos-mz"); - assert.equal(target.kind, "executable"); - assert.equal(target.architecture, "x86"); - assert.equal(target.sha256, sha256); - const image = await call("inspect_native_load_image"); - assert.equal( - image.status, - "verified", - JSON.stringify(image.checks?.filter((check) => !check.matched)), - ); - assert.equal(image.target_sha256, sha256); - assert.equal(image.header_bytes, fixture.header_bytes); - assert.equal(image.module_bytes, fixture.module_bytes); - assert.equal(image.entry.linear_address, expected.entry); - assert.ok(image.checks.every((check) => check.matched)); - assert.ok(image.observations.relocations.length > 0); - assert.ok( - image.observations.relocations.every( - ({ symbol }) => symbol === null || typeof symbol === "string", - ), - "Each real Ghidra relocation must record its symbol observation", - ); - const cliImage = await cliEvidence("inspect-native-load-image"); - // Snapshot filenames are session-specific. Every measured byte/range/check is stable. - const stableImage = (value) => ({ - ...value, - observations: { - ...value.observations, - source_files: value.observations.source_files.map( - ({ name: _name, ...file }) => file, - ), - }, - }); - assert.deepEqual(stableImage(cliImage.normalized_result), stableImage(image)); - const mapped = await call("address_to_file_offset", { - address: expected.entry, - }); - assert.equal(mapped.file_offset, fixture.header_bytes); - const headerMapping = await call("address_to_file_offset", { - address: "HEADER:0x0", - }); - assert.equal(headerMapping.file_offset, 0); - const fileRelocation = await call("address_to_file_offset", { - address: address(fixture.relocation_offset), - }); - assert.equal( - fileRelocation.file_offset, - fixture.header_bytes + fixture.relocation_offset, - ); - const memoryRelocation = await call("read_bytes", { - address: address(fixture.relocation_offset), - length: 2, - }); - assert.equal(memoryRelocation.bytes_hex, "0410"); - assert.equal(memoryRelocation.complete, true); - assert.deepEqual( - ( - await cliEvidence("read-bytes", address(fixture.relocation_offset), [ - "--length", - "2", - ]) - ).normalized_result, - memoryRelocation, - ); - assert.deepEqual( - (await cliEvidence("address-to-file-offset", expected.entry)) - .normalized_result, - mapped, - ); - const tail = await call("read_bytes", { - address: address(fixture.module_bytes - 2), - length: 8, - }); - assert.equal(tail.returned_bytes, 2); - assert.equal(tail.complete, false); - const unmapped = await call("read_bytes", { address: "0x80000", length: 8 }); - assert.equal(unmapped.returned_bytes, 0); - assert.equal(unmapped.complete, false); - const noSource = await client.callTool({ - name: "address_to_file_offset", - arguments: { address: "0x80000" }, - }); - assert.equal(noSource.isError, true); - const procedures = await call("list_procedures"); - assert.ok( - Array.isArray(procedures), - "Public procedure inventory is not an array", - ); - const identities = Object.fromEntries( - Object.entries(expected).map(([key, value]) => { - const item = procedures.find((candidate) => candidate.address === value); - assert.ok( - item, - `Source-owned ${key} procedure ${value} was not discovered from MZ entry/call flow`, - ); - return [key, item]; - }), - ); - const session = await call("binary_session"); - const profile = session.analysis_provider_binding?.analysis_profile; - assert.ok(profile, "Public session omitted committed profile"); - assertProfile(profile); - assert.equal(session.sha256, sha256); - assert.equal(session.open, true); - const facts = {}; - for (const [key, identity] of Object.entries(identities)) { - const procedure = identity.address; // Use returned addresses, never symbol-name heuristics. - assert.match(procedure, /^0x[0-9a-f]+$/u); - const resolved = await call("procedure_address", { procedure }); - assert.equal(resolved, procedure, "Flat address round-trip drifted"); - const info = await call("procedure_info", { procedure }); - const dossierResult = await evidenceCall("analyze_function", { - procedure, - detail: "complete", - }); - const dossier = functionDossierSchema.parse( - dossierResult.normalized_result, - ); - assertBody(info.body, procedure); - assertBody(dossier.procedure.body, procedure); - assert.equal(info.entrypoint, procedure); - assert.equal(info.length, info.body.total_bytes); - assert.deepEqual(dossier.procedure.body, info.body); - const containingQueries = [procedure, info.body.ranges.at(-1).end]; - for (const query of containingQueries) { - const containing = await call("resolve_containing_procedure", { - address: query, - }); - assert.equal(containing.found, true); - assert.equal(containing.query_address, query); - assert.equal(containing.procedure.address, procedure); - assert.deepEqual(containing.procedure.body, info.body); - } - if (key === "entry") { - const gap = await call("resolve_containing_procedure", { - address: address(0x10), - }); - assert.equal( - gap.found, - false, - "Gap inside enclosing span was treated as function body", - ); - assert.equal(gap.reason, "not_in_procedure"); - } - - assert.ok( - dossier.pseudocode.trim().length > 0, - "Real DOS decompilation returned empty pseudocode", - ); - assert.equal(dossier.procedure.address, procedure); - const window = await call("read_function_instructions", { procedure }); - assert.deepEqual(window.procedure.body, info.body); - assert.ok(window.instructions.length > 0); - if (key === "entry") { - assert.deepEqual(info.body.ranges, [ - { start: procedure, end: address(fixture.entry_first_bytes - 1) }, - { - start: address(fixture.entry_tail_offset), - end: address( - fixture.entry_tail_offset + fixture.entry_tail_bytes - 1, - ), - }, - ]); - assert.equal( - info.body.total_bytes, - fixture.entry_first_bytes + fixture.entry_tail_bytes, - ); - assert.equal( - info.body.span_bytes, - fixture.entry_tail_offset + fixture.entry_tail_bytes, - ); - assert.equal(info.body.non_contiguous, true); - } else { - const size = - key === "near" ? fixture.near_body_bytes : fixture.far_body_bytes; - assert.deepEqual(info.body.ranges, [ - { - start: procedure, - end: address( - (key === "near" ? fixture.near_offset : fixture.far_offset) + - size - - 1, - ), - }, - ]); - assert.equal(info.body.total_bytes, size); - assert.equal(info.body.span_bytes, size); - } - const cli = await cliEvidence("function", procedure); - if (captureDirectory !== undefined) { - await writeFile( - join(captureDirectory, `public-fixture-${key}-cli.json`), - `${JSON.stringify(cli, null, 2)}\n`, - { mode: 0o600 }, - ); - await writeFile( - join(captureDirectory, `public-fixture-${key}-mcp.json`), - `${JSON.stringify(dossierResult, null, 2)}\n`, - { mode: 0o600 }, - ); - } - const cliDossier = functionDossierSchema.parse(cli.normalized_result); - assert.deepEqual( - stableDossierObservations(cliDossier), - stableDossierObservations(dossier), - "CLI/MCP stable DOS dossier observations differ", - ); - facts[key] = { - address: procedure, - body: info.body, - instruction_count: window.instructions.length, - pseudocode_nonempty: true, - pseudocode: dossier.pseudocode, - cli_mcp_observations_equal: true, - containing_procedure_body_equal: true, - limitations: dossier.limitations, - }; - } - const move = await call("inspect_native_instruction", { - address: identities.entry.address, - }); - assert.equal(move.status, "decoded"); - assert.equal(move.length, 3); - assert.equal(hexBytes(move.bytes), "b83412"); - assert.match(move.mnemonic, /^MOV$/iu); - assert.ok( - move.operands - .flatMap((operand) => operand.components) - .some( - (part) => - part.kind === "register" && - /^AX$/iu.test(part.text) && - part.bit_width === 16, - ), - "DOS entry was not decoded as 16-bit AX", - ); - const nearMemory = await call("inspect_native_instruction", { - address: identities.near.address, - }); - assert.equal(nearMemory.status, "decoded"); - assert.equal(nearMemory.length, 2); - assert.equal(hexBytes(nearMemory.bytes), "fe00"); - assert.match(nearMemory.mnemonic, /^INC$/iu); - const nearCall = await call("inspect_native_instruction", { - address: address(fixture.near_call_offset), - }); - const farCall = await call("inspect_native_instruction", { - address: address(fixture.far_call_offset), - }); - assert.equal(hexBytes(nearCall.bytes), "e81a00"); - // Source far segment 0004 is relocated by the independently fixed 1000 load segment. - assert.equal(hexBytes(farCall.bytes), "9a00000410"); - for (const [key, instruction] of [ - ["near", nearCall], - ["far", farCall], - ]) { - assert.equal(instruction.flow.kind, "call"); - assert.ok( - instruction.flow.direct_destinations.includes(identities[key].address), - ); - const targets = await call("resolve_native_call_targets", { - address: instruction.address, - }); - assert.equal(targets.status, "direct"); - assert.ok( - targets.targets.some( - (target) => target.address === identities[key].address, - ), - ); - } - const invalid = await client.callTool({ - name: "procedure_info", - arguments: { procedure: "0xffffffffffffffff" }, - }); - assert.equal(invalid.isError, true, "Unmapped address did not fail"); - // The CLI reads a fresh session, so compare it with MCP before the boundary - // helper's temporary rename and restoration. - const entryWindow = await call("read_function_instructions", { - procedure: identities.entry.address, - }); - const cliWindow = await cliEvidence("instructions", identities.entry.address); - assert.deepEqual(cliWindow.normalized_result, entryWindow); - await verifyGhidraAddressBoundaries( - client, - identities.entry.address, - cliEvidence, - { mutable: !windows }, - ); - const entrySymbol = entryWindow.procedure.symbol; - assert.ok(entrySymbol != null, "The DOS entry needs observed symbol facts"); - // Restoring the name is itself a user edit, so Ghidra records user provenance. - // Windows P0 refuses edits and never renames, so its entry stays unchanged. - assert.deepEqual( - await call("read_function_instructions", { - procedure: identities.entry.address, - }), - windows - ? entryWindow - : { - ...entryWindow, - procedure: { - ...entryWindow.procedure, - symbol: { ...entrySymbol, source: "user_defined" }, - }, - }, - "Restoring the entry name changed more than its symbol provenance", - ); - assert.equal( - digest(await readFile(targetPath)), - sha256, - "Analysis changed original bytes", - ); - await call("close_binary"); - opened = false; - assert.equal((await call("binary_session")).open, false); - report = { - ok: true, - mocked: false, - lane: "ghidra-dos", - host: process.platform, - provider: { id: "ghidra", version: installation.providerVersion }, - target: { - format: "dos-mz", - architecture: "x86", - sha256, - bytes: fixture.bytes.length, - }, - profile: { - digest: profile.digest, - loader: profile.parameters.loader, - language_id: profile.parameters.language_id, - compiler_spec_id: profile.parameters.compiler_spec_id, - load_segment: profile.parameters.load_segment, - }, - transport: { cli: true, stdio_mcp: true }, - load_image: { - status: image.status, - checks: image.checks.length, - mappings: image.observations.mappings.length, - relocations: image.observations.relocations.length, - cli_mcp_parity: true, - }, - memory_evidence: { - relocated_word_hex: memoryRelocation.bytes_hex, - entry_file_offset: mapped.file_offset, - header_file_offset: headerMapping.file_offset, - partial_read_bytes: tail.returned_bytes, - unmapped_read_bytes: unmapped.returned_bytes, - unmapped_file_offset_rejected: true, - cli_mcp_parity: true, - }, - functions: facts, - instruction_oracles: { - entry_bytes: hexBytes(move.bytes), - near_memory_bytes: hexBytes(nearMemory.bytes), - near_call_bytes: hexBytes(nearCall.bytes), - relocated_far_call_bytes: hexBytes(farCall.bytes), - near_target: expected.near, - far_target: expected.far, - }, - invalid_address_rejected: true, - address_truncation_rejected: true, - original_unchanged: true, - limitations: [ - "Read-only import/decompilation evidence, not DOS runtime execution or whole-program semantic equivalence.", - "The complete body is Ghidra's observed AddressSet, not independently proven historical compiler ownership.", - "Language/compiler identity is bound by the production authenticated bridge handshake; profile loader is the declared import policy.", - "Native high-pcode flow is excluded from cross-runtime parity: LOAD/STORE address-space selector constants are process-specific in current Ghidra output. Both dossiers are still strictly parsed.", - ...(windows - ? [ - "Windows P0 is read-only: annotation and naming edits were checked as refused, not exercised.", - ] - : []), - ], - }; -} catch (error) { - if (stderr.trim()) - process.stderr.write(`DOS verifier MCP diagnostics:\n${stderr}\n`); - throw error; -} finally { +await withVerifierCleanup(async () => { try { - if (opened) { - try { - await call("close_binary"); - } catch (error) { - process.stderr.write(`DOS cleanup close failed: ${String(error)}\n`); + await client.connect(transport); + connected = true; + transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-65536); + }); + const target = await call("open_binary", { + path: targetPath, + provider_id: "ghidra", + }); + assert.equal(target.format, "dos-mz"); + assert.equal(target.kind, "executable"); + assert.equal(target.architecture, "x86"); + assert.equal(target.sha256, sha256); + const image = await call("inspect_native_load_image"); + assert.equal( + image.status, + "verified", + JSON.stringify(image.checks?.filter((check) => !check.matched)), + ); + assert.equal(image.target_sha256, sha256); + assert.equal(image.header_bytes, fixture.header_bytes); + assert.equal(image.module_bytes, fixture.module_bytes); + assert.equal(image.entry.linear_address, expected.entry); + assert.ok(image.checks.every((check) => check.matched)); + assert.ok(image.observations.relocations.length > 0); + assert.ok( + image.observations.relocations.every( + ({ symbol }) => symbol === null || typeof symbol === "string", + ), + "Each real Ghidra relocation must record its symbol observation", + ); + const cliImage = await cliEvidence("inspect-native-load-image"); + // Snapshot filenames are session-specific. Every measured byte/range/check is stable. + const stableImage = (value) => ({ + ...value, + observations: { + ...value.observations, + source_files: value.observations.source_files.map( + ({ name: _name, ...file }) => file, + ), + }, + }); + assert.deepEqual( + stableImage(cliImage.normalized_result), + stableImage(image), + ); + const mapped = await call("address_to_file_offset", { + address: expected.entry, + }); + assert.equal(mapped.file_offset, fixture.header_bytes); + const headerMapping = await call("address_to_file_offset", { + address: "HEADER:0x0", + }); + assert.equal(headerMapping.file_offset, 0); + const fileRelocation = await call("address_to_file_offset", { + address: address(fixture.relocation_offset), + }); + assert.equal( + fileRelocation.file_offset, + fixture.header_bytes + fixture.relocation_offset, + ); + const memoryRelocation = await call("read_bytes", { + address: address(fixture.relocation_offset), + length: 2, + }); + assert.equal(memoryRelocation.bytes_hex, "0410"); + assert.equal(memoryRelocation.complete, true); + assert.deepEqual( + ( + await cliEvidence("read-bytes", address(fixture.relocation_offset), [ + "--length", + "2", + ]) + ).normalized_result, + memoryRelocation, + ); + assert.deepEqual( + (await cliEvidence("address-to-file-offset", expected.entry)) + .normalized_result, + mapped, + ); + const tail = await call("read_bytes", { + address: address(fixture.module_bytes - 2), + length: 8, + }); + assert.equal(tail.returned_bytes, 2); + assert.equal(tail.complete, false); + const unmapped = await call("read_bytes", { + address: "0x80000", + length: 8, + }); + assert.equal(unmapped.returned_bytes, 0); + assert.equal(unmapped.complete, false); + const noSource = await client.callTool({ + name: "address_to_file_offset", + arguments: { address: "0x80000" }, + }); + assert.equal(noSource.isError, true); + const procedures = await call("list_procedures"); + assert.ok( + Array.isArray(procedures), + "Public procedure inventory is not an array", + ); + const identities = Object.fromEntries( + Object.entries(expected).map(([key, value]) => { + const item = procedures.find( + (candidate) => candidate.address === value, + ); + assert.ok( + item, + `Source-owned ${key} procedure ${value} was not discovered from MZ entry/call flow`, + ); + return [key, item]; + }), + ); + const session = await call("binary_session"); + const profile = session.analysis_provider_binding?.analysis_profile; + assert.ok(profile, "Public session omitted committed profile"); + assertProfile(profile); + assert.equal(session.sha256, sha256); + assert.equal(session.open, true); + const facts = {}; + for (const [key, identity] of Object.entries(identities)) { + const procedure = identity.address; // Use returned addresses, never symbol-name heuristics. + assert.match(procedure, /^0x[0-9a-f]+$/u); + const resolved = await call("procedure_address", { procedure }); + assert.equal(resolved, procedure, "Flat address round-trip drifted"); + const info = await call("procedure_info", { procedure }); + const dossierResult = await evidenceCall("analyze_function", { + procedure, + detail: "complete", + }); + const dossier = functionDossierSchema.parse( + dossierResult.normalized_result, + ); + assertBody(info.body, procedure); + assertBody(dossier.procedure.body, procedure); + assert.equal(info.entrypoint, procedure); + assert.equal(info.length, info.body.total_bytes); + assert.deepEqual(dossier.procedure.body, info.body); + const containingQueries = [procedure, info.body.ranges.at(-1).end]; + for (const query of containingQueries) { + const containing = await call("resolve_containing_procedure", { + address: query, + }); + assert.equal(containing.found, true); + assert.equal(containing.query_address, query); + assert.equal(containing.procedure.address, procedure); + assert.deepEqual(containing.procedure.body, info.body); } + if (key === "entry") { + const gap = await call("resolve_containing_procedure", { + address: address(0x10), + }); + assert.equal( + gap.found, + false, + "Gap inside enclosing span was treated as function body", + ); + assert.equal(gap.reason, "not_in_procedure"); + } + + assert.ok( + dossier.pseudocode.trim().length > 0, + "Real DOS decompilation returned empty pseudocode", + ); + assert.equal(dossier.procedure.address, procedure); + const window = await call("read_function_instructions", { procedure }); + assert.deepEqual(window.procedure.body, info.body); + assert.ok(window.instructions.length > 0); + if (key === "entry") { + assert.deepEqual(info.body.ranges, [ + { start: procedure, end: address(fixture.entry_first_bytes - 1) }, + { + start: address(fixture.entry_tail_offset), + end: address( + fixture.entry_tail_offset + fixture.entry_tail_bytes - 1, + ), + }, + ]); + assert.equal( + info.body.total_bytes, + fixture.entry_first_bytes + fixture.entry_tail_bytes, + ); + assert.equal( + info.body.span_bytes, + fixture.entry_tail_offset + fixture.entry_tail_bytes, + ); + assert.equal(info.body.non_contiguous, true); + } else { + const size = + key === "near" ? fixture.near_body_bytes : fixture.far_body_bytes; + assert.deepEqual(info.body.ranges, [ + { + start: procedure, + end: address( + (key === "near" ? fixture.near_offset : fixture.far_offset) + + size - + 1, + ), + }, + ]); + assert.equal(info.body.total_bytes, size); + assert.equal(info.body.span_bytes, size); + } + const cli = await cliEvidence("function", procedure); + if (captureDirectory !== undefined) { + await writeFile( + join(captureDirectory, `public-fixture-${key}-cli.json`), + `${JSON.stringify(cli, null, 2)}\n`, + { mode: 0o600 }, + ); + await writeFile( + join(captureDirectory, `public-fixture-${key}-mcp.json`), + `${JSON.stringify(dossierResult, null, 2)}\n`, + { mode: 0o600 }, + ); + } + const cliDossier = functionDossierSchema.parse(cli.normalized_result); + assert.deepEqual( + stableDossierObservations(cliDossier), + stableDossierObservations(dossier), + "CLI/MCP stable DOS dossier observations differ", + ); + facts[key] = { + address: procedure, + body: info.body, + instruction_count: window.instructions.length, + pseudocode_nonempty: true, + pseudocode: dossier.pseudocode, + cli_mcp_observations_equal: true, + containing_procedure_body_equal: true, + limitations: dossier.limitations, + }; } - try { - await client.close(); - } finally { - await transport.close(); + const move = await call("inspect_native_instruction", { + address: identities.entry.address, + }); + assert.equal(move.status, "decoded"); + assert.equal(move.length, 3); + assert.equal(hexBytes(move.bytes), "b83412"); + assert.match(move.mnemonic, /^MOV$/iu); + assert.ok( + move.operands + .flatMap((operand) => operand.components) + .some( + (part) => + part.kind === "register" && + /^AX$/iu.test(part.text) && + part.bit_width === 16, + ), + "DOS entry was not decoded as 16-bit AX", + ); + const nearMemory = await call("inspect_native_instruction", { + address: identities.near.address, + }); + assert.equal(nearMemory.status, "decoded"); + assert.equal(nearMemory.length, 2); + assert.equal(hexBytes(nearMemory.bytes), "fe00"); + assert.match(nearMemory.mnemonic, /^INC$/iu); + const nearCall = await call("inspect_native_instruction", { + address: address(fixture.near_call_offset), + }); + const farCall = await call("inspect_native_instruction", { + address: address(fixture.far_call_offset), + }); + assert.equal(hexBytes(nearCall.bytes), "e81a00"); + // Source far segment 0004 is relocated by the independently fixed 1000 load segment. + assert.equal(hexBytes(farCall.bytes), "9a00000410"); + for (const [key, instruction] of [ + ["near", nearCall], + ["far", farCall], + ]) { + assert.equal(instruction.flow.kind, "call"); + assert.ok( + instruction.flow.direct_destinations.includes(identities[key].address), + ); + const targets = await call("resolve_native_call_targets", { + address: instruction.address, + }); + assert.equal(targets.status, "direct"); + assert.ok( + targets.targets.some( + (target) => target.address === identities[key].address, + ), + ); } + const invalid = await client.callTool({ + name: "procedure_info", + arguments: { procedure: "0xffffffffffffffff" }, + }); + assert.equal(invalid.isError, true, "Unmapped address did not fail"); + // The CLI reads a fresh session, so compare it with MCP before the boundary + // helper's temporary rename and restoration. + const entryWindow = await call("read_function_instructions", { + procedure: identities.entry.address, + }); + const cliWindow = await cliEvidence( + "instructions", + identities.entry.address, + ); + assert.deepEqual(cliWindow.normalized_result, entryWindow); + await verifyGhidraAddressBoundaries( + client, + identities.entry.address, + cliEvidence, + { mutable: !windows }, + ); + const entrySymbol = entryWindow.procedure.symbol; + assert.ok(entrySymbol != null, "The DOS entry needs observed symbol facts"); + // Restoring the name is itself a user edit, so Ghidra records user provenance. + // Windows P0 refuses edits and never renames, so its entry stays unchanged. + assert.deepEqual( + await call("read_function_instructions", { + procedure: identities.entry.address, + }), + windows + ? entryWindow + : { + ...entryWindow, + procedure: { + ...entryWindow.procedure, + symbol: { ...entrySymbol, source: "user_defined" }, + }, + }, + "Restoring the entry name changed more than its symbol provenance", + ); + assert.equal( + digest(await readFile(targetPath)), + sha256, + "Analysis changed original bytes", + ); + await call("close_binary"); + assert.equal((await call("binary_session")).open, false); + report = { + ok: true, + mocked: false, + lane: "ghidra-dos", + host: process.platform, + provider: { id: "ghidra", version: installation.providerVersion }, + target: { + format: "dos-mz", + architecture: "x86", + sha256, + bytes: fixture.bytes.length, + }, + profile: { + digest: profile.digest, + loader: profile.parameters.loader, + language_id: profile.parameters.language_id, + compiler_spec_id: profile.parameters.compiler_spec_id, + load_segment: profile.parameters.load_segment, + }, + transport: { cli: true, stdio_mcp: true }, + load_image: { + status: image.status, + checks: image.checks.length, + mappings: image.observations.mappings.length, + relocations: image.observations.relocations.length, + cli_mcp_parity: true, + }, + memory_evidence: { + relocated_word_hex: memoryRelocation.bytes_hex, + entry_file_offset: mapped.file_offset, + header_file_offset: headerMapping.file_offset, + partial_read_bytes: tail.returned_bytes, + unmapped_read_bytes: unmapped.returned_bytes, + unmapped_file_offset_rejected: true, + cli_mcp_parity: true, + }, + functions: facts, + instruction_oracles: { + entry_bytes: hexBytes(move.bytes), + near_memory_bytes: hexBytes(nearMemory.bytes), + near_call_bytes: hexBytes(nearCall.bytes), + relocated_far_call_bytes: hexBytes(farCall.bytes), + near_target: expected.near, + far_target: expected.far, + }, + invalid_address_rejected: true, + address_truncation_rejected: true, + original_unchanged: true, + limitations: [ + "Read-only import/decompilation evidence, not DOS runtime execution or whole-program semantic equivalence.", + "The complete body is Ghidra's observed AddressSet, not independently proven historical compiler ownership.", + "Language/compiler identity is bound by the production authenticated bridge handshake; profile loader is the declared import policy.", + "Native high-pcode flow is excluded from cross-runtime parity: LOAD/STORE address-space selector constants are process-specific in current Ghidra output. Both dossiers are still strictly parsed.", + ...(windows + ? [ + "Windows P0 is read-only: annotation and naming edits were checked as refused, not exercised.", + ] + : []), + ], + }; + } catch (error) { + if (stderr.trim()) + process.stderr.write(`DOS verifier MCP diagnostics:\n${stderr}\n`); + throw error; + } +}, [ + async () => { + await closeVerifierResources([ + async () => { + if (connected) await call("close_binary", {}); + }, + () => client.close(), + () => transport.close(), + ]); assert.equal( digest(await readFile(targetPath)), sha256, "Original changed during cleanup", ); - const leftovers = (await readdir(runtime)).filter((name) => - name.startsWith("rea-ghidra-"), + assert.deepEqual( + (await readdir(runtime)).filter((name) => name.startsWith("rea-ghidra-")), + [], + "Owned Ghidra runtime survived close", ); - assert.deepEqual(leftovers, [], "Owned Ghidra runtime survived close"); - } finally { + verifierRun = await completeVerifierRun(run); + if (process.platform !== "win32") { + assert.equal(verifierRun.process_lineage.status, "verified"); + assert.deepEqual(verifierRun.process_lineage.descendants, []); + } await rm(workspace, { recursive: true, force: true }); - } -} + }, +]); assert.ok(report, "No DOS proof report was produced"); -const verifierRun = await completeVerifierRun(run); +verifierRun = await completeVerifierRun(run); // Windows provider jobs are verified by their native owners, as in the 6502 lane. if (!windows) assertVerifierCleanupComplete(verifierRun.process_lineage); process.stdout.write( diff --git a/scripts/verify-real-ghidra-nativeaot.mjs b/scripts/verify-real-ghidra-nativeaot.mjs index 6b0bb5851a..67bec660ae 100644 --- a/scripts/verify-real-ghidra-nativeaot.mjs +++ b/scripts/verify-real-ghidra-nativeaot.mjs @@ -1,4 +1,8 @@ #!/usr/bin/env node +import { + closeVerifierResources, + withVerifierCleanup, +} from "./lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { requireMcpLifecycleResult, @@ -24,6 +28,8 @@ import { parseEvidence } from "../dist/domain/evidence.js"; import { nativeDataTypeSchema } from "../dist/domain/native/nativeDataType.js"; import { nativeLoadImageSchema } from "../dist/domain/native/nativeLoadImage.js"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +const run = createVerifierRun(); const mode = process.argv[2] ?? "symbols"; if ( ![ @@ -152,248 +158,268 @@ transport.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString()).slice(-65536); }); let opened = false; +let connected = false; +let verifierRun; let report; -try { - if (mode === "loader-failure") await prepareLoaderFailure(); - await client.connect(transport); - const response = await client.callTool( - { name: "open_binary", arguments: { path: target, provider_id: "ghidra" } }, - { timeout: 360000 }, - ); - if ( - ["unsupported", "malformed", "ambiguous", "loader-failure"].includes(mode) - ) { - // MCP open binds a lazy session; the first inspection starts import. - let failure = response; - if (failure.isError !== true) { +await withVerifierCleanup(async () => { + try { + if (mode === "loader-failure") await prepareLoaderFailure(); + await client.connect(transport); + connected = true; + const response = await client.callTool( + { + name: "open_binary", + arguments: { path: target, provider_id: "ghidra" }, + }, + { timeout: 360000 }, + ); + if ( + ["unsupported", "malformed", "ambiguous", "loader-failure"].includes(mode) + ) { + // MCP open binds a lazy session; the first inspection starts import. + let failure = response; + if (failure.isError !== true) { + opened = true; + failure = await client.callTool( + { name: "inspect_native_load_image", arguments: {} }, + { timeout: 360000 }, + ); + } + assert.equal(failure.isError, true, JSON.stringify(failure)); + const diagnostic = JSON.stringify(failure); + assert.match( + diagnostic, + mode === "unsupported" + ? /Unsupported NativeAOT RTR format/ + : mode === "malformed" + ? /Reversed NativeAOT section/ + : mode === "loader-failure" + ? /ExceptionInInitializerError.*REA_INITIALIZER_FIXTURE/ + : /Ambiguous NativeAOT directory candidates/, + ); + report = { mode, target, sha256, typed_failure: failure }; + } else if (mode === "default-native") { + assert.notEqual(response.isError, true, JSON.stringify(response)); opened = true; - failure = await client.callTool( - { name: "inspect_native_load_image", arguments: {} }, - { timeout: 360000 }, + const image = nativeLoadImageSchema.parse( + await call("inspect_native_load_image"), ); - } - assert.equal(failure.isError, true, JSON.stringify(failure)); - const diagnostic = JSON.stringify(failure); - assert.match( - diagnostic, - mode === "unsupported" - ? /Unsupported NativeAOT RTR format/ - : mode === "malformed" - ? /Reversed NativeAOT section/ - : mode === "loader-failure" - ? /ExceptionInInitializerError.*REA_INITIALIZER_FIXTURE/ - : /Ambiguous NativeAOT directory candidates/, - ); - report = { mode, target, sha256, typed_failure: failure }; - } else if (mode === "default-native") { - assert.notEqual(response.isError, true, JSON.stringify(response)); - opened = true; - const image = nativeLoadImageSchema.parse( - await call("inspect_native_load_image"), - ); - assert.equal(image.observations.metadata_recovery, undefined); - report = { mode, target, sha256, image }; - } else { - assert.notEqual(response.isError, true, JSON.stringify(response)); - opened = true; - const image = nativeLoadImageSchema.parse( - await call("inspect_native_load_image"), - ); - const summary = image.observations.metadata_recovery?.[0]; - assert.ok( - summary, - "Loaded-image inspection omitted inline runtime-format discovery", - ); - assert.equal(summary.format, "dotnet-nativeaot"); - assert.equal( - summary.analysis_artifact_sha256, - hash(await readFile(env.REA_GHIDRA_NATIVEAOT_JAR)), - ); - if (mode === "ordinary") { - assert.equal(summary.status, "not_applicable"); - assert.equal(summary.method_tables, 0); - assert.deepEqual(summary.types, []); - report = { mode, target, sha256, summary }; + assert.equal(image.observations.metadata_recovery, undefined); + report = { mode, target, sha256, image }; } else { - assert.ok(["complete", "partial"].includes(summary.status)); - assert.equal(summary.format_major, 9); - assert.equal(summary.format_minor, 1); - assert.equal(summary.types.length, summary.method_tables); - assert.ok(summary.method_tables > 0); - assert.equal( - summary.discovery, - mode === "stripped" || oracle.runtime === "win-x64" - ? "signature-heuristic" - : "symbol", + assert.notEqual(response.isError, true, JSON.stringify(response)); + opened = true; + const image = nativeLoadImageSchema.parse( + await call("inspect_native_load_image"), ); - assert.equal(summary.derived_memory.file_offset, null); - assert.ok(summary.coverage.frozen_objects_annotated > 0); - // Independent compiler/linker symbols identify fixture classes. Recovered - // Class_address names are never treated as the original source identities. - const symbols = await readFile(join(fixtureRoot, "symbols.txt"), "utf8"); - const bias = - oracle.runtime === "linux-x64" - ? BigInt(image.observations.image_base) - : 0n; - const addressOf = (name) => { - const line = symbols - .split(/\r?\n/u) - .find((line) => line.split(/\s+/u).includes(name)); - assert.ok(line, `Independent symbol missing: ${name}`); - const match = + const summary = image.observations.metadata_recovery?.[0]; + assert.ok( + summary, + "Loaded-image inspection omitted inline runtime-format discovery", + ); + assert.equal(summary.format, "dotnet-nativeaot"); + assert.equal( + summary.analysis_artifact_sha256, + hash(await readFile(env.REA_GHIDRA_NATIVEAOT_JAR)), + ); + if (mode === "ordinary") { + assert.equal(summary.status, "not_applicable"); + assert.equal(summary.method_tables, 0); + assert.deepEqual(summary.types, []); + report = { mode, target, sha256, summary }; + } else { + assert.ok(["complete", "partial"].includes(summary.status)); + assert.equal(summary.format_major, 9); + assert.equal(summary.format_minor, 1); + assert.equal(summary.types.length, summary.method_tables); + assert.ok(summary.method_tables > 0); + assert.equal( + summary.discovery, + mode === "stripped" || oracle.runtime === "win-x64" + ? "signature-heuristic" + : "symbol", + ); + assert.equal(summary.derived_memory.file_offset, null); + assert.ok(summary.coverage.frozen_objects_annotated > 0); + // Independent compiler/linker symbols identify fixture classes. Recovered + // Class_address names are never treated as the original source identities. + const symbols = await readFile( + join(fixtureRoot, "symbols.txt"), + "utf8", + ); + const bias = oracle.runtime === "linux-x64" - ? line.match(/^([a-f0-9]+)\s/iu) - : line.match(/\s([a-f0-9]{16})\s/iu); - assert.ok(match, `Unrecognized independent symbol line: ${line}`); - return `0x${(BigInt(`0x${match[1]}`) + bias).toString(16)}`; - }; - const fixtureSymbols = - oracle.symbols ?? - (oracle.runtime === "win-x64" - ? { - base: "??_7NativeAotFixture_BaseProbe@@6B@", - derived: "??_7NativeAotFixture_DerivedProbe@@6B@", - interface: "??_7NativeAotFixture_IProbe@@6B@", - compute: "NativeAotFixture_DerivedProbe__Compute", - dispatch: "NativeAotFixture_Program__Dispatch", - } - : { - base: "_ZTV26NativeAotFixture_BaseProbe", - derived: "_ZTV29NativeAotFixture_DerivedProbe", - interface: "_ZTV23NativeAotFixture_IProbe", - compute: "NativeAotFixture_DerivedProbe__Compute", - dispatch: "NativeAotFixture_Program__Dispatch", - }); - const lookup = (address) => { - const identity = summary.types.find((type) => type.address === address); - assert.ok(identity?.type, `Recovered identity missing at ${address}`); - return identity.type; - }; - const derivedAddress = addressOf(fixtureSymbols.derived); - const baseAddress = addressOf(fixtureSymbols.base); - const interfaceAddress = addressOf(fixtureSymbols.interface); - const derived = nativeDataTypeSchema.parse( - await call("inspect_native_data_type", { - type: lookup(derivedAddress), - }), - ); - const metadata = derived.metadata_recovery; - assert.ok(metadata); - assert.equal(metadata.original_name, null); - assert.equal(metadata.name_origin, "generated"); - assert.equal(metadata.method_table_address, derivedAddress); - assert.equal(metadata.related_type.address, baseAddress); - assert.ok( - metadata.interfaces.some((type) => type.address === interfaceAddress), - ); - assert.ok( - metadata.virtual_slots.some( - (slot) => slot.target_address === addressOf(fixtureSymbols.compute), - ), - ); - const compute = await call("analyze_function", { - procedure: addressOf(fixtureSymbols.compute), - detail: "complete", - }); - assert.ok(compute.pseudocode.trim()); - assert.doesNotMatch( - compute.pseudocode, - /\b__thiscall\b/u, - "Upstream x64 calling-convention rewrite leaked into recovered pseudocode", - ); - assert.match(compute.pseudocode, /(?:0xb|\b11\b|<<)/u); - const dispatch = await call("analyze_function", { - procedure: addressOf(fixtureSymbols.dispatch), - detail: "complete", - }); - assert.ok(dispatch.pseudocode.trim()); - for (const literal of oracle.expected_strings) + ? BigInt(image.observations.image_base) + : 0n; + const addressOf = (name) => { + const line = symbols + .split(/\r?\n/u) + .find((line) => line.split(/\s+/u).includes(name)); + assert.ok(line, `Independent symbol missing: ${name}`); + const match = + oracle.runtime === "linux-x64" + ? line.match(/^([a-f0-9]+)\s/iu) + : line.match(/\s([a-f0-9]{16})\s/iu); + assert.ok(match, `Unrecognized independent symbol line: ${line}`); + return `0x${(BigInt(`0x${match[1]}`) + bias).toString(16)}`; + }; + const fixtureSymbols = + oracle.symbols ?? + (oracle.runtime === "win-x64" + ? { + base: "??_7NativeAotFixture_BaseProbe@@6B@", + derived: "??_7NativeAotFixture_DerivedProbe@@6B@", + interface: "??_7NativeAotFixture_IProbe@@6B@", + compute: "NativeAotFixture_DerivedProbe__Compute", + dispatch: "NativeAotFixture_Program__Dispatch", + } + : { + base: "_ZTV26NativeAotFixture_BaseProbe", + derived: "_ZTV29NativeAotFixture_DerivedProbe", + interface: "_ZTV23NativeAotFixture_IProbe", + compute: "NativeAotFixture_DerivedProbe__Compute", + dispatch: "NativeAotFixture_Program__Dispatch", + }); + const lookup = (address) => { + const identity = summary.types.find( + (type) => type.address === address, + ); + assert.ok(identity?.type, `Recovered identity missing at ${address}`); + return identity.type; + }; + const derivedAddress = addressOf(fixtureSymbols.derived); + const baseAddress = addressOf(fixtureSymbols.base); + const interfaceAddress = addressOf(fixtureSymbols.interface); + const derived = nativeDataTypeSchema.parse( + await call("inspect_native_data_type", { + type: lookup(derivedAddress), + }), + ); + const metadata = derived.metadata_recovery; + assert.ok(metadata); + assert.equal(metadata.original_name, null); + assert.equal(metadata.name_origin, "generated"); + assert.equal(metadata.method_table_address, derivedAddress); + assert.equal(metadata.related_type.address, baseAddress); assert.ok( - (await call("search_strings", { pattern: literal })).length > 0, - `Frozen string missing: ${literal}`, + metadata.interfaces.some((type) => type.address === interfaceAddress), ); - const observed = await call("read_bytes", { - address: metadata.virtual_slots[0].slot_address, - length: 8, - }); - assert.equal(observed.complete, true); - assert.equal( - `0x${Buffer.from(observed.bytes_hex, "hex").readBigUInt64LE().toString(16)}`, - metadata.virtual_slots[0].target_address, - ); - report = { mode, target, sha256, summary, derived, compute, dispatch }; - await call("close_binary"); - opened = false; - // One representative CLI boundary, selected explicitly to avoid repeated - // full NativeAOT auto-analysis during each focused iteration. - if (process.env.REA_NATIVEAOT_PROOF_CLI === "1") { - const { stdout } = await promisify(execFile)( - process.execPath, - [ - entrypoint, - "inspect-native-data-type", - target, - "--type", - lookup(derivedAddress), - "--provider", - "ghidra", - "--json", - ], - { env, timeout: 360000, maxBuffer: 16 * 1024 * 1024 }, + assert.ok( + metadata.virtual_slots.some( + (slot) => slot.target_address === addressOf(fixtureSymbols.compute), + ), ); - const evidence = parseEvidence(JSON.parse(stdout)); - assert.equal(evidence.subject.digest.sha256, sha256); - assert.deepEqual( - evidence.normalized_result.metadata_recovery, - metadata, + const compute = await call("analyze_function", { + procedure: addressOf(fixtureSymbols.compute), + detail: "complete", + }); + assert.ok(compute.pseudocode.trim()); + assert.doesNotMatch( + compute.pseudocode, + /\b__thiscall\b/u, + "Upstream x64 calling-convention rewrite leaked into recovered pseudocode", ); - report.cli_metadata = evidence.normalized_result.metadata_recovery; + assert.match(compute.pseudocode, /(?:0xb|\b11\b|<<)/u); + const dispatch = await call("analyze_function", { + procedure: addressOf(fixtureSymbols.dispatch), + detail: "complete", + }); + assert.ok(dispatch.pseudocode.trim()); + for (const literal of oracle.expected_strings) + assert.ok( + (await call("search_strings", { pattern: literal })).length > 0, + `Frozen string missing: ${literal}`, + ); + const observed = await call("read_bytes", { + address: metadata.virtual_slots[0].slot_address, + length: 8, + }); + assert.equal(observed.complete, true); + assert.equal( + `0x${Buffer.from(observed.bytes_hex, "hex").readBigUInt64LE().toString(16)}`, + metadata.virtual_slots[0].target_address, + ); + report = { mode, target, sha256, summary, derived, compute, dispatch }; + await call("close_binary"); + opened = false; + // One representative CLI boundary, selected explicitly to avoid repeated + // full NativeAOT auto-analysis during each focused iteration. + if (process.env.REA_NATIVEAOT_PROOF_CLI === "1") { + const { stdout } = await promisify(execFile)( + process.execPath, + [ + entrypoint, + "inspect-native-data-type", + target, + "--type", + lookup(derivedAddress), + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 360000, maxBuffer: 16 * 1024 * 1024 }, + ); + const evidence = parseEvidence(JSON.parse(stdout)); + assert.equal(evidence.subject.digest.sha256, sha256); + assert.deepEqual( + evidence.normalized_result.metadata_recovery, + metadata, + ); + report.cli_metadata = evidence.normalized_result.metadata_recovery; + } } } - } - assert.equal( - hash(await readFile(target)), - sha256, - "Input executable changed", - ); - if (opened) { - await call("close_binary"); - opened = false; - } - await client.close(); - assert.deepEqual( - await readdir(runtime), - [], - "Owned runtime/project/extension files leaked after close", - ); - const capture = process.env.REA_NATIVEAOT_PROOF_CAPTURE_DIR; - if (capture !== undefined) { - assert.ok(isAbsolute(capture)); - await mkdir(capture, { recursive: true }); - await writeFile( - join(capture, `${oracle.runtime}-${mode}.json`), - JSON.stringify(report, null, 2) + "\n", + assert.equal( + hash(await readFile(target)), + sha256, + "Input executable changed", ); + if (opened) { + await call("close_binary"); + opened = false; + } + } catch (error) { + console.error(stderr); + throw error; } - const outcome = report.typed_failure - ? "MCP refusal" - : mode === "default-native" - ? "native load image without metadata recovery" - : "MCP metadata"; - console.log( - `PASS real Ghidra NativeAOT ${oracle.runtime}/${mode}: ${outcome}, source unchanged and runtime directory empty${report?.compute ? ", independent type/slot oracle and pseudocode" : ""}${report?.cli_metadata ? ", equivalent CLI" : ""}`, +}, [ + async () => { + await closeVerifierResources([ + async () => { + if (connected) await call("close_binary", {}); + }, + () => client.close(), + () => transport.close(), + ]); + assert.deepEqual( + await readdir(runtime), + [], + "Owned runtime/project/extension files leaked after close", + ); + verifierRun = await completeVerifierRun(run); + assert.equal(verifierRun.process_lineage.status, "verified"); + assert.deepEqual(verifierRun.process_lineage.descendants, []); + await rm(workspace, { recursive: true, force: true }); + }, +]); +const capture = process.env.REA_NATIVEAOT_PROOF_CAPTURE_DIR; +if (capture !== undefined) { + assert.ok(isAbsolute(capture)); + await mkdir(capture, { recursive: true }); + await writeFile( + join(capture, `${oracle.runtime}-${mode}.json`), + JSON.stringify(report, null, 2) + "\n", ); -} catch (error) { - console.error(stderr); - throw error; -} finally { - if (opened) - await client - .callTool({ name: "close_binary", arguments: {} }) - .catch(() => {}); - await client.close().catch(() => {}); - await rm(workspace, { recursive: true, force: true }); } +const outcome = report.typed_failure + ? "MCP refusal" + : mode === "default-native" + ? "native load image without metadata recovery" + : "MCP metadata"; +console.log( + `PASS real Ghidra NativeAOT ${oracle.runtime}/${mode}: ${outcome}, source unchanged and runtime directory empty${report?.compute ? ", independent type/slot oracle and pseudocode" : ""}${report?.cli_metadata ? ", equivalent CLI" : ""}`, +); + function hash(bytes) { return createHash("sha256").update(bytes).digest("hex"); } diff --git a/scripts/verify-real-ghidra-raw.mjs b/scripts/verify-real-ghidra-raw.mjs index 6079e491ef..82d91936a6 100644 --- a/scripts/verify-real-ghidra-raw.mjs +++ b/scripts/verify-real-ghidra-raw.mjs @@ -29,7 +29,10 @@ import { requireMcpOutput, } from "./lib/mcp-verifier-results.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; -import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; +import { + withVerifierCleanup, + closeVerifierResources, +} from "./lib/verifier-cleanup.mjs"; if (process.argv.length !== 2) throw new Error("Usage: node scripts/verify-real-ghidra-raw.mjs"); @@ -102,490 +105,521 @@ let stderr = ""; transport.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString()).slice(-65536); }); +let connected = false; +let verifierRun; let report; let searchDefinition; -try { - await client.connect(transport); - searchDefinition = (await client.listTools()).tools.find( - (tool) => tool.name === "search_bytes", - ); - assert.ok(searchDefinition?.outputSchema); - const profiles = []; - const sessions = []; - for (const entryPoint of ["0x08000000", "0x8000000", "0x09000000"]) { - // Omit provider_id to exercise reuse of the current provider route. - const opened = await call("open_binary", { - path, - format: "raw-binary", - architecture: "arm", - entry_point: entryPoint, - }); - const entry = `0x${BigInt(entryPoint).toString(16)}`; - assert.equal(opened.entry_point, entry); - const image = await call("inspect_native_load_image"); - assert.equal( - image.normalized_result.status, - "verified", - JSON.stringify(image), +await withVerifierCleanup(async () => { + try { + await client.connect(transport); + connected = true; + searchDefinition = (await client.listTools()).tools.find( + (tool) => tool.name === "search_bytes", ); - assert.equal(image.normalized_result.base_address, entry); - assert.equal(image.normalized_result.entry.address, entry); + assert.ok(searchDefinition?.outputSchema); + const profiles = []; + const sessions = []; + for (const entryPoint of ["0x08000000", "0x8000000", "0x09000000"]) { + // Omit provider_id to exercise reuse of the current provider route. + const opened = await call("open_binary", { + path, + format: "raw-binary", + architecture: "arm", + entry_point: entryPoint, + }); + const entry = `0x${BigInt(entryPoint).toString(16)}`; + assert.equal(opened.entry_point, entry); + const image = await call("inspect_native_load_image"); + assert.equal( + image.normalized_result.status, + "verified", + JSON.stringify(image), + ); + assert.equal(image.normalized_result.base_address, entry); + assert.equal(image.normalized_result.entry.address, entry); + assert.equal( + image.normalized_result.observations.language_id, + "ARM:LE:32:v7", + ); + assert.ok( + image.normalized_result.observations.entry_points.includes(entry), + ); + assert.equal(image.analysis_profile.parameters.entry_point, entry); + profiles.push(image.analysis_profile.digest); + const status = await call("binary_session"); + assert.equal(status.entry_point, entry); + sessions.push(status.analysis_run.run_id); + const memory = await call("read_bytes", { + address: entry, + length: bytes.length, + }); + assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex")); + const signature = await call("search_bytes", { + pattern: "1e ?? 2f e1", + limit: 1000, + }); + assert.equal(signature.normalized_result.complete, true); + assert.equal(signature.normalized_result.next_cursor, null); + assert.equal(signature.normalized_result.searched.bytes, bytes.length); + assert.deepEqual( + signature.normalized_result.matches.map((match) => match.address), + [entry], + ); + assert.equal(signature.normalized_result.matches[0].file_offset, 0); + assert.equal( + signature.normalized_result.matches[0].procedure.address, + entry, + ); + const procedures = await call("list_procedures"); + assert.ok( + procedures.normalized_result.some( + (procedure) => procedure.address === entry, + ), + ); + const instruction = await call("inspect_native_instruction", { + address: entry, + }); + assert.equal(instruction.normalized_result.address, entry); + assert.ok(Array.isArray(instruction.normalized_result.decoder_context)); + const tmodeContext = instruction.normalized_result.decoder_context.find( + (ctx) => ctx.register === "TMode", + ); + assert.ok(tmodeContext !== undefined); + assert.equal(tmodeContext.source, "ghidra-program-context"); + assert.ok([null, "0", "1"].includes(tmodeContext.value)); + } + assert.equal(profiles[0], profiles[1]); + assert.notEqual(profiles[1], profiles[2]); + assert.equal(sessions[0], sessions[1]); + assert.notEqual(sessions[1], sessions[2]); + await call("annotate_native_function", { + procedure: "0x9000000", + name: "retained_after_failed_open", + comment: "Owned session annotation", + }); + const rejected = await client.callTool({ + name: "open_binary", + arguments: { + path, + format: "raw-binary", + architecture: "arm64", + entry_point: "0x8000001", + }, + }); + assert.equal(rejected.isError, true); + assert.match(JSON.stringify(rejected.content), /must be 4-byte aligned/); + const rejection = JSON.parse(rejected.content[0].text).error; assert.equal( - image.normalized_result.observations.language_id, - "ARM:LE:32:v7", + rejection.details.target_at_open_completion.entry_point, + "0x9000000", + ); + assert.equal(rejection.details.provider_id_at_open_completion, "ghidra"); + const unchanged = await call("binary_session"); + assert.equal(unchanged.analysis_run.run_id, sessions[2]); + assert.equal(unchanged.entry_point, "0x9000000"); + const retained = await call("analyze_function", { + procedure: "0x9000000", + detail: "complete", + }); + assert.equal( + retained.normalized_result.procedure.name, + "retained_after_failed_open", ); assert.ok( - image.normalized_result.observations.entry_points.includes(entry), - ); - assert.equal(image.analysis_profile.parameters.entry_point, entry); - profiles.push(image.analysis_profile.digest); - const status = await call("binary_session"); - assert.equal(status.entry_point, entry); - sessions.push(status.analysis_run.run_id); - const memory = await call("read_bytes", { - address: entry, - length: bytes.length, - }); - assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex")); - const signature = await call("search_bytes", { - pattern: "1e ?? 2f e1", - limit: 1000, - }); - assert.equal(signature.normalized_result.complete, true); - assert.equal(signature.normalized_result.next_cursor, null); - assert.equal(signature.normalized_result.searched.bytes, bytes.length); - assert.deepEqual( - signature.normalized_result.matches.map((match) => match.address), - [entry], - ); - assert.equal(signature.normalized_result.matches[0].file_offset, 0); - assert.equal( - signature.normalized_result.matches[0].procedure.address, - entry, - ); - const procedures = await call("list_procedures"); - assert.ok( - procedures.normalized_result.some( - (procedure) => procedure.address === entry, + retained.normalized_result.comments.some( + (comment) => comment.text === "Owned session annotation", ), ); - const instruction = await call("inspect_native_instruction", { - address: entry, - }); - assert.equal(instruction.normalized_result.address, entry); - assert.ok(Array.isArray(instruction.normalized_result.decoder_context)); - const tmodeContext = instruction.normalized_result.decoder_context.find( - (ctx) => ctx.register === "TMode", - ); - assert.ok(tmodeContext !== undefined); - assert.equal(tmodeContext.source, "ghidra-program-context"); - assert.ok([null, "0", "1"].includes(tmodeContext.value)); - } - assert.equal(profiles[0], profiles[1]); - assert.notEqual(profiles[1], profiles[2]); - assert.equal(sessions[0], sessions[1]); - assert.notEqual(sessions[1], sessions[2]); - await call("annotate_native_function", { - procedure: "0x9000000", - name: "retained_after_failed_open", - comment: "Owned session annotation", - }); - const rejected = await client.callTool({ - name: "open_binary", - arguments: { - path, - format: "raw-binary", - architecture: "arm64", - entry_point: "0x8000001", - }, - }); - assert.equal(rejected.isError, true); - assert.match(JSON.stringify(rejected.content), /must be 4-byte aligned/); - const rejection = JSON.parse(rejected.content[0].text).error; - assert.equal( - rejection.details.target_at_open_completion.entry_point, - "0x9000000", - ); - assert.equal(rejection.details.provider_id_at_open_completion, "ghidra"); - const unchanged = await call("binary_session"); - assert.equal(unchanged.analysis_run.run_id, sessions[2]); - assert.equal(unchanged.entry_point, "0x9000000"); - const retained = await call("analyze_function", { - procedure: "0x9000000", - detail: "complete", - }); - assert.equal( - retained.normalized_result.procedure.name, - "retained_after_failed_open", - ); - assert.ok( - retained.normalized_result.comments.some( - (comment) => comment.text === "Owned session annotation", - ), - ); - await call("close_binary"); + await call("close_binary"); - const { stdout } = await promisify(execFile)( - process.execPath, - [ - entrypoint, - "inspect-native-load-image", - path, - "--target-format", - "raw-binary", - "--architecture", - "arm", - "--entry-point", - "0x08000000", - "--provider", - "ghidra", - "--json", - ], - { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, - ); - const cli = parseEvidence(JSON.parse(stdout)); - assert.equal(cli.subject.digest.sha256, sha256); - assert.equal(cli.normalized_result.status, "verified"); - assert.equal(cli.normalized_result.base_address, "0x8000000"); - assert.equal(cli.analysis_profile.digest, profiles[0]); - assert.deepEqual(await readFile(path), bytes); - const x86Targets = []; - for (const architecture of ["x86", "x86_64"]) { - const x86Bytes = Buffer.from("b82a000000c3", "hex"); // mov eax, 42; ret. - const x86Sha256 = createHash("sha256").update(x86Bytes).digest("hex"); - const x86Path = join(workspace, `${architecture}.bin`); - await writeFile(x86Path, x86Bytes); + const { stdout } = await promisify(execFile)( + process.execPath, + [ + entrypoint, + "inspect-native-load-image", + path, + "--target-format", + "raw-binary", + "--architecture", + "arm", + "--entry-point", + "0x08000000", + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const cli = parseEvidence(JSON.parse(stdout)); + assert.equal(cli.subject.digest.sha256, sha256); + assert.equal(cli.normalized_result.status, "verified"); + assert.equal(cli.normalized_result.base_address, "0x8000000"); + assert.equal(cli.analysis_profile.digest, profiles[0]); + assert.deepEqual(await readFile(path), bytes); + const x86Targets = []; + for (const architecture of ["x86", "x86_64"]) { + const x86Bytes = Buffer.from("b82a000000c3", "hex"); // mov eax, 42; ret. + const x86Sha256 = createHash("sha256").update(x86Bytes).digest("hex"); + const x86Path = join(workspace, `${architecture}.bin`); + await writeFile(x86Path, x86Bytes); + await call("open_binary", { + path: x86Path, + format: "raw-binary", + architecture, + entry_point: "0x1000", + }); + const image = await call("inspect_native_load_image", {}, x86Sha256); + assert.equal( + image.normalized_result.status, + "verified", + JSON.stringify(image), + ); + assert.equal(image.normalized_result.base_address, "0x1000"); + assert.equal( + image.normalized_result.observations.compiler_spec_id, + "gcc", + ); + assert.equal(image.analysis_profile.parameters.compiler_spec_id, "gcc"); + const memory = await call( + "read_bytes", + { address: "0x1000", length: x86Bytes.length }, + x86Sha256, + ); + assert.equal( + memory.normalized_result.bytes_hex, + x86Bytes.toString("hex"), + ); + const pseudo = await call( + "procedure_pseudo_code", + { procedure: "0x1000" }, + x86Sha256, + ); + assert.match(pseudo.normalized_result, /\b(?:42|0x2a)\b/iu); + await call("close_binary"); + assert.deepEqual(await readFile(x86Path), x86Bytes); + x86Targets.push({ + architecture, + sha256: x86Sha256, + compiler_spec_id: "gcc", + return_42: true, + }); + } + const repeatedPath = join(workspace, "signatures.bin"); + const repeatedBytes = Buffer.from("b82a000000c3".repeat(1005), "hex"); + const repeatedSha256 = createHash("sha256") + .update(repeatedBytes) + .digest("hex"); + await writeFile(repeatedPath, repeatedBytes); await call("open_binary", { - path: x86Path, + path: repeatedPath, format: "raw-binary", - architecture, + architecture: "x86_64", entry_point: "0x1000", }); - const image = await call("inspect_native_load_image", {}, x86Sha256); + // Import/analysis is complete before timing the first uncached search. + await call("read_bytes", { address: "0x1000", length: 6 }, repeatedSha256); + const started = performance.now(); + const page = await call( + "search_bytes", + { pattern: "b8 ?? 00 00 00 c3", limit: 1000 }, + repeatedSha256, + ); + const searchElapsedMs = performance.now() - started; + assert.equal(page.normalized_result.matches.length, 1000); + assert.equal(page.normalized_result.complete, false); + const rest = await call( + "search_bytes", + { + pattern: "b8 ?? 00 00 00 c3", + limit: 1000, + cursor: page.normalized_result.next_cursor, + }, + repeatedSha256, + ); + assert.equal(rest.normalized_result.matches.length, 5); assert.equal( - image.normalized_result.status, - "verified", - JSON.stringify(image), + rest.normalized_result.matches[0].address, + `0x${(0x1000 + 6000).toString(16)}`, ); - assert.equal(image.normalized_result.base_address, "0x1000"); - assert.equal(image.normalized_result.observations.compiler_spec_id, "gcc"); - assert.equal(image.analysis_profile.parameters.compiler_spec_id, "gcc"); - const memory = await call( - "read_bytes", - { address: "0x1000", length: x86Bytes.length }, - x86Sha256, + assert.equal(rest.normalized_result.complete, true); + const masked = await call( + "search_bytes", + { pattern: "bf", mask: "f0", start: "0x1000", end: "0x1005" }, + repeatedSha256, ); - assert.equal(memory.normalized_result.bytes_hex, x86Bytes.toString("hex")); - const pseudo = await call( - "procedure_pseudo_code", - { procedure: "0x1000" }, - x86Sha256, + assert.deepEqual( + masked.normalized_result.matches.map((match) => match.address), + ["0x1000"], ); - assert.match(pseudo.normalized_result, /\b(?:42|0x2a)\b/iu); - await call("close_binary"); - assert.deepEqual(await readFile(x86Path), x86Bytes); - x86Targets.push({ - architecture, - sha256: x86Sha256, - compiler_spec_id: "gcc", - return_42: true, + const overlapping = await call( + "search_bytes", + { pattern: "00 00", start: "0x1000", end: "0x1005" }, + repeatedSha256, + ); + assert.deepEqual( + overlapping.normalized_result.matches.map((match) => match.address), + ["0x1002", "0x1003"], + ); + const wrongCursor = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "c3", cursor: page.normalized_result.next_cursor }, }); - } - const repeatedPath = join(workspace, "signatures.bin"); - const repeatedBytes = Buffer.from("b82a000000c3".repeat(1005), "hex"); - const repeatedSha256 = createHash("sha256") - .update(repeatedBytes) - .digest("hex"); - await writeFile(repeatedPath, repeatedBytes); - await call("open_binary", { - path: repeatedPath, - format: "raw-binary", - architecture: "x86_64", - entry_point: "0x1000", - }); - // Import/analysis is complete before timing the first uncached search. - await call("read_bytes", { address: "0x1000", length: 6 }, repeatedSha256); - const started = performance.now(); - const page = await call( - "search_bytes", - { pattern: "b8 ?? 00 00 00 c3", limit: 1000 }, - repeatedSha256, - ); - const searchElapsedMs = performance.now() - started; - assert.equal(page.normalized_result.matches.length, 1000); - assert.equal(page.normalized_result.complete, false); - const rest = await call( - "search_bytes", - { - pattern: "b8 ?? 00 00 00 c3", - limit: 1000, - cursor: page.normalized_result.next_cursor, - }, - repeatedSha256, - ); - assert.equal(rest.normalized_result.matches.length, 5); - assert.equal( - rest.normalized_result.matches[0].address, - `0x${(0x1000 + 6000).toString(16)}`, - ); - assert.equal(rest.normalized_result.complete, true); - const masked = await call( - "search_bytes", - { pattern: "bf", mask: "f0", start: "0x1000", end: "0x1005" }, - repeatedSha256, - ); - assert.deepEqual( - masked.normalized_result.matches.map((match) => match.address), - ["0x1000"], - ); - const overlapping = await call( - "search_bytes", - { pattern: "00 00", start: "0x1000", end: "0x1005" }, - repeatedSha256, - ); - assert.deepEqual( - overlapping.normalized_result.matches.map((match) => match.address), - ["0x1002", "0x1003"], - ); - const wrongCursor = await client.callTool({ - name: "search_bytes", - arguments: { pattern: "c3", cursor: page.normalized_result.next_cursor }, - }); - assert.equal(wrongCursor.isError, true); - await call("close_binary"); - assert.deepEqual(await readFile(repeatedPath), repeatedBytes); + assert.equal(wrongCursor.isError, true); + await call("close_binary"); + assert.deepEqual(await readFile(repeatedPath), repeatedBytes); - // Two adjacent virtual regions backed by disjoint file ranges. The same - // signature crosses their boundary and overlaps at every possible start. - const splitPath = join(workspace, "split.elf"); - const splitBytes = splitSearchElf(0); - const splitSha256 = createHash("sha256").update(splitBytes).digest("hex"); - await writeFile(splitPath, splitBytes); - await call("open_binary", { path: splitPath, provider_id: "ghidra" }); - let cursorReopened = false; - for (const pattern of ["aa aa aa", "aa ?? aa"]) { - let cursor; - const addresses = []; - const pages = []; - do { - const evidence = await call( + // Two adjacent virtual regions backed by disjoint file ranges. The same + // signature crosses their boundary and overlaps at every possible start. + const splitPath = join(workspace, "split.elf"); + const splitBytes = splitSearchElf(0); + const splitSha256 = createHash("sha256").update(splitBytes).digest("hex"); + await writeFile(splitPath, splitBytes); + await call("open_binary", { path: splitPath, provider_id: "ghidra" }); + let cursorReopened = false; + for (const pattern of ["aa aa aa", "aa ?? aa"]) { + let cursor; + const addresses = []; + const pages = []; + do { + const evidence = await call( + "search_bytes", + { + pattern, + start: "0x401000", + end: "0x401007", + limit: 2, + ...(cursor === undefined ? {} : { cursor }), + }, + splitSha256, + "elf", + ); + const result = evidence.normalized_result; + addresses.push(...result.matches.map((match) => match.address)); + pages.push({ + matches: result.matches.map(({ address, segment, file_offset }) => ({ + address, + segment, + file_offset, + })), + blocks: result.searched.blocks, + }); + cursor = result.next_cursor ?? undefined; + if (cursor !== undefined && !cursorReopened) { + const snapshot = join(workspace, "split-snapshot.json"); + await call("close_binary", { snapshot_path: snapshot }); + await call("open_binary", { + path: splitPath, + provider_id: "ghidra", + snapshot_path: snapshot, + }); + cursorReopened = true; + } + } while (cursor !== undefined); + assert.deepEqual(addresses, [ + "0x401000", + "0x401001", + "0x401002", + "0x401003", + "0x401004", + "0x401005", + ]); + assert.deepEqual(pages, [ + { + matches: [ + { address: "0x401000", segment: "segment_0", file_offset: 0x1000 }, + { address: "0x401001", segment: "segment_0", file_offset: 0x1001 }, + ], + blocks: [ + { + segment: "segment_0", + start: "0x401000", + end: "0x401003", + bytes: 4, + }, + { + segment: "segment_1", + start: "0x401004", + end: "0x401004", + bytes: 1, + }, + ], + }, + { + matches: [ + { address: "0x401002", segment: "segment_0", file_offset: 0x1002 }, + { address: "0x401003", segment: "segment_0", file_offset: 0x1003 }, + ], + blocks: [ + { + segment: "segment_0", + start: "0x401002", + end: "0x401003", + bytes: 2, + }, + { + segment: "segment_1", + start: "0x401004", + end: "0x401006", + bytes: 3, + }, + ], + }, + { + matches: [ + { address: "0x401004", segment: "segment_1", file_offset: 0x3000 }, + { address: "0x401005", segment: "segment_1", file_offset: 0x3001 }, + ], + blocks: [ + { + segment: "segment_1", + start: "0x401004", + end: "0x401007", + bytes: 4, + }, + ], + }, + ]); + const bounded = await call( "search_bytes", { pattern, start: "0x401000", - end: "0x401007", - limit: 2, - ...(cursor === undefined ? {} : { cursor }), + end: "0x401005", + limit: 20, }, splitSha256, "elf", ); - const result = evidence.normalized_result; - addresses.push(...result.matches.map((match) => match.address)); - pages.push({ - matches: result.matches.map(({ address, segment, file_offset }) => ({ - address, - segment, - file_offset, - })), - blocks: result.searched.blocks, - }); - cursor = result.next_cursor ?? undefined; - if (cursor !== undefined && !cursorReopened) { - const snapshot = join(workspace, "split-snapshot.json"); - await call("close_binary", { snapshot_path: snapshot }); - await call("open_binary", { - path: splitPath, - provider_id: "ghidra", - snapshot_path: snapshot, - }); - cursorReopened = true; - } - } while (cursor !== undefined); - assert.deepEqual(addresses, [ - "0x401000", - "0x401001", - "0x401002", - "0x401003", - "0x401004", - "0x401005", - ]); - assert.deepEqual(pages, [ - { - matches: [ - { address: "0x401000", segment: "segment_0", file_offset: 0x1000 }, - { address: "0x401001", segment: "segment_0", file_offset: 0x1001 }, - ], - blocks: [ - { - segment: "segment_0", - start: "0x401000", - end: "0x401003", - bytes: 4, - }, - { - segment: "segment_1", - start: "0x401004", - end: "0x401004", - bytes: 1, - }, - ], - }, - { - matches: [ - { address: "0x401002", segment: "segment_0", file_offset: 0x1002 }, - { address: "0x401003", segment: "segment_0", file_offset: 0x1003 }, - ], - blocks: [ - { - segment: "segment_0", - start: "0x401002", - end: "0x401003", - bytes: 2, - }, - { - segment: "segment_1", - start: "0x401004", - end: "0x401006", - bytes: 3, - }, - ], - }, - { - matches: [ - { address: "0x401004", segment: "segment_1", file_offset: 0x3000 }, - { address: "0x401005", segment: "segment_1", file_offset: 0x3001 }, - ], - blocks: [ - { - segment: "segment_1", - start: "0x401004", - end: "0x401007", - bytes: 4, - }, - ], - }, - ]); - const bounded = await call( - "search_bytes", - { - pattern, - start: "0x401000", - end: "0x401005", - limit: 20, - }, + assert.deepEqual( + bounded.normalized_result.matches.map((match) => match.address), + addresses.slice(0, 4), + ); + } + assert.equal(cursorReopened, true); + const fileStart = await call( + "address_to_file_offset", + { address: "0x401000" }, splitSha256, "elf", ); - assert.deepEqual( - bounded.normalized_result.matches.map((match) => match.address), - addresses.slice(0, 4), + const fileSecond = await call( + "address_to_file_offset", + { address: "0x401004" }, + splitSha256, + "elf", ); + assert.equal(fileStart.normalized_result.file_offset, 0x1000); + assert.equal(fileSecond.normalized_result.file_offset, 0x3000); + await call("close_binary"); + assert.deepEqual(await readFile(splitPath), splitBytes); + await verifyCrossBlockProject(splitPath, splitSha256); + const gapPath = join(workspace, "gap.elf"); + const gapBytes = splitSearchElf(1); + const gapSha256 = createHash("sha256").update(gapBytes).digest("hex"); + await writeFile(gapPath, gapBytes); + await call("open_binary", { path: gapPath, provider_id: "ghidra" }); + const gap = await call( + "search_bytes", + { + pattern: "aa aa aa aa ?? aa aa aa aa", + start: "0x401000", + end: "0x401008", + }, + gapSha256, + "elf", + ); + assert.deepEqual(gap.normalized_result.matches, []); + await call("close_binary"); + assert.deepEqual(await readFile(gapPath), gapBytes); + const zeroFillPath = join(workspace, "initialized-zerofill.elf"); + const zeroFillBytes = splitSearchElf(1, true); + const zeroFillSha256 = createHash("sha256") + .update(zeroFillBytes) + .digest("hex"); + await writeFile(zeroFillPath, zeroFillBytes); + await call("open_binary", { path: zeroFillPath, provider_id: "ghidra" }); + const zeroFill = await call( + "search_bytes", + { + pattern: "aa aa aa aa ?? aa aa aa aa", + start: "0x401000", + end: "0x401008", + }, + zeroFillSha256, + "elf", + ); + // This Ghidra loader initializes the ELF zero-fill byte in this fixture. + assert.deepEqual( + zeroFill.normalized_result.matches.map((match) => match.address), + ["0x401000"], + ); + assert.deepEqual( + zeroFill.normalized_result.searched.skipped_uninitialized, + [], + ); + await call("close_binary"); + assert.deepEqual(await readFile(zeroFillPath), zeroFillBytes); + report = { + ok: true, + mocked: false, + lane: "ghidra-raw", + host: process.platform, + provider: { id: "ghidra", version: installation.providerVersion }, + target: { + format: "raw-binary", + architecture: "arm", + sha256, + bytes: bytes.length, + }, + transport: { cli: true, stdio_mcp: true }, + canonical_entry_reuses_session: true, + changed_entry_replaces_session: true, + lifecycle_entry_preserved: true, + invalid_entry_preserves_active_session: true, + load_images_verified: true, + original_unchanged: true, + x86_targets: x86Targets, + byte_search: { + cross_block_paging_and_gap: true, + matches: 1005, + page_size: 1000, + first_search_elapsed_ms: searchElapsedMs, + timing_scope: "MCP request after import; first uncached search", + token_usage: "unmeasured", + process_family_cpu_time: "unmeasured", + peak_process_family_memory: "unmeasured", + }, + }; + } catch (error) { + if (stderr.trim()) process.stderr.write(stderr); + throw error; } - assert.equal(cursorReopened, true); - const fileStart = await call( - "address_to_file_offset", - { address: "0x401000" }, - splitSha256, - "elf", - ); - const fileSecond = await call( - "address_to_file_offset", - { address: "0x401004" }, - splitSha256, - "elf", - ); - assert.equal(fileStart.normalized_result.file_offset, 0x1000); - assert.equal(fileSecond.normalized_result.file_offset, 0x3000); - await call("close_binary"); - assert.deepEqual(await readFile(splitPath), splitBytes); - await verifyCrossBlockProject(splitPath, splitSha256); - const gapPath = join(workspace, "gap.elf"); - const gapBytes = splitSearchElf(1); - const gapSha256 = createHash("sha256").update(gapBytes).digest("hex"); - await writeFile(gapPath, gapBytes); - await call("open_binary", { path: gapPath, provider_id: "ghidra" }); - const gap = await call( - "search_bytes", - { - pattern: "aa aa aa aa ?? aa aa aa aa", - start: "0x401000", - end: "0x401008", - }, - gapSha256, - "elf", - ); - assert.deepEqual(gap.normalized_result.matches, []); - await call("close_binary"); - assert.deepEqual(await readFile(gapPath), gapBytes); - const zeroFillPath = join(workspace, "initialized-zerofill.elf"); - const zeroFillBytes = splitSearchElf(1, true); - const zeroFillSha256 = createHash("sha256") - .update(zeroFillBytes) - .digest("hex"); - await writeFile(zeroFillPath, zeroFillBytes); - await call("open_binary", { path: zeroFillPath, provider_id: "ghidra" }); - const zeroFill = await call( - "search_bytes", - { - pattern: "aa aa aa aa ?? aa aa aa aa", - start: "0x401000", - end: "0x401008", - }, - zeroFillSha256, - "elf", - ); - // This Ghidra loader initializes the ELF zero-fill byte in this fixture. - assert.deepEqual( - zeroFill.normalized_result.matches.map((match) => match.address), - ["0x401000"], - ); - assert.deepEqual( - zeroFill.normalized_result.searched.skipped_uninitialized, - [], - ); - await call("close_binary"); - assert.deepEqual(await readFile(zeroFillPath), zeroFillBytes); - report = { - ok: true, - mocked: false, - lane: "ghidra-raw", - host: process.platform, - provider: { id: "ghidra", version: installation.providerVersion }, - target: { - format: "raw-binary", - architecture: "arm", - sha256, - bytes: bytes.length, - }, - transport: { cli: true, stdio_mcp: true }, - canonical_entry_reuses_session: true, - changed_entry_replaces_session: true, - lifecycle_entry_preserved: true, - invalid_entry_preserves_active_session: true, - load_images_verified: true, - original_unchanged: true, - x86_targets: x86Targets, - byte_search: { - cross_block_paging_and_gap: true, - matches: 1005, - page_size: 1000, - first_search_elapsed_ms: searchElapsedMs, - timing_scope: "MCP request after import; first uncached search", - token_usage: "unmeasured", - process_family_cpu_time: "unmeasured", - peak_process_family_memory: "unmeasured", - }, - }; -} catch (error) { - if (stderr.trim()) process.stderr.write(stderr); - throw error; -} finally { - await client.close(); - await transport.close(); - if (fixtureCleaned) await rm(workspace, { recursive: true, force: true }); -} -const verifierRun = await completeVerifierRun(run); +}, [ + async () => { + await closeVerifierResources([ + async () => { + if (connected) await call("close_binary", {}); + }, + () => client.close(), + () => transport.close(), + ]); + assert.deepEqual( + (await readdir(runtime)).filter((name) => name.startsWith("rea-ghidra-")), + [], + "Owned Ghidra runtime survived close", + ); + assert.equal( + fixtureCleaned, + true, + "Owned Java fixture cleanup remains pending", + ); + verifierRun = await completeVerifierRun(run); + assert.equal(verifierRun.process_lineage.status, "verified"); + assert.deepEqual(verifierRun.process_lineage.descendants, []); + await rm(workspace, { recursive: true, force: true }); + }, +]); +verifierRun = await completeVerifierRun(run); assert.equal(verifierRun.process_lineage.status, "verified"); assert.deepEqual(verifierRun.process_lineage.descendants, []); process.stdout.write( diff --git a/scripts/verify-real-ghidra-swift.mjs b/scripts/verify-real-ghidra-swift.mjs index c28824fe09..23ada4c16c 100644 --- a/scripts/verify-real-ghidra-swift.mjs +++ b/scripts/verify-real-ghidra-swift.mjs @@ -1,4 +1,8 @@ #!/usr/bin/env node +import { + closeVerifierResources, + withVerifierCleanup, +} from "./lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { @@ -71,9 +75,11 @@ const literalObjects = ["decoder-safeguards.S", "unresolved-string-jump.s"].map( }), ); const reports = []; +let connected = false; +let verifierRun; let literalReport; let decoderCleaned = true; -try { +await withVerifierCleanup(async () => { await execute("swiftc", ["-O", "-g", source, "-o", binary], { timeout: 60_000, }); @@ -110,12 +116,13 @@ try { ); await verifyDecoderSafeguards(); await client.connect(transport); + connected = true; for (const [variant, path] of [ ["debug", binary], ["without-dsym", copied], ]) { await call("open_binary", { path, provider_id: "ghidra" }); - try { + await withVerifierCleanup(async () => { const procedures = await call("list_procedures", {}); const inventory = await call("analyze_swift_types", {}); assert( @@ -183,16 +190,25 @@ try { metadata_accessors: metadata.total, unclassified: inventory.unclassified.length, }); - } finally { - await call("close_binary", {}); - } + }, [() => call("close_binary", {})]); } literalReport = await verifyStringLiterals(); -} finally { - await client.close(); - await transport.close(); - if (decoderCleaned) await rm(root, { recursive: true, force: true }); -} +}, [ + async () => { + await closeVerifierResources([ + async () => { + if (connected) await call("close_binary", {}); + }, + () => client.close(), + () => transport.close(), + ]); + assert.equal(decoderCleaned, true, "Owned decoder cleanup remains pending"); + verifierRun = await completeVerifierRun(run); + assert.equal(verifierRun.process_lineage.status, "verified"); + assert.deepEqual(verifierRun.process_lineage.descendants, []); + await rm(root, { recursive: true, force: true }); + }, +]); console.log( JSON.stringify( { @@ -200,7 +216,7 @@ console.log( provider: "ghidra", fixtures: reports, swift_string_literals: literalReport, - verifier_run: await completeVerifierRun(run), + verifier_run: verifierRun, }, null, 2, @@ -220,7 +236,7 @@ async function call(name, arguments_) { // Literal text lives only in register constants, so it must come from the decoded pairs. async function verifyStringLiterals() { await call("open_binary", { path: literalBinary, provider_id: "ghidra" }); - try { + return withVerifierCleanup(async () => { const procedures = await call("list_procedures", {}); const expected = { smallLiteral: [["SleepDisabled", "small"]], @@ -281,9 +297,7 @@ async function verifyStringLiterals() { decoded.unresolvedStringJump = unresolvedDossier.swift_string_literal_omissions.length; return decoded; - } finally { - await call("close_binary", {}); - } + }, [() => call("close_binary", {})]); } // Exercise the Java producer inside an ephemeral Ghidra database. The explicit diff --git a/scripts/verify-real-ghidra-switch.mjs b/scripts/verify-real-ghidra-switch.mjs index 5797969234..fae601d5cd 100644 --- a/scripts/verify-real-ghidra-switch.mjs +++ b/scripts/verify-real-ghidra-switch.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { requireMcpOperationResult, @@ -95,7 +96,7 @@ await writeFile(source, buildSwitchSource()); const flags = ["-O2", "-g", "-fno-pie", "-no-pie"]; const results = []; let productionControls = null; -try { +await withVerifierCleanup(async () => { for (const compiler of ["gcc", "clang"]) { const original = join(workspace, `${compiler}-optimized`); await exec(commands[compiler], [...flags, source, "-o", original]); @@ -169,17 +170,19 @@ try { }); } } -} finally { - try { +}, [ + async () => { assert.deepEqual( (await readdir(runtime)).filter((name) => name.startsWith("rea-ghidra-")), [], "Owned Ghidra runtime survived close", ); - } finally { + const cleanup = await completeVerifierRun(run); + assert.equal(cleanup.process_lineage.status, "verified"); + assert.deepEqual(cleanup.process_lineage.descendants, []); await rm(workspace, { recursive: true, force: true }); - } -} + }, +]); const lifecycle = await completeVerifierRun(run); if (lifecycle.process_lineage.status === "verified") assert.deepEqual(lifecycle.process_lineage.descendants, []); @@ -212,7 +215,7 @@ async function verifyTarget(path, sha256, fixtures, variant) { let opened = false; let stderr = ""; const observations = []; - try { + return withVerifierCleanup(async () => { await client.connect(transport); transport.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString()).slice(-65536); @@ -287,17 +290,13 @@ async function verifyTarget(path, sha256, fixtures, variant) { }); } return { observations, stderr }; - } finally { - try { + }, [ + async () => { if (opened) await call(client, "close_binary"); - } finally { - try { - await client.close(); - } finally { - await transport.close(); - } - } - } + }, + () => client.close(), + () => transport.close(), + ]); } function assertWorkflowIdentity(evidence, sha256) { assert.equal(evidence.subject.digest.sha256, sha256); diff --git a/scripts/verify-release-artifact.mjs b/scripts/verify-release-artifact.mjs index b04f1d0a4f..e918e7aafd 100644 --- a/scripts/verify-release-artifact.mjs +++ b/scripts/verify-release-artifact.mjs @@ -1,3 +1,5 @@ +import { createPackageVerifierCommands } from "./lib/verify-package-commands.mjs"; +import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -26,22 +28,25 @@ const environment = { npm_config_cache: join(workspace, "npm-cache"), }; delete environment.HOPPER_TARGET_PATH; +const commands = await createPackageVerifierCommands({ + workspace: { + path: workspace, + close: () => rm(workspace, { recursive: true, force: true }), + }, +}); let result; -try { +await withVerifierCleanup(async () => { const packageRoot = await installCanaryPackage( source, workspace, environment, + commands, ); - result = await verifyInstalledCanaryPackage( - packageRoot, - version, - workspace, - environment, - ); -} finally { - await rm(workspace, { recursive: true, force: true }); -} + result = await verifyInstalledCanaryPackage(packageRoot, version, workspace, { + environment: environment, + commands, + }); +}, [() => commands.close()]); process.stdout.write( `${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), package: "rea-agents", version, source, ...result })}\n`, ); diff --git a/scripts/verify-windows-native.mjs b/scripts/verify-windows-native.mjs index a3eedf3660..54dcbdfb8d 100644 --- a/scripts/verify-windows-native.mjs +++ b/scripts/verify-windows-native.mjs @@ -215,500 +215,506 @@ const wait = async (child) => { throw new Error("Owned job did not settle within the fixture deadline."); }; const report = { ok: false, artifact: "verified", controls: {}, processes: {} }; -try { - // Exercise the loader in a process whose image is not named node.exe. The - // ordinary Windows lane otherwise cannot detect eager Node-API imports. - const renamedHost = join(workspace, "rea-renamed-node.exe"); - const renamedHostFixture = join(workspace, "renamed-host.mjs"); - const loaderUrl = pathToFileURL( - join(packageRoot, "dist/windows/WindowsNativeLoader.js"), - ).href; - await copyFile(process.execPath, renamedHost); - await writeFile( - renamedHostFixture, - `import {basename} from 'node:path'; +await withVerifierCleanup(async () => { + try { + // Exercise the loader in a process whose image is not named node.exe. The + // ordinary Windows lane otherwise cannot detect eager Node-API imports. + const renamedHost = join(workspace, "rea-renamed-node.exe"); + const renamedHostFixture = join(workspace, "renamed-host.mjs"); + const loaderUrl = pathToFileURL( + join(packageRoot, "dist/windows/WindowsNativeLoader.js"), + ).href; + await copyFile(process.execPath, renamedHost); + await writeFile( + renamedHostFixture, + `import {basename} from 'node:path'; import {requireWindowsNativeAuthority} from ${JSON.stringify(loaderUrl)}; const {inspection}=requireWindowsNativeAuthority(); process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVersion:inspection.abiVersion}));\n`, - ); - const renamedHostResult = execFileSync(renamedHost, [renamedHostFixture], { - encoding: "utf8", - timeout: 20_000, - windowsHide: true, - }); - assert.deepEqual(JSON.parse(renamedHostResult), { - executable: "rea-renamed-node.exe", - abiVersion: 1, - }); - report.controls.renamedHostNodeApi = true; - const sourceDirectory = join(workspace, "source"); - await mkdir(sourceDirectory); - const source = join(sourceDirectory, "target.bin"); - await writeFile(source, Buffer.alloc(1024 * 1024, 0x37)); - const sourceIdentity = native.call("open", [source]); - try { - for (const requested of [ - source.replaceAll("\\", "/"), - source.replace(/\\/u, "/"), - ]) { - const admitted = native.call("open", [requested]); - try { - assert.equal(admitted.requestedPath, requested); - assert.equal(admitted.finalPath, sourceIdentity.finalPath); - assert.equal(admitted.fileId, sourceIdentity.fileId); - assert.equal(native.call("read", [admitted.handle, 0, 1])[0], 0x37); - } finally { - native.call("close", [admitted.handle]); + ); + const renamedHostResult = execFileSync(renamedHost, [renamedHostFixture], { + encoding: "utf8", + timeout: 20_000, + windowsHide: true, + }); + assert.deepEqual(JSON.parse(renamedHostResult), { + executable: "rea-renamed-node.exe", + abiVersion: 1, + }); + report.controls.renamedHostNodeApi = true; + const sourceDirectory = join(workspace, "source"); + await mkdir(sourceDirectory); + const source = join(sourceDirectory, "target.bin"); + await writeFile(source, Buffer.alloc(1024 * 1024, 0x37)); + const sourceIdentity = native.call("open", [source]); + try { + for (const requested of [ + source.replaceAll("\\", "/"), + source.replace(/\\/u, "/"), + ]) { + const admitted = native.call("open", [requested]); + try { + assert.equal(admitted.requestedPath, requested); + assert.equal(admitted.finalPath, sourceIdentity.finalPath); + assert.equal(admitted.fileId, sourceIdentity.fileId); + assert.equal(native.call("read", [admitted.handle, 0, 1])[0], 0x37); + } finally { + native.call("close", [admitted.handle]); + } } + } finally { + native.call("close", [sourceIdentity.handle]); } - } finally { - native.call("close", [sourceIdentity.handle]); - } - for (const rejected of [ - source.replace(/^([A-Za-z]):\\/u, "$1:"), - `${sourceDirectory}/../source/target.bin`, - `${sourceDirectory}//target.bin`, - `${source}:stream`, - `\\\\?\\${source.replaceAll("\\", "/")}`, - "//localhost/share/target.bin", - ]) - assert.throws(() => native.call("open", [rejected])); - assert.throws( - () => native.call("cutter_bridge_verify_directory", [sourceDirectory]), - /owner|DACL/u, - ); - report.controls.ordinaryDriveSeparatorsAndRequestedIdentity = true; - // An owned runtime's DELETE lease conflicts with a fresh parent admission. - // This produces a genuine sharing failure after successful authority caching, - // without another executable, and proves that no child runtime was created. - const sharingParent = WindowsPrivateRuntime.create( - workspace, - "rea-sharing-parent-", - ); - try { - await assert.rejects( - PrivateRuntimeRoot.create({ parent: sharingParent.observation.path }), - (failure) => { - assert.ok(failure instanceof PrivateRuntimeRootUnavailableError); - assert.equal(failure.retryable, true); - assert.ok(failure.cause instanceof Error); - assert.equal(failure.cause.win32Code, 32); - assert.equal(failure.cause.runtimeAllocation, "not-started"); - return true; - }, + for (const rejected of [ + source.replace(/^([A-Za-z]):\\/u, "$1:"), + `${sourceDirectory}/../source/target.bin`, + `${sourceDirectory}//target.bin`, + `${source}:stream`, + `\\\\?\\${source.replaceAll("\\", "/")}`, + "//localhost/share/target.bin", + ]) + assert.throws(() => native.call("open", [rejected])); + assert.throws( + () => native.call("cutter_bridge_verify_directory", [sourceDirectory]), + /owner|DACL/u, ); - assert.deepEqual(await readdir(sharingParent.observation.path), []); - report.controls.transientRuntimeAdmissionBeforeAllocation = true; - } finally { - await sharingParent.close(); - } - const driveRoot = parse(workspace).root; - const runtimeParent = ghidraSessionRoot({ - base: join(driveRoot, ".Tmp", "scratch"), - }); - assert.equal(runtimeParent, driveRoot); - const driveRootRuntime = native.call("runtime_create", [ - runtimeParent, - "rea-drive-root-", - ]); - try { - assert.equal(driveRootRuntime.path.startsWith(`${driveRoot}\\`), false); - assert.equal(dirname(driveRootRuntime.path), driveRoot); - const snapshot = await native.call("runtime_snapshot", [ - driveRootRuntime.handle, - source, - "probe.bin", - ]); - const sourceBytes = await readFile(source); - assert.equal( - snapshot.sha256, - createHash("sha256").update(sourceBytes).digest("hex"), + report.controls.ordinaryDriveSeparatorsAndRequestedIdentity = true; + // An owned runtime's DELETE lease conflicts with a fresh parent admission. + // This produces a genuine sharing failure after successful authority caching, + // without another executable, and proves that no child runtime was created. + const sharingParent = WindowsPrivateRuntime.create( + workspace, + "rea-sharing-parent-", ); - assert.equal(snapshot.source.requestedPath, source); - const readback = native.call("runtime_open", [ - driveRootRuntime.handle, - "probe.bin", + try { + await assert.rejects( + PrivateRuntimeRoot.create({ parent: sharingParent.observation.path }), + (failure) => { + assert.ok(failure instanceof PrivateRuntimeRootUnavailableError); + assert.equal(failure.retryable, true); + assert.ok(failure.cause instanceof Error); + assert.equal(failure.cause.win32Code, 32); + assert.equal(failure.cause.runtimeAllocation, "not-started"); + return true; + }, + ); + assert.deepEqual(await readdir(sharingParent.observation.path), []); + report.controls.transientRuntimeAdmissionBeforeAllocation = true; + } finally { + await sharingParent.close(); + } + const driveRoot = parse(workspace).root; + const runtimeParent = ghidraSessionRoot({ + base: join(driveRoot, ".Tmp", "scratch"), + }); + assert.equal(runtimeParent, driveRoot); + const driveRootRuntime = native.call("runtime_create", [ + runtimeParent, + "rea-drive-root-", ]); try { - assert.equal(readback.size, sourceBytes.byteLength); - assert.deepEqual( - native.call("read", [readback.handle, 0, 65536]), - sourceBytes.subarray(0, 65536), + assert.equal(driveRootRuntime.path.startsWith(`${driveRoot}\\`), false); + assert.equal(dirname(driveRootRuntime.path), driveRoot); + const snapshot = await native.call("runtime_snapshot", [ + driveRootRuntime.handle, + source, + "probe.bin", + ]); + const sourceBytes = await readFile(source); + assert.equal( + snapshot.sha256, + createHash("sha256").update(sourceBytes).digest("hex"), ); + assert.equal(snapshot.source.requestedPath, source); + const readback = native.call("runtime_open", [ + driveRootRuntime.handle, + "probe.bin", + ]); + try { + assert.equal(readback.size, sourceBytes.byteLength); + assert.deepEqual( + native.call("read", [readback.handle, 0, 65536]), + sourceBytes.subarray(0, 65536), + ); + } finally { + native.call("close", [readback.handle]); + } } finally { - native.call("close", [readback.handle]); + native.call("runtime_close", [driveRootRuntime.handle]); } - } finally { - native.call("runtime_close", [driveRootRuntime.handle]); - } - assert.equal(await exists(driveRootRuntime.path), false); - report.controls.driveRootRuntimeParent = true; - const snapshotOwner = WindowsPrivateRuntime.create( - workspace.replace(/\\/u, "/"), - "single-flight-", - ); - try { - const rejectedController = new AbortController(); - const requestedSource = source.replaceAll("\\", "/"); - const accepted = snapshotOwner.snapshot(requestedSource, "accepted.bin"); - const rejected = snapshotOwner.snapshot( - source, - "rejected.bin", - rejectedController.signal, + assert.equal(await exists(driveRootRuntime.path), false); + report.controls.driveRootRuntimeParent = true; + const snapshotOwner = WindowsPrivateRuntime.create( + workspace.replace(/\\/u, "/"), + "single-flight-", ); - rejectedController.abort(); - await assert.rejects(rejected, /Runtime snapshot is still pending/u); - const completed = await accepted; - assert.equal(completed.source.requestedPath, requestedSource); - assert.equal( - completed.sha256, - createHash("sha256") - .update(await readFile(source)) - .digest("hex"), - ); - assert.equal( - await exists(join(snapshotOwner.observation.path, "rejected.bin")), - false, - ); - report.controls.singleFlightCancellationOwnership = true; - } finally { - await snapshotOwner.close(); - } - const unrelatedFile = join(workspace, "unrelated-file"); - await writeFile(unrelatedFile, "preserve unrelated writes"); - if (process.argv[3] !== undefined) { - await verifyVolumeRootAppendLease({ - fixturePath: process.argv[3], - volumeRoot: parse(source).root, - verifyAdmission: () => { - const rootGuard = native.call("open", [source]); - native.call("close", [rootGuard.handle]); - }, - }); - report.controls.volumeRootAdmissionWithAppendHandle = true; - } - const admitted = native.call("open", [source]); - try { - await assert.rejects( - rename(sourceDirectory, `${sourceDirectory}-replacement`), - ); - await assert.rejects(open(source, "r+")); - await rename(unrelatedFile, `${unrelatedFile}-renamed`); - if (process.argv[3] !== undefined) { - const observation = JSON.parse( - execFileSync( - resolve(process.argv[3]), - ["reparse-directory", sourceDirectory, workspace], - { encoding: "utf8" }, - ), + try { + const rejectedController = new AbortController(); + const requestedSource = source.replaceAll("\\", "/"); + const accepted = snapshotOwner.snapshot(requestedSource, "accepted.bin"); + const rejected = snapshotOwner.snapshot( + source, + "rejected.bin", + rejectedController.signal, + ); + rejectedController.abort(); + await assert.rejects(rejected, /Runtime snapshot is still pending/u); + const completed = await accepted; + assert.equal(completed.source.requestedPath, requestedSource); + assert.equal( + completed.sha256, + createHash("sha256") + .update(await readFile(source)) + .digest("hex"), ); assert.equal( - observation.reparseConversionDenied, - true, - "An admitted ancestor was converted into a junction.", + await exists(join(snapshotOwner.observation.path, "rejected.bin")), + false, ); - report.controls.inPlaceAncestorReparseDenied = true; + report.controls.singleFlightCancellationOwnership = true; + } finally { + await snapshotOwner.close(); } - assert.throws(() => native.call("read", [{}, 0, 1])); - assert.throws(() => native.call("read", [admitted.handle, NaN, 1])); - assert.equal(native.call("read", [admitted.handle, 0, 1])[0], 0x37); - } finally { - native.call("close", [admitted.handle]); - } - assert.throws(() => native.call("read", [admitted.handle, 0, 1])); - const junction = join(workspace, "junction"); - await symlink(sourceDirectory, junction, "junction"); - assert.throws( - () => native.call("open", [join(junction, "target.bin")]), - /Reparse/u, - ); - assert.throws( - () => native.call("cutter_bridge_verify_directory", [junction]), - /Reparse/u, - ); - assert.throws(() => native.call("open", [source + ":stream"])); - const runtime = native.call("runtime_create", [ - workspace.replaceAll("\\", "/"), - "private root with spaces-", - ]); - const sentinel = join(sourceDirectory, "sentinel"); - await writeFile(sentinel, "preserve"); - const runtimeVerificationErrors = []; - try { - const acl = JSON.parse( - observeWithWindowsPowerShell(privateRuntimeAclObserver, { - REA_TEST_PRIVATE_RUNTIME_PATH: runtime.path, - }), + const unrelatedFile = join(workspace, "unrelated-file"); + await writeFile(unrelatedFile, "preserve unrelated writes"); + if (process.argv[3] !== undefined) { + await verifyVolumeRootAppendLease({ + fixturePath: process.argv[3], + volumeRoot: parse(source).root, + verifyAdmission: () => { + const rootGuard = native.call("open", [source]); + native.call("close", [rootGuard.handle]); + }, + }); + report.controls.volumeRootAdmissionWithAppendHandle = true; + } + const admitted = native.call("open", [source]); + try { + await assert.rejects( + rename(sourceDirectory, `${sourceDirectory}-replacement`), + ); + await assert.rejects(open(source, "r+")); + await rename(unrelatedFile, `${unrelatedFile}-renamed`); + if (process.argv[3] !== undefined) { + const observation = JSON.parse( + execFileSync( + resolve(process.argv[3]), + ["reparse-directory", sourceDirectory, workspace], + { encoding: "utf8" }, + ), + ); + assert.equal( + observation.reparseConversionDenied, + true, + "An admitted ancestor was converted into a junction.", + ); + report.controls.inPlaceAncestorReparseDenied = true; + } + assert.throws(() => native.call("read", [{}, 0, 1])); + assert.throws(() => native.call("read", [admitted.handle, NaN, 1])); + assert.equal(native.call("read", [admitted.handle, 0, 1])[0], 0x37); + } finally { + native.call("close", [admitted.handle]); + } + assert.throws(() => native.call("read", [admitted.handle, 0, 1])); + const junction = join(workspace, "junction"); + await symlink(sourceDirectory, junction, "junction"); + assert.throws( + () => native.call("open", [join(junction, "target.bin")]), + /Reparse/u, ); - assert.deepEqual(acl, { - protected: true, - ownerMatchesUser: true, - trusteesOnlyUserAndSystem: true, - count: 2, - rootReadonly: true, - }); - native.call("runtime_mkdir", [runtime.handle, "inputs"]); - native.call("runtime_write", [ - runtime.handle, - "descriptor.json", - Buffer.from("fixture"), + assert.throws( + () => native.call("cutter_bridge_verify_directory", [junction]), + /Reparse/u, + ); + assert.throws(() => native.call("open", [source + ":stream"])); + const runtime = native.call("runtime_create", [ + workspace.replaceAll("\\", "/"), + "private root with spaces-", ]); - assert.equal( - native.call("cutter_bridge_verify_directory", [runtime.path]).privateDacl, - true, - ); - assert.equal( - native - .call("cutter_bridge_read_descriptor", [ - runtime.path, - "descriptor.json", - 64 * 1024, - ]) - .toString("utf8"), - "fixture", - ); - const descriptorJunction = join(runtime.path, "descriptor-link.json"); - await symlink(sourceDirectory, descriptorJunction, "junction"); - assert.throws(() => - native.call("cutter_bridge_read_descriptor", [ - runtime.path, - "descriptor-link.json", - 64 * 1024, - ]), - ); - assert.throws( - () => - native.call("cutter_bridge_read_descriptor", [ - runtime.path, - "descriptor.json", - 3, - ]), - /byte limit/u, - ); - assert.throws( - () => - native.call("cutter_bridge_read_descriptor", [ - runtime.path, - "..\\escape.json", - 64 * 1024, - ]), - /filename component/u, - ); - assert.throws(() => + const sentinel = join(sourceDirectory, "sentinel"); + await writeFile(sentinel, "preserve"); + const runtimeVerificationErrors = []; + try { + const acl = JSON.parse( + observeWithWindowsPowerShell(privateRuntimeAclObserver, { + REA_TEST_PRIVATE_RUNTIME_PATH: runtime.path, + }), + ); + assert.deepEqual(acl, { + protected: true, + ownerMatchesUser: true, + trusteesOnlyUserAndSystem: true, + count: 2, + rootReadonly: true, + }); + native.call("runtime_mkdir", [runtime.handle, "inputs"]); native.call("runtime_write", [ runtime.handle, "descriptor.json", - Buffer.from("replace"), - ]), - ); - assert.throws(() => - native.call("runtime_mkdir", [runtime.handle, "..\\escape"]), - ); - const pending = native.call("runtime_snapshot", [ - runtime.handle, - source, - "inputs\\target.bin", - ]); - assert.ok(pending instanceof Promise); - assert.throws( - () => native.call("runtime_close", [runtime.handle]), - /pending/u, - ); - const snapshot = await pending; - assert.equal( - snapshot.sha256, - createHash("sha256") - .update(await readFile(source)) - .digest("hex"), - ); - assert.equal(snapshot.source.requestedPath, source); - await assert.rejects( - writeFile(join(runtime.path, "inputs", "target.bin"), "replace"), - ); - await symlink( - sourceDirectory, - join(runtime.path, "outside-junction"), - "junction", - ); - const cancelled = native.call("runtime_snapshot", [ - runtime.handle, - source, - "cancelled.bin", - ]); - native.call("runtime_snapshot_cancel", [runtime.handle]); - await assert.rejects(cancelled, /cancelled/u); - assert.equal(await exists(join(runtime.path, "cancelled.bin")), false); - const readbackRuntime = WindowsPrivateRuntime.create( - workspace.replaceAll("\\", "/"), - "readback-", - ); - try { - const written = "runtime-write-readback"; - const sourceBytes = await readFile(source); - readbackRuntime.writeFile("written.txt", written); + Buffer.from("fixture"), + ]); assert.equal( - readbackRuntime.readFile("written.txt", Buffer.byteLength(written)), - written, + native.call("cutter_bridge_verify_directory", [runtime.path]) + .privateDacl, + true, ); - const readbackSnapshot = await readbackRuntime.snapshot( + assert.equal( + native + .call("cutter_bridge_read_descriptor", [ + runtime.path, + "descriptor.json", + 64 * 1024, + ]) + .toString("utf8"), + "fixture", + ); + const descriptorJunction = join(runtime.path, "descriptor-link.json"); + await symlink(sourceDirectory, descriptorJunction, "junction"); + assert.throws(() => + native.call("cutter_bridge_read_descriptor", [ + runtime.path, + "descriptor-link.json", + 64 * 1024, + ]), + ); + assert.throws( + () => + native.call("cutter_bridge_read_descriptor", [ + runtime.path, + "descriptor.json", + 3, + ]), + /byte limit/u, + ); + assert.throws( + () => + native.call("cutter_bridge_read_descriptor", [ + runtime.path, + "..\\escape.json", + 64 * 1024, + ]), + /filename component/u, + ); + assert.throws(() => + native.call("runtime_write", [ + runtime.handle, + "descriptor.json", + Buffer.from("replace"), + ]), + ); + assert.throws(() => + native.call("runtime_mkdir", [runtime.handle, "..\\escape"]), + ); + const pending = native.call("runtime_snapshot", [ + runtime.handle, source, - "snapshot.bin", + "inputs\\target.bin", + ]); + assert.ok(pending instanceof Promise); + assert.throws( + () => native.call("runtime_close", [runtime.handle]), + /pending/u, ); + const snapshot = await pending; assert.equal( - readbackSnapshot.sha256, - createHash("sha256").update(sourceBytes).digest("hex"), + snapshot.sha256, + createHash("sha256") + .update(await readFile(source)) + .digest("hex"), ); - assert.equal( - readbackRuntime.readFile("snapshot.bin", sourceBytes.byteLength), - sourceBytes.toString("utf8"), + assert.equal(snapshot.source.requestedPath, source); + await assert.rejects( + writeFile(join(runtime.path, "inputs", "target.bin"), "replace"), ); - const writtenPath = join(readbackRuntime.observation.path, "written.txt"); - await assert.rejects(writeFile(writtenPath, "replace")); - await assert.rejects(rename(writtenPath, `${writtenPath}.renamed`)); - await assert.rejects(rm(writtenPath)); - assert.equal( - readbackRuntime.readFile("written.txt", Buffer.byteLength(written)), - written, + await symlink( + sourceDirectory, + join(runtime.path, "outside-junction"), + "junction", ); - const snapshotPath = join( - readbackRuntime.observation.path, - "snapshot.bin", + const cancelled = native.call("runtime_snapshot", [ + runtime.handle, + source, + "cancelled.bin", + ]); + native.call("runtime_snapshot_cancel", [runtime.handle]); + await assert.rejects(cancelled, /cancelled/u); + assert.equal(await exists(join(runtime.path, "cancelled.bin")), false); + const readbackRuntime = WindowsPrivateRuntime.create( + workspace.replaceAll("\\", "/"), + "readback-", ); - // Java RandomAccessFile uses read/write sharing without delete sharing. - // Exercise that independent Win32 reader contract without requiring Java. - const readerDigest = observeWithWindowsPowerShell( - String.raw` + try { + const written = "runtime-write-readback"; + const sourceBytes = await readFile(source); + readbackRuntime.writeFile("written.txt", written); + assert.equal( + readbackRuntime.readFile("written.txt", Buffer.byteLength(written)), + written, + ); + const readbackSnapshot = await readbackRuntime.snapshot( + source, + "snapshot.bin", + ); + assert.equal( + readbackSnapshot.sha256, + createHash("sha256").update(sourceBytes).digest("hex"), + ); + assert.equal( + readbackRuntime.readFile("snapshot.bin", sourceBytes.byteLength), + sourceBytes.toString("utf8"), + ); + const writtenPath = join( + readbackRuntime.observation.path, + "written.txt", + ); + await assert.rejects(writeFile(writtenPath, "replace")); + await assert.rejects(rename(writtenPath, `${writtenPath}.renamed`)); + await assert.rejects(rm(writtenPath)); + assert.equal( + readbackRuntime.readFile("written.txt", Buffer.byteLength(written)), + written, + ); + const snapshotPath = join( + readbackRuntime.observation.path, + "snapshot.bin", + ); + // Java RandomAccessFile uses read/write sharing without delete sharing. + // Exercise that independent Win32 reader contract without requiring Java. + const readerDigest = observeWithWindowsPowerShell( + String.raw` $stream = [IO.File]::Open($env:REA_SNAPSHOT_READER_PATH, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite) $hash = [Security.Cryptography.SHA256]::Create() try { [Console]::Out.Write([BitConverter]::ToString($hash.ComputeHash($stream)).Replace('-', '').ToLowerInvariant()) } finally { $hash.Dispose(); $stream.Dispose() } `, - { REA_SNAPSHOT_READER_PATH: snapshotPath }, + { REA_SNAPSHOT_READER_PATH: snapshotPath }, + ); + assert.equal(readerDigest, readbackSnapshot.sha256); + await assert.rejects(writeFile(snapshotPath, "replace")); + await assert.rejects(rename(snapshotPath, `${snapshotPath}.renamed`)); + await assert.rejects(rm(snapshotPath)); + assert.equal( + readbackRuntime.readFile("snapshot.bin", sourceBytes.byteLength), + sourceBytes.toString("utf8"), + ); + report.controls = { + ...report.controls, + completedRuntimeFileReadback: true, + completedRuntimeFileMutationDenied: true, + snapshotReadWriteSharingReader: true, + }; + } finally { + await readbackRuntime.close(); + } + const retryRuntime = WindowsPrivateRuntime.create( + workspace, + "cleanup-retry-", ); - assert.equal(readerDigest, readbackSnapshot.sha256); - await assert.rejects(writeFile(snapshotPath, "replace")); - await assert.rejects(rename(snapshotPath, `${snapshotPath}.renamed`)); - await assert.rejects(rm(snapshotPath)); - assert.equal( - readbackRuntime.readFile("snapshot.bin", sourceBytes.byteLength), - sourceBytes.toString("utf8"), + const heldPath = join(retryRuntime.observation.path, "snapshot.bin"); + let holder; + await withVerifierCleanup(async () => { + await retryRuntime.snapshot(source, "snapshot.bin"); + holder = await startDeleteSharingDeniedReader(heldPath); + // The reader denies delete sharing. Cleanup must retain ownership when + // its handle-relative open receives ERROR_SHARING_VIOLATION (32) through + // the bounded automatic retries; a later explicit close must succeed. + await assert.rejects(retryRuntime.close(), { win32Code: 32 }); + assert.equal( + windowsPrivateRuntime(retryRuntime.observation.path), + retryRuntime, + ); + assert.equal(await exists(heldPath), true); + assert.throws( + () => retryRuntime.writeFile("after-close.txt", "forbidden"), + /Windows private runtime cleanup is in progress or complete/u, + ); + assert.equal( + await exists(join(retryRuntime.observation.path, "after-close.txt")), + false, + ); + holder.child.stdin.end("\n"); + const [exitCode] = await holder.closed; + assert.equal(exitCode, 0); + await retryRuntime.close(); + await retryRuntime.close(); + assert.equal(await exists(retryRuntime.observation.path), false); + assert.throws( + () => windowsPrivateRuntime(retryRuntime.observation.path), + /No native private runtime/u, + ); + report.controls.cleanupFailureRetainsOwnershipForRetry = true; + }, [ + async () => { + if (holder !== undefined) await stopDeleteSharingDeniedReader(holder); + }, + () => retryRuntime.close(), + ]); + const transientRuntime = WindowsPrivateRuntime.create( + workspace, + "cleanup-transient-", ); + const transientHeldPath = join( + transientRuntime.observation.path, + "snapshot.bin", + ); + let transientHolder; + await withVerifierCleanup(async () => { + await transientRuntime.snapshot(source, "snapshot.bin"); + transientHolder = + await startDeleteSharingDeniedReader(transientHeldPath); + // Keep the reader held through the first close attempt. Without retries, + // close rejects before the next event-loop turn; with retries, it remains + // pending until the caller releases this sharing conflict. + let closeSettled = false; + const closing = transientRuntime.close(); + void closing.then( + () => { + closeSettled = true; + }, + () => { + closeSettled = true; + }, + ); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(closeSettled, false); + transientHolder.child.stdin.end("\n"); + const [, [exitCode]] = await Promise.all([ + closing, + transientHolder.closed, + ]); + assert.equal(exitCode, 0); + assert.equal(await exists(transientRuntime.observation.path), false); + assert.throws( + () => windowsPrivateRuntime(transientRuntime.observation.path), + /No native private runtime/u, + ); + report.controls.cleanupTransientConflictRetries = true; + }, [ + async () => { + if (transientHolder !== undefined) + await stopDeleteSharingDeniedReader(transientHolder); + }, + () => transientRuntime.close(), + ]); report.controls = { ...report.controls, - completedRuntimeFileReadback: true, - completedRuntimeFileMutationDenied: true, - snapshotReadWriteSharingReader: true, + protectedDaclReadback: true, + sourceReplacementDenied: true, + sourceWriteDenied: true, + unrelatedFilesystemRenamePreserved: true, + junctionAdmissionDenied: true, + alternateStreamsDenied: true, + forgedAndClosedHandlesDenied: true, + immutableSnapshotDigest: true, + asynchronousSnapshot: true, + snapshotCancellation: true, }; - } finally { - await readbackRuntime.close(); - } - const retryRuntime = WindowsPrivateRuntime.create( - workspace, - "cleanup-retry-", - ); - const heldPath = join(retryRuntime.observation.path, "snapshot.bin"); - let holder; - await withVerifierCleanup(async () => { - await retryRuntime.snapshot(source, "snapshot.bin"); - holder = await startDeleteSharingDeniedReader(heldPath); - // The reader denies delete sharing. Cleanup must retain ownership when - // its handle-relative open receives ERROR_SHARING_VIOLATION (32) through - // the bounded automatic retries; a later explicit close must succeed. - await assert.rejects(retryRuntime.close(), { win32Code: 32 }); - assert.equal( - windowsPrivateRuntime(retryRuntime.observation.path), - retryRuntime, - ); - assert.equal(await exists(heldPath), true); - assert.throws( - () => retryRuntime.writeFile("after-close.txt", "forbidden"), - /Windows private runtime cleanup is in progress or complete/u, - ); - assert.equal( - await exists(join(retryRuntime.observation.path, "after-close.txt")), - false, - ); - holder.child.stdin.end("\n"); - const [exitCode] = await holder.closed; - assert.equal(exitCode, 0); - await retryRuntime.close(); - await retryRuntime.close(); - assert.equal(await exists(retryRuntime.observation.path), false); - assert.throws( - () => windowsPrivateRuntime(retryRuntime.observation.path), - /No native private runtime/u, - ); - report.controls.cleanupFailureRetainsOwnershipForRetry = true; - }, [ - async () => { - if (holder !== undefined) await stopDeleteSharingDeniedReader(holder); - }, - () => retryRuntime.close(), - ]); - const transientRuntime = WindowsPrivateRuntime.create( - workspace, - "cleanup-transient-", - ); - const transientHeldPath = join( - transientRuntime.observation.path, - "snapshot.bin", - ); - let transientHolder; - await withVerifierCleanup(async () => { - await transientRuntime.snapshot(source, "snapshot.bin"); - transientHolder = await startDeleteSharingDeniedReader(transientHeldPath); - // Keep the reader held through the first close attempt. Without retries, - // close rejects before the next event-loop turn; with retries, it remains - // pending until the caller releases this sharing conflict. - let closeSettled = false; - const closing = transientRuntime.close(); - void closing.then( - () => { - closeSettled = true; - }, - () => { - closeSettled = true; - }, - ); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(closeSettled, false); - transientHolder.child.stdin.end("\n"); - const [, [exitCode]] = await Promise.all([ - closing, - transientHolder.closed, - ]); - assert.equal(exitCode, 0); - assert.equal(await exists(transientRuntime.observation.path), false); - assert.throws( - () => windowsPrivateRuntime(transientRuntime.observation.path), - /No native private runtime/u, - ); - report.controls.cleanupTransientConflictRetries = true; - }, [ - async () => { - if (transientHolder !== undefined) - await stopDeleteSharingDeniedReader(transientHolder); - }, - () => transientRuntime.close(), - ]); - report.controls = { - ...report.controls, - protectedDaclReadback: true, - sourceReplacementDenied: true, - sourceWriteDenied: true, - unrelatedFilesystemRenamePreserved: true, - junctionAdmissionDenied: true, - alternateStreamsDenied: true, - forgedAndClosedHandlesDenied: true, - immutableSnapshotDigest: true, - asynchronousSnapshot: true, - snapshotCancellation: true, - }; - // Paths are newline-separated: Windows file names cannot contain control - // characters, and the observer then needs no JSON parser. - const readonlyChildren = JSON.parse( - observeWithWindowsPowerShell( - String.raw` + // Paths are newline-separated: Windows file names cannot contain control + // characters, and the observer then needs no JSON parser. + const readonlyChildren = JSON.parse( + observeWithWindowsPowerShell( + String.raw` $observations = foreach ($item in $env:REA_TEST_READONLY_CHILDREN.Split([char]10)) { $current = [int][IO.File]::GetAttributes($item) $next = if (($current -band 16) -ne 0) { $current -bor 1 } else { 1 } @@ -720,270 +726,272 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe } [Console]::Out.Write('[' + ($observations -join ',') + ']') `, - { - REA_TEST_READONLY_CHILDREN: [ - join(runtime.path, "inputs"), - join(runtime.path, "inputs", "target.bin"), - ].join("\n"), - }, - ), - ); - assert.deepEqual(readonlyChildren, [ - { readonly: true, directory: true, onlyReadonly: false }, - { readonly: true, directory: false, onlyReadonly: true }, - ]); - } catch (failure) { - runtimeVerificationErrors.push(failure); - } - try { - native.call("runtime_close", [runtime.handle]); - } catch (cleanupFailure) { - runtimeVerificationErrors.push(cleanupFailure); - } - if (runtimeVerificationErrors.length === 1) - throw runtimeVerificationErrors[0]; - if (runtimeVerificationErrors.length > 1) - throw new AggregateError( - runtimeVerificationErrors, - "Native runtime verification and cleanup failed", - ); - assert.equal(await exists(runtime.path), false); - assert.equal(await readFile(sentinel, "utf8"), "preserve"); - report.controls.cleanupDoesNotTraverseJunctions = true; - report.controls.readonlyRootAndChildrenRemoved = true; - report.controls.readonlyFileWithoutArchiveAttributeRemoved = true; - const fixture = join(workspace, "tree.mjs"); - await writeFile( - fixture, - `import {spawn} from 'node:child_process'; import {writeFileSync} from 'node:fs'; + { + REA_TEST_READONLY_CHILDREN: [ + join(runtime.path, "inputs"), + join(runtime.path, "inputs", "target.bin"), + ].join("\n"), + }, + ), + ); + assert.deepEqual(readonlyChildren, [ + { readonly: true, directory: true, onlyReadonly: false }, + { readonly: true, directory: false, onlyReadonly: true }, + ]); + } catch (failure) { + runtimeVerificationErrors.push(failure); + } + try { + native.call("runtime_close", [runtime.handle]); + } catch (cleanupFailure) { + runtimeVerificationErrors.push(cleanupFailure); + } + if (runtimeVerificationErrors.length === 1) + throw runtimeVerificationErrors[0]; + if (runtimeVerificationErrors.length > 1) + throw new AggregateError( + runtimeVerificationErrors, + "Native runtime verification and cleanup failed", + ); + assert.equal(await exists(runtime.path), false); + assert.equal(await readFile(sentinel, "utf8"), "preserve"); + report.controls.cleanupDoesNotTraverseJunctions = true; + report.controls.readonlyRootAndChildrenRemoved = true; + report.controls.readonlyFileWithoutArchiveAttributeRemoved = true; + const fixture = join(workspace, "tree.mjs"); + await writeFile( + fixture, + `import {spawn} from 'node:child_process'; import {writeFileSync} from 'node:fs'; const depth=Number(process.argv[2]); writeFileSync(process.argv[3]+depth,String(process.pid)); if(depth>0)spawn(process.execPath,[import.meta.filename,String(depth-1),process.argv[3]],{detached:true,stdio:'ignore'}); setInterval(()=>{},1000);\n`, - ); - const pidPrefix = join(workspace, "pid-"); - const child = launch(["--max-old-space-size=128", fixture, "2", pidPrefix]); - const unrelated = spawn( - process.execPath, - ["--max-old-space-size=128", "-e", "setInterval(()=>{},1000)"], - { stdio: "ignore" }, - ); - try { - for ( - let attempt = 0; - attempt < 200 && !(await exists(pidPrefix + "0")); - attempt++ - ) - await delay(); - const pids = await Promise.all( - [0, 1, 2].map(async (depth) => - Number(await readFile(pidPrefix + depth, "utf8")), - ), ); - assert.ok(native.call("process_poll", [child.handle]).activeProcesses >= 3); - native.call("process_terminate", [child.handle]); - assert.equal((await wait(child)).activeProcesses, 0); - assert.equal(pids.some(live), false); - assert.ok(unrelated.pid !== undefined && live(unrelated.pid)); - report.processes.descendantsAndDetachedDescendantsTerminated = true; - report.processes.unrelatedProcessPreserved = true; - } finally { - native.call("process_terminate", [child.handle]); - await wait(child); - native.call("process_close", [child.handle]); - if (unrelated.exitCode === null) { - const closed = new Promise((resolve) => unrelated.once("close", resolve)); - unrelated.kill(); - await closed; + const pidPrefix = join(workspace, "pid-"); + const child = launch(["--max-old-space-size=128", fixture, "2", pidPrefix]); + const unrelated = spawn( + process.execPath, + ["--max-old-space-size=128", "-e", "setInterval(()=>{},1000)"], + { stdio: "ignore" }, + ); + try { + for ( + let attempt = 0; + attempt < 200 && !(await exists(pidPrefix + "0")); + attempt++ + ) + await delay(); + const pids = await Promise.all( + [0, 1, 2].map(async (depth) => + Number(await readFile(pidPrefix + depth, "utf8")), + ), + ); + assert.ok( + native.call("process_poll", [child.handle]).activeProcesses >= 3, + ); + native.call("process_terminate", [child.handle]); + assert.equal((await wait(child)).activeProcesses, 0); + assert.equal(pids.some(live), false); + assert.ok(unrelated.pid !== undefined && live(unrelated.pid)); + report.processes.descendantsAndDetachedDescendantsTerminated = true; + report.processes.unrelatedProcessPreserved = true; + } finally { + native.call("process_terminate", [child.handle]); + await wait(child); + native.call("process_close", [child.handle]); + if (unrelated.exitCode === null) { + const closed = new Promise((resolve) => + unrelated.once("close", resolve), + ); + unrelated.kill(); + await closed; + } } - } - const ownerClose = launch([ - "--max-old-space-size=128", - fixture, - "0", - join(workspace, "owner-close-"), - ]); - native.call("process_close", [ownerClose.handle]); - for (let attempt = 0; attempt < 100 && live(ownerClose.pid); attempt++) - await delay(); - assert.equal(live(ownerClose.pid), false); - report.processes.killOnOwnerClose = true; - report.processes.protocolInput = await verifyWindowsProtocolInput( - packageRoot, - workspace, - ); - report.processes.jobMemoryLimit = await verifyWindowsJobMemoryLimit( - packageRoot, - workspace, - ); - const exited = launch([ - "--max-old-space-size=128", - "-e", - "console.log('observed');process.exit(7)", - ]); - const settled = await wait(exited); - assert.equal(settled.exitCode, 7); - assert.match(settled.output, /observed/u); - native.call("process_close", [exited.handle]); - report.processes.normalExitAndOutput = true; - const forwardCommand = process.execPath.replaceAll("\\", "/"); - const forwardProcess = native.call("process_spawn", [ - forwardCommand, - [forwardCommand, "-e", "console.log('forward executable')"] - .map(quote) - .join(" "), - workspace, - environment, - ]); - try { - const completed = await wait(forwardProcess); - assert.equal(completed.exitCode, 0); - assert.match(completed.output, /forward executable/u); - report.processes.ordinaryDriveSeparators = true; - } finally { - native.call("process_close", [forwardProcess.handle]); - } - const boundaryFixture = process.argv[3]; - if (boundaryFixture !== undefined) { - const before = JSON.parse( - execFileSync(boundaryFixture, [], { encoding: "utf8" }), + const ownerClose = launch([ + "--max-old-space-size=128", + fixture, + "0", + join(workspace, "owner-close-"), + ]); + native.call("process_close", [ownerClose.handle]); + for (let attempt = 0; attempt < 100 && live(ownerClose.pid); attempt++) + await delay(); + assert.equal(live(ownerClose.pid), false); + report.processes.killOnOwnerClose = true; + report.processes.protocolInput = await verifyWindowsProtocolInput( + packageRoot, + workspace, ); - const boundary = native.call("process_spawn", [ - boundaryFixture, - `${quote(boundaryFixture)} breakaway`, + report.processes.jobMemoryLimit = await verifyWindowsJobMemoryLimit( + packageRoot, + workspace, + ); + const exited = launch([ + "--max-old-space-size=128", + "-e", + "console.log('observed');process.exit(7)", + ]); + const settled = await wait(exited); + assert.equal(settled.exitCode, 7); + assert.match(settled.output, /observed/u); + native.call("process_close", [exited.handle]); + report.processes.normalExitAndOutput = true; + const forwardCommand = process.execPath.replaceAll("\\", "/"); + const forwardProcess = native.call("process_spawn", [ + forwardCommand, + [forwardCommand, "-e", "console.log('forward executable')"] + .map(quote) + .join(" "), workspace, environment, ]); - const denied = await wait(boundary); - native.call("process_close", [boundary.handle]); - assert.equal(JSON.parse(denied.output).breakawayDenied, true); - const tokenChild = native.call("process_spawn", [ - boundaryFixture, - quote(boundaryFixture), - workspace, - environment, - ]); - const observed = await wait(tokenChild); - native.call("process_close", [tokenChild.handle]); - assert.equal(JSON.parse(observed.output).defaultOwnerIsUser, true); - const after = JSON.parse( - execFileSync(boundaryFixture, [], { encoding: "utf8" }), - ); - assert.deepEqual(after, before); - report.processes.breakawayDenied = true; - report.processes.childOwnerNormalizedWithoutChangingCaller = true; - report.callerToken = before; - const environmentChild = native.call("process_spawn", [ - boundaryFixture, - `${quote(boundaryFixture)} environment`, - workspace, - [ - "z_REA=z", - "_REA=underscore", - "A_REA=a", - "REA_CASE=first", - "rea_case=last", - "REA_ß=sharp", - "REA_SS=double", - ], - ]); - const childEnvironment = await wait(environmentChild); - native.call("process_close", [environmentChild.handle]); - assert.deepEqual(JSON.parse(childEnvironment.output), { - ordinallySorted: true, - lastDuplicateSelected: true, - unicodeNamesPreserved: true, - }); - report.processes.windowsEnvironmentSemantics = true; - // Observed through this process's own id: a separate probe process would - // get its own processor group, so it cannot stand in for the process that - // spawns the child. - const callerAffinity = JSON.parse( - execFileSync(boundaryFixture, ["affinity", String(process.pid)], { - encoding: "utf8", - }), - ); - const affinityChild = native.call("process_spawn", [ - boundaryFixture, - `${quote(boundaryFixture)} affinity`, - workspace, - environment, - ]); - const observedAffinity = await wait(affinityChild); - native.call("process_close", [affinityChild.handle]); - assert.equal(observedAffinity.exitCode, 0); - const childAffinity = JSON.parse(observedAffinity.output); - // Masks cross as hexadecimal strings because a mask can exceed the - // safe-integer range. Sharing one processor group makes the caller's mask - // applicable, so it must be inherited exactly. Windows placing the child in - // another group is only permitted when the caller asked for no bound of its - // own, which its group's whole active set establishes; a narrower mask has - // to fail the spawn rather than widen to that group's default. - const sharedGroup = - callerAffinity.singleProcessorGroup && - childAffinity.singleProcessorGroup && - callerAffinity.processorGroup === childAffinity.processorGroup; - if (sharedGroup) - assert.equal(childAffinity.affinityMask, callerAffinity.affinityMask); - else assert.equal(callerAffinity.affinityMask, callerAffinity.systemMask); - report.processes.callerAffinityBoundsOwnedProcessOrRefusesToWiden = true; - report.callerAffinity = { - ...callerAffinity, - sharedGroupWithChild: sharedGroup, - }; - } - const ownerFixture = join(workspace, "owner.mjs"); - const ownerPidFile = join(workspace, "crash-child.pid"); - const moduleUrl = pathToFileURL( - join(packageRoot, "dist/windows/WindowsNativeLoader.js"), - ).href; - await writeFile( - ownerFixture, - `import {writeFileSync} from 'node:fs'; + try { + const completed = await wait(forwardProcess); + assert.equal(completed.exitCode, 0); + assert.match(completed.output, /forward executable/u); + report.processes.ordinaryDriveSeparators = true; + } finally { + native.call("process_close", [forwardProcess.handle]); + } + const boundaryFixture = process.argv[3]; + if (boundaryFixture !== undefined) { + const before = JSON.parse( + execFileSync(boundaryFixture, [], { encoding: "utf8" }), + ); + const boundary = native.call("process_spawn", [ + boundaryFixture, + `${quote(boundaryFixture)} breakaway`, + workspace, + environment, + ]); + const denied = await wait(boundary); + native.call("process_close", [boundary.handle]); + assert.equal(JSON.parse(denied.output).breakawayDenied, true); + const tokenChild = native.call("process_spawn", [ + boundaryFixture, + quote(boundaryFixture), + workspace, + environment, + ]); + const observed = await wait(tokenChild); + native.call("process_close", [tokenChild.handle]); + assert.equal(JSON.parse(observed.output).defaultOwnerIsUser, true); + const after = JSON.parse( + execFileSync(boundaryFixture, [], { encoding: "utf8" }), + ); + assert.deepEqual(after, before); + report.processes.breakawayDenied = true; + report.processes.childOwnerNormalizedWithoutChangingCaller = true; + report.callerToken = before; + const environmentChild = native.call("process_spawn", [ + boundaryFixture, + `${quote(boundaryFixture)} environment`, + workspace, + [ + "z_REA=z", + "_REA=underscore", + "A_REA=a", + "REA_CASE=first", + "rea_case=last", + "REA_ß=sharp", + "REA_SS=double", + ], + ]); + const childEnvironment = await wait(environmentChild); + native.call("process_close", [environmentChild.handle]); + assert.deepEqual(JSON.parse(childEnvironment.output), { + ordinallySorted: true, + lastDuplicateSelected: true, + unicodeNamesPreserved: true, + }); + report.processes.windowsEnvironmentSemantics = true; + // Observed through this process's own id: a separate probe process would + // get its own processor group, so it cannot stand in for the process that + // spawns the child. + const callerAffinity = JSON.parse( + execFileSync(boundaryFixture, ["affinity", String(process.pid)], { + encoding: "utf8", + }), + ); + const affinityChild = native.call("process_spawn", [ + boundaryFixture, + `${quote(boundaryFixture)} affinity`, + workspace, + environment, + ]); + const observedAffinity = await wait(affinityChild); + native.call("process_close", [affinityChild.handle]); + assert.equal(observedAffinity.exitCode, 0); + const childAffinity = JSON.parse(observedAffinity.output); + // Masks cross as hexadecimal strings because a mask can exceed the + // safe-integer range. Sharing one processor group makes the caller's mask + // applicable, so it must be inherited exactly. Windows placing the child in + // another group is only permitted when the caller asked for no bound of its + // own, which its group's whole active set establishes; a narrower mask has + // to fail the spawn rather than widen to that group's default. + const sharedGroup = + callerAffinity.singleProcessorGroup && + childAffinity.singleProcessorGroup && + callerAffinity.processorGroup === childAffinity.processorGroup; + if (sharedGroup) + assert.equal(childAffinity.affinityMask, callerAffinity.affinityMask); + else assert.equal(callerAffinity.affinityMask, callerAffinity.systemMask); + report.processes.callerAffinityBoundsOwnedProcessOrRefusesToWiden = true; + report.callerAffinity = { + ...callerAffinity, + sharedGroupWithChild: sharedGroup, + }; + } + const ownerFixture = join(workspace, "owner.mjs"); + const ownerPidFile = join(workspace, "crash-child.pid"); + const moduleUrl = pathToFileURL( + join(packageRoot, "dist/windows/WindowsNativeLoader.js"), + ).href; + await writeFile( + ownerFixture, + `import {writeFileSync} from 'node:fs'; import {requireWindowsNativeAuthority} from ${JSON.stringify(moduleUrl)}; const native=requireWindowsNativeAuthority(); const env=Object.entries(process.env).map(([k,v])=>k+'='+v); const child=native.call('process_spawn',[process.execPath,${JSON.stringify([process.execPath, "--max-old-space-size=128", fixture, "0", join(workspace, "crash-descendant-")].map(quote).join(" "))},${JSON.stringify(workspace)},env]); writeFileSync(${JSON.stringify(ownerPidFile)},String(child.pid));setInterval(()=>{},1000);\n`, - ); - const owner = spawn( - process.execPath, - ["--max-old-space-size=128", ownerFixture], - { stdio: "ignore" }, - ); - try { - for ( - let attempt = 0; - attempt < 200 && !(await exists(ownerPidFile)); - attempt++ - ) - await delay(); - const pid = Number(await readFile(ownerPidFile, "utf8")); - const closed = new Promise((resolve) => owner.once("close", resolve)); - owner.kill("SIGKILL"); - await closed; - for (let attempt = 0; attempt < 100 && live(pid); attempt++) await delay(); - assert.equal(live(pid), false); - report.processes.ownerForcedExitKillsOwnedChild = true; - } finally { - if (owner.exitCode === null && owner.signalCode === null) { + ); + const owner = spawn( + process.execPath, + ["--max-old-space-size=128", ownerFixture], + { stdio: "ignore" }, + ); + try { + for ( + let attempt = 0; + attempt < 200 && !(await exists(ownerPidFile)); + attempt++ + ) + await delay(); + const pid = Number(await readFile(ownerPidFile, "utf8")); const closed = new Promise((resolve) => owner.once("close", resolve)); owner.kill("SIGKILL"); await closed; + for (let attempt = 0; attempt < 100 && live(pid); attempt++) + await delay(); + assert.equal(live(pid), false); + report.processes.ownerForcedExitKillsOwnedChild = true; + } finally { + if (owner.exitCode === null && owner.signalCode === null) { + const closed = new Promise((resolve) => owner.once("close", resolve)); + owner.kill("SIGKILL"); + await closed; + } } + } catch (cause) { + console.error( + "Native conformance failed", + cause.code ?? cause.name, + cause.constraint ?? "assertion", + ); + throw cause; } - report.ok = true; - if (process.argv[4] !== undefined) - await writeFile(process.argv[4], `${JSON.stringify(report)}\n`); - process.stdout.write(`${JSON.stringify(report)}\n`); -} catch (cause) { - console.error( - "Native conformance failed", - cause.code ?? cause.name, - cause.constraint ?? "assertion", - ); - throw cause; -} finally { - await rm(workspace, { recursive: true, force: true }).catch((cause) => - console.error("Fixture cleanup failed", cause.code), - ); -} +}, [() => rm(workspace, { recursive: true, force: true })]); +report.ok = true; +if (process.argv[4] !== undefined) + await writeFile(process.argv[4], `${JSON.stringify(report)}\n`); +process.stdout.write(`${JSON.stringify(report)}\n`); diff --git a/scripts/verify/apple/macos-bundle.mjs b/scripts/verify/apple/macos-bundle.mjs index e2a1308421..a4e07eb4b5 100644 --- a/scripts/verify/apple/macos-bundle.mjs +++ b/scripts/verify/apple/macos-bundle.mjs @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; +import { createPackageVerifierCommands } from "../../lib/verify-package-commands.mjs"; +import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { promisify } from "node:util"; import { artifactCliEvidence, @@ -19,8 +19,6 @@ import { verifyDylibResolution } from "./macos-dylib-e2e.mjs"; import { verifyMachOOccurrences } from "./macho-occurrences.mjs"; import { verifyNativeProvenance } from "./native-provenance.mjs"; -const exec = promisify(execFile); - await preflightMacosBundleFixture(); const root = await mkdtemp(join(tmpdir(), "rea-macos-bundle-")); @@ -67,8 +65,19 @@ const EXPECTED_BUNDLES = [ ["Contents/XPCServices/Svc.xpc", "xpc-service", "macos-deep", "."], ]; +const commands = await createPackageVerifierCommands({ + workspace: { + path: root, + close: () => rm(root, { recursive: true, force: true }), + }, +}); +const exec = (command, args) => + commands.exec(command, args, { + timeout: 120_000, + maxBuffer: 16 * 1024 * 1024, + }); let report; -try { +await withVerifierCleanup(async () => { const { app, executable } = await buildMacosBundleFixture(root); const nativeProvenance = await verifyNativeProvenance(app, executable); const machoOccurrences = await verifyMachOOccurrences(app, executable); @@ -173,7 +182,5 @@ try { dylib_resolution: dylibs, dyld_environment: dyldEnvironment, }; -} finally { - await rm(root, { recursive: true, force: true }); -} +}, [() => commands.close()]); process.stdout.write(`${JSON.stringify(report)}\n`); diff --git a/scripts/verify/apple/native-provenance.mjs b/scripts/verify/apple/native-provenance.mjs index 524ed93e39..385752b6d8 100644 --- a/scripts/verify/apple/native-provenance.mjs +++ b/scripts/verify/apple/native-provenance.mjs @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; +import { createPackageVerifierCommands } from "../../lib/verify-package-commands.mjs"; +import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs"; import { createHash } from "node:crypto"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { promisify } from "node:util"; import { toolContract } from "../../../dist/contracts/toolContracts.js"; import { @@ -14,8 +14,6 @@ import { withBundleResourcesMcp, } from "../../lib/artifact-e2e.mjs"; -const exec = promisify(execFile); - // dyld_info abbreviates a terminal .dylib suffix; padding is part of the name. const INSTALL_NAME_CASES = [ { installName: "leading.dylib", importSource: "leading" }, @@ -182,7 +180,18 @@ export async function verifyNativeProvenance(app, executable) { async function verifyInstallNames() { const root = await mkdtemp(join(tmpdir(), "rea-macho-install-names-")); const observations = []; - try { + const commands = await createPackageVerifierCommands({ + workspace: { + path: root, + close: () => rm(root, { recursive: true, force: true }), + }, + }); + const exec = (command, args) => + commands.exec(command, args, { + timeout: 120_000, + maxBuffer: 16 * 1024 * 1024, + }); + return withVerifierCleanup(async () => { const librarySource = join(root, "library.c"); const consumerSource = join(root, "consumer.c"); await writeFile(librarySource, "int exported_value(void) { return 42; }\n"); @@ -279,7 +288,5 @@ async function verifyInstallNames() { }); } return observations; - } finally { - await rm(root, { recursive: true, force: true }); - } + }, [() => commands.close()]); } diff --git a/scripts/verify/electron/tool-contracts.mjs b/scripts/verify/electron/tool-contracts.mjs index 6541a679e9..48009002c2 100644 --- a/scripts/verify/electron/tool-contracts.mjs +++ b/scripts/verify/electron/tool-contracts.mjs @@ -1,3 +1,7 @@ +import { + closeVerifierResources, + withVerifierCleanup, +} from "../../lib/verifier-cleanup.mjs"; import { requireMcpToolError } from "../../lib/mcp-verifier-results.mjs"; import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; @@ -92,207 +96,221 @@ bridge.exposeInMainWorld("fixture", { }); let application; let stderr = ""; - try { - await readProcessRunId(process.pid); - application = spawn( - executable, - [ - "--remote-debugging-address=127.0.0.1", - "--remote-debugging-port=0", - `--user-data-dir=${join(root, "profile")}`, - main, - ], - { - cwd: root, - env: { ...process.env, REA_PROCESS_RUN_ID: runId }, - detached: true, - stdio: ["ignore", "ignore", "pipe"], - }, - ); - application.stderr.on("data", (chunk) => { - stderr += chunk.toString(); - }); - const endpoint = await waitForEndpoint(application, () => stderr); - await client.connect(transport); - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - const validators = new Map( - (await client.listTools()).tools.map((tool) => { - ajv.compile(tool.inputSchema); - return [ - tool.name, - tool.outputSchema - ? (tool.outputSchema.$id && ajv.getSchema(tool.outputSchema.$id)) || - ajv.compile(tool.outputSchema) - : undefined, - ]; - }), - ); - const call = async (name, arguments_) => { - console.log(`PROBE ${name}`); - const result = await client.callTool( - { name, arguments: arguments_ }, + await withVerifierCleanup(async () => { + try { + await readProcessRunId(process.pid); + application = spawn( + executable, + [ + "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=0", + `--user-data-dir=${join(root, "profile")}`, + main, + ], + { + cwd: root, + env: { ...process.env, REA_PROCESS_RUN_ID: runId }, + detached: true, + stdio: ["ignore", "ignore", "pipe"], + }, + ); + application.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const endpoint = await waitForEndpoint(application, () => stderr); + await client.connect(transport); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const validators = new Map( + (await client.listTools()).tools.map((tool) => { + ajv.compile(tool.inputSchema); + return [ + tool.name, + tool.outputSchema + ? (tool.outputSchema.$id && + ajv.getSchema(tool.outputSchema.$id)) || + ajv.compile(tool.outputSchema) + : undefined, + ]; + }), + ); + const call = async (name, arguments_) => { + console.log(`PROBE ${name}`); + const result = await client.callTool( + { name, arguments: arguments_ }, + { timeout: 150_000 }, + ); + assert.notEqual(result.isError, true, JSON.stringify(result)); + const validate = validators.get(name); + assert.ok( + validate?.(result.structuredContent), + JSON.stringify(validate?.errors), + ); + return result.structuredContent; + }; + let targets; + for (let attempt = 0; attempt < 30; attempt++) { + targets = await call("list_electron_targets", { + cdp_endpoint: endpoint, + }); + if (targets.normalized_result.targets.length > 0) break; + await pause(100); + } + const target = targets.normalized_result.targets.find( + (item) => item.file_path === join(root, "renderer.html"), + ); + assert.ok(target, JSON.stringify(targets)); + const page = await call("inspect_electron_page", { + cdp_endpoint: endpoint, + target_id: target.target_id, + include_script_sources: true, + observation_ms: 50, + }); + assert.ok( + page.normalized_result.scripts.items.some( + (script) => + script.file_path === join(root, "renderer.js") && + script.source.included, + ), + ); + const staticAnalysis = await call("analyze_javascript_application", { + input_path: root, + }); + assert.equal(staticAnalysis.normalized_result.summary.browser_windows, 1); + assert.equal( + staticAnalysis.normalized_result.summary.preload_entrypoints, + 1, + ); + assert.equal( + staticAnalysis.normalized_result.summary.context_bridge_apis, + 1, + ); + assert.equal( + staticAnalysis.normalized_result.summary.ipc.main_handlers, + 1, + ); + assert.equal( + staticAnalysis.normalized_result.summary.ipc.renderer_transmissions, + 1, + ); + assert.equal( + staticAnalysis.normalized_result.summary.ipc + .paired_renderer_transmissions, + 1, + ); + const reconciled = await call("reconcile_javascript_runtime", { + static_layers: [ + { + analysis: staticAnalysis, + runtime_mappings: [{ kind: "file-root", root }], + }, + ], + runtime_observations: [page], + }); + assert.ok(reconciled.normalized_result.runtime_captures.length > 0); + const cli = await execute( + process.execPath, + [ + entrypoint, + "inspect-electron-page", + endpoint, + target.target_id, + "--include-script-sources", + "--json", + ], + { + env: { ...process.env, REA_LOG_LEVEL: "silent" }, + timeout: 60_000, + maxBuffer: 32 * 1024 * 1024, + }, + ); + assert.equal( + JSON.parse(cli.stdout).normalized_result.target.file_path, + target.file_path, + ); + const input = { + executable_path: executable, + application_path: main, + application_root: root, + actions: [{ step_id: "ipc", kind: "click", selector: "#run" }], + }; + const active = await client.callTool( + { name: "capture_electron_scenario", arguments: input }, { timeout: 150_000 }, ); - assert.notEqual(result.isError, true, JSON.stringify(result)); - const validate = validators.get(name); + const activeError = active.isError + ? requireMcpToolError(active) + : undefined; + if (activeError !== undefined) + assert.equal( + activeError.code, + "cleanup_incomplete", + JSON.stringify(activeError), + ); + const capture = + activeError === undefined + ? active.structuredContent.normalized_result + : activeError.details?.partial_observation?.capture; + assert.ok(capture, JSON.stringify(activeError ?? active)); + if (activeError !== undefined) { + assert.equal(capture.application.cleanup, "unverified"); + } else { + assert.ok( + validators.get("capture_electron_scenario")(active.structuredContent), + ); + assert.equal(capture.application.cleanup, "terminated-owned-process"); + } + assert.equal(capture.actions[0].status, "completed"); assert.ok( - validate?.(result.structuredContent), - JSON.stringify(validate?.errors), + capture.ipc.events.some( + ({ kind, channel }) => + kind === "main-handler-invocation" && channel === "fixture:echo", + ), + JSON.stringify(capture.ipc), ); - return result.structuredContent; - }; - let targets; - for (let attempt = 0; attempt < 30; attempt++) { - targets = await call("list_electron_targets", { cdp_endpoint: endpoint }); - if (targets.normalized_result.targets.length > 0) break; - await pause(100); - } - const target = targets.normalized_result.targets.find( - (item) => item.file_path === join(root, "renderer.html"), - ); - assert.ok(target, JSON.stringify(targets)); - const page = await call("inspect_electron_page", { - cdp_endpoint: endpoint, - target_id: target.target_id, - include_script_sources: true, - observation_ms: 50, - }); - assert.ok( - page.normalized_result.scripts.items.some( - (script) => - script.file_path === join(root, "renderer.js") && - script.source.included, - ), - ); - const staticAnalysis = await call("analyze_javascript_application", { - input_path: root, - }); - assert.equal(staticAnalysis.normalized_result.summary.browser_windows, 1); - assert.equal( - staticAnalysis.normalized_result.summary.preload_entrypoints, - 1, - ); - assert.equal( - staticAnalysis.normalized_result.summary.context_bridge_apis, - 1, - ); - assert.equal(staticAnalysis.normalized_result.summary.ipc.main_handlers, 1); - assert.equal( - staticAnalysis.normalized_result.summary.ipc.renderer_transmissions, - 1, - ); - assert.equal( - staticAnalysis.normalized_result.summary.ipc - .paired_renderer_transmissions, - 1, - ); - const reconciled = await call("reconcile_javascript_runtime", { - static_layers: [ - { - analysis: staticAnalysis, - runtime_mappings: [{ kind: "file-root", root }], - }, - ], - runtime_observations: [page], - }); - assert.ok(reconciled.normalized_result.runtime_captures.length > 0); - const cli = await execute( - process.execPath, - [ - entrypoint, - "inspect-electron-page", - endpoint, - target.target_id, - "--include-script-sources", - "--json", - ], - { - env: { ...process.env, REA_LOG_LEVEL: "silent" }, - timeout: 60_000, - maxBuffer: 32 * 1024 * 1024, - }, - ); - assert.equal( - JSON.parse(cli.stdout).normalized_result.target.file_path, - target.file_path, - ); - const input = { - executable_path: executable, - application_path: main, - application_root: root, - actions: [{ step_id: "ipc", kind: "click", selector: "#run" }], - }; - const active = await client.callTool( - { name: "capture_electron_scenario", arguments: input }, - { timeout: 150_000 }, - ); - const activeError = active.isError - ? requireMcpToolError(active) - : undefined; - if (activeError !== undefined) assert.equal( - activeError.code, - "cleanup_incomplete", - JSON.stringify(activeError), + capture.coverage.status, + "partial_attach", + JSON.stringify(capture), ); - const capture = - activeError === undefined - ? active.structuredContent.normalized_result - : activeError.details?.partial_observation?.capture; - assert.ok(capture, JSON.stringify(activeError ?? active)); - if (activeError !== undefined) { - assert.equal(capture.application.cleanup, "unverified"); - } else { assert.ok( - validators.get("capture_electron_scenario")(active.structuredContent), + capture.timeline.events.some( + (event) => + event.kind === "window-lifecycle" && event.event === "created", + ), + ); + await client.ping(); + console.log( + `PASS live Electron CLI/MCP discovery, inspection, reconciliation, and active observations; active cleanup=${capture.application.cleanup}`, + ); + if (process.platform !== "win32") + await verifyCancellation({ input, ready, entrypoint, root }); + } catch (cause) { + throw new Error( + `Electron public-tool verification failed: ${cause.message}\nMCP stderr:\n${serverStderr}`, + { cause }, ); - assert.equal(capture.application.cleanup, "terminated-owned-process"); } - assert.equal(capture.actions[0].status, "completed"); - assert.ok( - capture.ipc.events.some( - ({ kind, channel }) => - kind === "main-handler-invocation" && channel === "fixture:echo", - ), - JSON.stringify(capture.ipc), - ); - assert.equal( - capture.coverage.status, - "partial_attach", - JSON.stringify(capture), - ); - assert.ok( - capture.timeline.events.some( - (event) => - event.kind === "window-lifecycle" && event.event === "created", - ), - ); - await client.ping(); - console.log( - `PASS live Electron CLI/MCP discovery, inspection, reconciliation, and active observations; active cleanup=${capture.application.cleanup}`, - ); - if (process.platform !== "win32") - await verifyCancellation({ input, ready, entrypoint, root }); - } catch (cause) { - throw new Error( - `Electron public-tool verification failed: ${cause.message}\nMCP stderr:\n${serverStderr}`, - { cause }, - ); - } finally { - await client.close(); - await transport.close(); - if (application?.pid) { - const cleanup = await cleanupOwnedProcessGroup({ - runId, - leaderPid: application.pid, - processGroupId: application.pid, - expectedParentPid: process.pid, - expectedCommand: executable, - }); - assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup)); - } - await rm(root, { recursive: true, force: true }); - } + }, [ + async () => { + await closeVerifierResources([ + () => client.close(), + () => transport.close(), + async () => { + if (application?.pid) { + const cleanup = await cleanupOwnedProcessGroup({ + runId, + leaderPid: application.pid, + processGroupId: application.pid, + expectedParentPid: process.pid, + expectedCommand: executable, + }); + assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup)); + } + }, + ]); + await rm(root, { recursive: true, force: true }); + }, + ]); } const pause = (milliseconds) => @@ -345,7 +363,7 @@ async function verifyCancellation({ input, ready, entrypoint, root }) { child.once("exit", (code, signal) => resolve({ code, signal })); }); let ownership; - try { + await withVerifierCleanup(async () => { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const table = ( @@ -419,14 +437,35 @@ async function verifyCancellation({ input, ready, entrypoint, root }) { console.log( `PASS real Electron CLI SIGTERM, retained cancelled actions, and owned group termination; reported code=${error.code}`, ); - } finally { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGTERM"); - await exited; - } - if (ownership) { - const cleanup = await cleanupOwnedProcessGroup(ownership); - assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup)); - } - } + }, [ + async () => { + for (const signal of ["SIGTERM", "SIGKILL"]) { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill(signal); + let timer; + try { + if ( + await Promise.race([ + exited.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), 2000); + }), + ]) + ) + return; + } finally { + clearTimeout(timer); + } + } + throw new Error( + "Electron verifier CLI did not exit after SIGKILL; application cleanup remains independently owned", + ); + }, + async () => { + if (ownership) { + const cleanup = await cleanupOwnedProcessGroup(ownership); + assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup)); + } + }, + ]); } diff --git a/scripts/verify/firmware/devicetree.mjs b/scripts/verify/firmware/devicetree.mjs index c4ac2211c3..09b1a6e05e 100644 --- a/scripts/verify/firmware/devicetree.mjs +++ b/scripts/verify/firmware/devicetree.mjs @@ -1,17 +1,29 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { createPackageVerifierCommands } from "../../lib/verify-package-commands.mjs"; +import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; // Optional verification tool only; the runtime inspector never starts dtc. const dtc = process.env.REA_DTC_PATH ?? "dtc"; -assert.match( - execFileSync(dtc, ["--version"], { encoding: "utf8" }), - /DTC 1\.8\.1\b/, -); const root = mkdtempSync(join(tmpdir(), "rea-dtb-verification-")); -try { +const commands = await createPackageVerifierCommands({ + workspace: { + path: root, + close: async () => rmSync(root, { recursive: true, force: true }), + }, +}); +const exec = async (command, args) => + ( + await commands.exec(command, args, { + timeout: 120_000, + maxBuffer: 16 * 1024 * 1024, + }) + ).stdout; +await withVerifierCleanup(async () => { + assert.match(await exec(dtc, ["--version"]), /DTC 1\.8\.1\b/); + const source = join(root, "board.dts"), blob = join(root, "board.dtb"); writeFileSync( @@ -30,16 +42,15 @@ try { consumer { remote-endpoint = <&serial>; }; };\n`, ); - execFileSync(dtc, ["-I", "dts", "-O", "dtb", "-o", blob, source]); - const decompiled = execFileSync(dtc, ["-I", "dtb", "-O", "dts", blob], { - encoding: "utf8", - }); + await exec(dtc, ["-I", "dts", "-O", "dtb", "-o", blob, source]); + const decompiled = await exec(dtc, ["-I", "dtb", "-O", "dts", blob]); const evidence = JSON.parse( - execFileSync( - process.execPath, - ["scripts/rea.mjs", "inspect-devicetree", blob, "--json"], - { encoding: "utf8" }, - ), + await exec(process.execPath, [ + "scripts/rea.mjs", + "inspect-devicetree", + blob, + "--json", + ]), ); const report = evidence.normalized_result; // This controlled fixture uses one opening/closing brace per node line. @@ -83,6 +94,4 @@ try { 2, ), ); -} finally { - rmSync(root, { recursive: true, force: true }); -} +}, [() => commands.close()]); diff --git a/scripts/verify/inspector/runtime-observation.mjs b/scripts/verify/inspector/runtime-observation.mjs index 6485ed3cb6..1d5baaebc9 100644 --- a/scripts/verify/inspector/runtime-observation.mjs +++ b/scripts/verify/inspector/runtime-observation.mjs @@ -1,3 +1,7 @@ +import { + closeVerifierResources, + withVerifierCleanup, +} from "../../lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; import { once } from "node:events"; @@ -38,7 +42,7 @@ const names = [ ...(process.platform === "win32" ? [] : ['with"quote.cjs']), ]; -try { +await withVerifierCleanup(async () => { await client.connect(transport); for (const name of names) { const targetPath = @@ -54,19 +58,20 @@ try { stdio: ["ignore", "ignore", "pipe"], }, ); - try { - await verifyTarget(child, targetPath, name); - } finally { - await terminate(child); - } + await withVerifierCleanup( + () => verifyTarget(child, targetPath, name), + [() => terminate(child)], + ); } -} finally { - try { - await client.close(); - } finally { +}, [ + async () => { + await closeVerifierResources([ + () => client.close(), + () => transport.close(), + ]); await rm(root, { recursive: true, force: true }); - } -} + }, +]); async function verifyTarget(child, targetPath, name) { const socket = new URL(await inspectorUrl(child)); @@ -192,15 +197,37 @@ async function runMcp(name, arguments_) { } async function inspectorUrl(process_) { - let captured = ""; - for await (const chunk of process_.stderr) { - captured += chunk.toString("utf8"); - const match = /Debugger listening on (ws:\/\/[^\s]+)/u.exec(captured); - if (match?.[1] !== undefined) return match[1]; - if (captured.length > 64 * 1_024) - throw new Error("Inspector startup output exceeded its bound"); - } - throw new Error("Node exited before reporting its Inspector URL"); + return new Promise((resolveUrl, rejectUrl) => { + let captured = ""; + const timer = setTimeout( + () => + finish(new Error("Inspector startup exceeded its 10000ms deadline")), + 10_000, + ); + const finish = (error, url) => { + clearTimeout(timer); + process_.stderr.off("data", onData); + process_.stderr.off("end", onEnd); + process_.stderr.off("error", onError); + process_.off("error", onError); + if (error !== undefined) rejectUrl(error); + else resolveUrl(url); + }; + const onData = (chunk) => { + captured += chunk.toString("utf8"); + const match = /Debugger listening on (ws:\/\/[^\s]+)/u.exec(captured); + if (match?.[1] !== undefined) finish(undefined, match[1]); + else if (captured.length > 64 * 1_024) + finish(new Error("Inspector startup output exceeded its bound")); + }; + const onEnd = () => + finish(new Error("Node exited before reporting its Inspector URL")); + const onError = (error) => finish(error); + process_.stderr.on("data", onData); + process_.stderr.once("end", onEnd); + process_.stderr.once("error", onError); + process_.once("error", onError); + }); } async function runCli(arguments_) { @@ -236,7 +263,14 @@ async function terminate(process_) { ) return; process_.kill("SIGKILL"); - await exited; + clearTimeout(timer); + const killed = await Promise.race([ + exited.then(() => true), + new Promise((resolveTimeout) => { + timer = setTimeout(() => resolveTimeout(false), 2_000); + }), + ]); + assert.equal(killed, true, "Inspector fixture did not exit after SIGKILL"); } finally { clearTimeout(timer); } diff --git a/scripts/verify/javascript/recovery.mjs b/scripts/verify/javascript/recovery.mjs index 67ed72debd..8220c849de 100644 --- a/scripts/verify/javascript/recovery.mjs +++ b/scripts/verify/javascript/recovery.mjs @@ -1,4 +1,8 @@ #!/usr/bin/env node +import { + closeVerifierResources, + withVerifierCleanup, +} from "../../lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; @@ -51,7 +55,7 @@ const call = async (name, args) => { const fingerprint = (bytes) => createHash("sha256").update(bytes).digest("hex"); const counts = []; let finalReport; -try { +await withVerifierCleanup(async () => { const fixtures = await buildRecoveryFixtures( root, process.env.REA_JAVASCRIPT_FIXTURE_TOOLS, @@ -165,11 +169,15 @@ try { results: counts, verified: true, }; -} finally { - await client.close(); - await transport.close(); - await rm(root, { recursive: true, force: true }); -} +}, [ + async () => { + await closeVerifierResources([ + () => client.close(), + () => transport.close(), + ]); + await rm(root, { recursive: true, force: true }); + }, +]); console.log(JSON.stringify(finalReport)); async function assertRecoveredArtifacts(evidence, result, fixture) { diff --git a/tests/boundary/process/inspectorVerifierStartup.test.ts b/tests/boundary/process/inspectorVerifierStartup.test.ts new file mode 100644 index 0000000000..b4d35baff4 --- /dev/null +++ b/tests/boundary/process/inspectorVerifierStartup.test.ts @@ -0,0 +1,76 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { processTest } from "../../support/process/processFixture.js"; + +processTest( + "refuses a silent Inspector startup and retires its exact child", + async ({ processes }) => { + const root = await createTestTempDirectory("rea-inspector-startup-"); + const marker = join(root, "child.pid"); + const stopped = join(root, "stop-reason"); + const preload = join(root, "silent-inspector.mjs"); + await writeFile( + preload, + `import childProcess from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +const { Client } = await import(${JSON.stringify(import.meta.resolve("@modelcontextprotocol/client"))}); +Client.prototype.connect = async () => undefined; +let acknowledgeReady; +const ready = new Promise((resolve) => { acknowledgeReady = resolve; }); +const children = []; +process.once("exit", () => { + for (const child of children) + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); +}); +const spawn = childProcess.spawn; +childProcess.spawn = (command, args, options) => { + if (!args[0]?.startsWith("--inspect=")) return spawn(command, args, options); + // Finite expiry also bounds the baseline control if startup never times out. + const child = spawn(command, ["-e", ${JSON.stringify(` +const { writeFileSync } = require("node:fs"); +process.once("SIGTERM", () => { + writeFileSync(${JSON.stringify(stopped)}, "SIGTERM"); + process.exit(0); +}); +setTimeout(() => { + writeFileSync(${JSON.stringify(stopped)}, "expired"); + process.exit(0); +}, 2000); +process.stderr.write("REA_STARTUP_READY\\n"); +`)}], options); + child.stderr.on("data", (chunk) => { + if (chunk.toString().includes("REA_STARTUP_READY")) acknowledgeReady(); + }); + children.push(child); + writeFileSync(${JSON.stringify(marker)}, String(child.pid)); + return child; +}; +syncBuiltinESMExports(); +const schedule = globalThis.setTimeout; +globalThis.setTimeout = (callback, delay, ...args) => + delay === 10000 + ? schedule(() => { void ready.then(() => callback(...args)); }, 50) + : schedule(callback, delay, ...args); +`, + ); + const result = await processes.run( + process.execPath, + ["--import", preload, "scripts/verify/inspector/runtime-observation.mjs"], + { timeoutMs: 5_000, env: process.env }, + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "Inspector startup exceeded its 10000ms deadline", + ); + expect(await readFile(stopped, "utf8")).toBe("SIGTERM"); + const pid = Number(await readFile(marker, "utf8")); + expect(pid).toBeGreaterThan(0); + expect(() => process.kill(pid, 0)).toThrowError( + expect.objectContaining({ code: "ESRCH" }), + ); + }, +); diff --git a/tests/fixtures/buildComputerMcp.ts b/tests/fixtures/buildComputerMcp.ts index ebd3edd153..33c14484cf 100644 --- a/tests/fixtures/buildComputerMcp.ts +++ b/tests/fixtures/buildComputerMcp.ts @@ -1,4 +1,5 @@ import type { BuildComputerDefaults } from "../../src/desktop/BuildComputerEnvironment.js"; +import { closeVerifierResources } from "../../scripts/lib/verifier-cleanup.mjs"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -63,9 +64,14 @@ export const connectBuildServer = async ( ); const client = new Client({ name: "build-computer-mcp", version: "1" }); onTestFinished(async () => { - await client.close(); - await server.close(); - await session.close(); + await closeVerifierResources([ + () => client.close(), + () => server.close(), + async () => { + const closed = await session.close(); + if (!closed.ok) throw closed.error; + }, + ]); }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); diff --git a/tests/fixtures/contractPresentation.mjs b/tests/fixtures/contractPresentation.mjs index 6f7af51cbd..3f438a1369 100644 --- a/tests/fixtures/contractPresentation.mjs +++ b/tests/fixtures/contractPresentation.mjs @@ -1,4 +1,5 @@ import { silentLogger } from "../../dist/logger.js"; +import { withVerifierCleanup } from "../../scripts/lib/verifier-cleanup.mjs"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { BROWSER_TOOL_CONTRACTS } from "../../dist/contracts/browserToolContracts.js"; @@ -27,7 +28,7 @@ const session = createBinarySession(config.value, silentLogger, {}); const server = createServer({ kind: "session", session }); const client = new Client({ name: "contract-presentation", version: "1" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); -try { +await withVerifierCleanup(async () => { await server.connect(serverTransport); await client.connect(clientTransport); const catalog = await client.listTools(); @@ -52,8 +53,11 @@ try { }); } process.stdout.write(JSON.stringify({ catalog: catalog.tools, responses })); -} finally { - await client.close(); - await server.close(); - await session.close(); -} +}, [ + () => client.close(), + () => server.close(), + async () => { + const closed = await session.close(); + if (!closed.ok) throw closed.error; + }, +]); diff --git a/tests/fixtures/hopperCleanupTimeout.py b/tests/fixtures/hopperCleanupTimeout.py index 1a3d76c179..e8d7dd6cdb 100644 --- a/tests/fixtures/hopperCleanupTimeout.py +++ b/tests/fixtures/hopperCleanupTimeout.py @@ -26,25 +26,35 @@ child = subprocess.Popen( stdout=subprocess.PIPE, text=True, ) -assert child.stdout is not None -if child.stdout.readline().strip() != "ready": - raise SystemExit("child did not become ready") +try: + assert child.stdout is not None + if child.stdout.readline().strip() != "ready": + raise SystemExit("child did not become ready") -started = time.monotonic() -helper.stop_process(child) -elapsed = time.monotonic() - started + started = time.monotonic() + helper.stop_process(child) + elapsed = time.monotonic() - started -print( - json.dumps( - { - "returncode": child.returncode, - "elapsed_ms": round(elapsed * 1000), - }, - separators=(",", ":"), + print( + json.dumps( + { + "returncode": child.returncode, + "elapsed_ms": round(elapsed * 1000), + }, + separators=(",", ":"), + ) ) -) -if child.returncode != -9: - raise SystemExit(f"expected SIGKILL fallback, got {child.returncode}") -if not 1800 <= elapsed * 1000 < 4000: - raise SystemExit(f"unexpected cleanup duration: {elapsed}") + if child.returncode != -9: + raise SystemExit(f"expected SIGKILL fallback, got {child.returncode}") + if not 1800 <= elapsed * 1000 < 4000: + raise SystemExit(f"unexpected cleanup duration: {elapsed}") +finally: + # This exact Popen is fixture-owned even when stop_process under test fails. + try: + if child.poll() is None: + child.kill() + child.wait(timeout=2) + finally: + if child.stdout is not None: + child.stdout.close()