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()