From 47d14d0fb837543d0faf09a7147952676e7dd73e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 19:52:38 +0800 Subject: [PATCH] refactor(verify): give the javascript verifiers' roots to VerifierFixture (#11068) * refactor(verify): give the javascript verifiers' roots to VerifierFixture VerifierFixture.retain(reason) keeps a root that an owned command whose cleanup is incomplete may still use, replacing each verifier's local retain flag and cleanup closure. * refactor(verify): give the Ghidra COM, DOS, 6502, NativeAOT and raw verifiers' workspaces to VerifierFixture (#11070) * refactor(verify): give the Ghidra COM, DOS, 6502, NativeAOT and raw verifiers' workspaces to VerifierFixture * refactor(verify): keep the Ghidra decompiler-failure, decompile-timeout and no-return workspaces through VerifierFixture until owned cleanup is verified (#11077) --- scripts/lib/verifier-fixture.d.mts | 5 ++ scripts/lib/verifier-fixture.mjs | 12 +++ scripts/verify-real-ghidra-6502.mjs | 62 ++++++++------- scripts/verify-real-ghidra-com.mjs | 64 +++++++-------- .../verify-real-ghidra-decompile-timeout.mjs | 30 ++++--- .../verify-real-ghidra-decompiler-failure.mjs | 26 ++++-- scripts/verify-real-ghidra-dos.mjs | 79 +++++++++---------- scripts/verify-real-ghidra-nativeaot.mjs | 64 +++++++-------- scripts/verify-real-ghidra-noreturn.mjs | 26 ++++-- scripts/verify-real-ghidra-raw.mjs | 69 ++++++++-------- .../verify/architecture-guard-baselines.json | 15 +--- .../verify/javascript/module-references.mjs | 28 +++---- scripts/verify/javascript/native-handoffs.mjs | 28 +++---- scripts/verify/javascript/output.mjs | 33 +++++--- scripts/verify/javascript/recovery.mjs | 30 ++++--- scripts/verify/javascript/retained-facts.mjs | 26 +++--- tests/process-global/verifierFixture.test.ts | 27 +++++++ 17 files changed, 329 insertions(+), 295 deletions(-) diff --git a/scripts/lib/verifier-fixture.d.mts b/scripts/lib/verifier-fixture.d.mts index 6ee233a8fc..ba77339aa2 100644 --- a/scripts/lib/verifier-fixture.d.mts +++ b/scripts/lib/verifier-fixture.d.mts @@ -23,6 +23,11 @@ export class VerifierFixture { readonly released: boolean; /** Register a cleanup that must confirm before the root may be removed. */ releaseAfter unknown>(cleanup: T): T; + /** + * Keep the root for good: something that may still use it cannot be + * confirmed stopped. Every later release fails with `reason`. + */ + retain(reason: string): void; /** * Run pending cleanups in order, then remove the root if all confirmed. A * failure is an AggregateError whose `cleanupLeases` is `[this fixture]`. diff --git a/scripts/lib/verifier-fixture.mjs b/scripts/lib/verifier-fixture.mjs index 7b6b35ea58..ed8f926d04 100644 --- a/scripts/lib/verifier-fixture.mjs +++ b/scripts/lib/verifier-fixture.mjs @@ -77,6 +77,18 @@ export class VerifierFixture { return cleanup; } + /** + * Keep the root for good: something that may still use its files cannot be + * confirmed stopped and has no cleanup this fixture could retry (an owned + * command whose cleanup is incomplete). Every release then fails, naming + * `reason` and the root. + */ + retain(reason) { + this.releaseAfter(() => { + throw new Error(reason); + }); + } + /** Concurrent callers share one attempt; a failed attempt can be retried. */ release() { if (this.#released) return Promise.resolve(); diff --git a/scripts/verify-real-ghidra-6502.mjs b/scripts/verify-real-ghidra-6502.mjs index b5303e10b7..df27a0ebad 100644 --- a/scripts/verify-real-ghidra-6502.mjs +++ b/scripts/verify-real-ghidra-6502.mjs @@ -1,16 +1,11 @@ -import { - closeVerifierResources, - withVerifierCleanup, -} from "./lib/verifier-cleanup.mjs"; +import { closeVerifierResources } from "./lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, - mkdtemp, readFile, readdir, - rm, symlink, unlink, writeFile, @@ -27,6 +22,10 @@ import { requireMcpOutput, } from "./lib/mcp-verifier-results.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { + VerifierFixture, + withVerifierFixture, +} from "./lib/verifier-fixture.mjs"; if (process.argv.length !== 2) throw new Error("Usage: node scripts/verify-real-ghidra-6502.mjs"); @@ -45,7 +44,12 @@ for (const [name, command] of Object.entries({ ca65, ld65 })) { toolchain[name] = (version.stdout + version.stderr).trim(); } await mkdir(join(root, "build"), { recursive: true }); -const workspace = await mkdtemp(join(root, "build", "rea-6502-proof-")); +const workspaceFixture = await VerifierFixture.open({ + prefix: "rea-6502-proof-", + parent: join(root, "build"), + private: false, +}); +const workspace = workspaceFixture.path; const runtime = join(workspace, "runtime"); await mkdir(runtime); const path = join(workspace, "image.bin"); @@ -74,7 +78,26 @@ transport.stderr?.on("data", (chunk) => { let connected = false; let verifierRun; let report; -await withVerifierCleanup(async () => { +workspaceFixture.releaseAfter(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 withVerifierFixture(workspaceFixture, async () => { try { await exec( ca65, @@ -465,28 +488,7 @@ await withVerifierCleanup(async () => { if (stderr) process.stderr.write(stderr); throw cause; } -}, [ - 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"); diff --git a/scripts/verify-real-ghidra-com.mjs b/scripts/verify-real-ghidra-com.mjs index 7ba7992fe7..f1c08c06a2 100644 --- a/scripts/verify-real-ghidra-com.mjs +++ b/scripts/verify-real-ghidra-com.mjs @@ -1,9 +1,6 @@ #!/usr/bin/env node import { schemaVerifierEnvironment } from "./lib/mcp-verification-environment.mjs"; -import { - closeVerifierResources, - withVerifierCleanup, -} from "./lib/verifier-cleanup.mjs"; +import { closeVerifierResources } from "./lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { requireMcpLifecycleResult, @@ -12,15 +9,7 @@ import { } from "./lib/mcp-verifier-results.mjs"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { - mkdtemp, - mkdir, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -34,6 +23,10 @@ import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-ins import { buildDosComFixture } from "../tests/conformance/ghidra/dos-com-fixture.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { verifyGhidraAddressBoundaries } from "./lib/real-ghidra-address-boundaries.mjs"; +import { + VerifierFixture, + withVerifierFixture, +} from "./lib/verifier-fixture.mjs"; // This lane intentionally has no game paths, binary seeds or compiler dependency. if (process.argv.length !== 2) @@ -73,7 +66,10 @@ const entrypoint = ? fileURLToPath(new URL("./rea.mjs", import.meta.url)) : join(packageRoot, "scripts", "rea.mjs"); const run = createVerifierRun(); -const workspace = await mkdtemp(join(tmpdir(), "rea-com-proof-")); +const workspaceFixture = await VerifierFixture.open({ + prefix: "rea-com-proof-", +}); +const workspace = workspaceFixture.path; const runtime = join(workspace, "runtime"); await mkdir(runtime); const fixture = buildDosComFixture(); @@ -107,7 +103,24 @@ let report; transport.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString()).slice(-65536); }); -await withVerifierCleanup(async () => { +workspaceFixture.releaseAfter(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 withVerifierFixture(workspaceFixture, async () => { try { await client.connect(transport); connected = true; @@ -476,26 +489,7 @@ await withVerifierCleanup(async () => { 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); verifierRun = await completeVerifierRun(run); assert.equal(verifierRun.process_lineage.status, "verified"); diff --git a/scripts/verify-real-ghidra-decompile-timeout.mjs b/scripts/verify-real-ghidra-decompile-timeout.mjs index 1378990fdd..28b1b28191 100644 --- a/scripts/verify-real-ghidra-decompile-timeout.mjs +++ b/scripts/verify-real-ghidra-decompile-timeout.mjs @@ -2,8 +2,7 @@ import { AnalysisSettings } from "../dist/application/AnalysisSettings.js"; import { runtimeAnalysisConfig } from "../dist/config/runtimeAnalysisSettings.js"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; @@ -23,6 +22,10 @@ import { completeVerifierRun, createVerifierRun, } from "./lib/verifier-run.mjs"; +import { + VerifierFixture, + withVerifierFixture, +} from "./lib/verifier-fixture.mjs"; // Volatile straight-line work produces a natural native response above 1 MiB // and a decompile above one second, without the old branch-heavy fixture. @@ -111,9 +114,10 @@ const installation = await inspectVerifierGhidraInstallation({ assert.equal(installation.status, "available", JSON.stringify(installation)); assert.equal(installation.providerVersion, SUPPORTED_GHIDRA_VERSION); -const workspace = await mkdtemp( - join(tmpdir(), "rea-ghidra-decompile-timeout-"), -); +const workspaceFixture = await VerifierFixture.open({ + prefix: "rea-ghidra-decompile-timeout-", +}); +const workspace = workspaceFixture.path; const began = performance.now(); const controller = new AbortController(); const outerDeadline = setTimeout( @@ -123,7 +127,16 @@ const outerDeadline = setTimeout( outerDeadline.unref(); let cleaned = true; let report; -try { +// The workspace stays while an owned Ghidra process or server may still use +// it: this cleanup confirms only once their cleanup was verified. +workspaceFixture.releaseAfter(() => { + clearTimeout(outerDeadline); + if (!cleaned) + throw new Error( + `Owned Ghidra cleanup is unverified; retaining ${workspace}.`, + ); +}); +await withVerifierFixture(workspaceFixture, async () => { const sourcePath = join(workspace, "slow-decompile.c"); const source = fixtureSource(); await writeFile(sourcePath, source); @@ -403,8 +416,5 @@ try { scope: "Real Ghidra analysis of generated Mach-O/ELF through authenticated loopback: independent value-flow budgets, lowered admission, native payload exhaustion and recovery, natural decompile timeout with listing-backed partial facts, and deadline recovery on the same Program. No target execution. Windows and public CLI/MCP transport recovery are unverified. Timings are observations, not performance comparisons; CPU, peak process-family memory and agent tokens are unmeasured.", }; -} finally { - clearTimeout(outerDeadline); - if (cleaned) await rm(workspace, { recursive: true, force: true }); -} +}); console.log(JSON.stringify({ ...report, cleanup: "complete" })); diff --git a/scripts/verify-real-ghidra-decompiler-failure.mjs b/scripts/verify-real-ghidra-decompiler-failure.mjs index c9c6c23ad1..eabf17aaee 100644 --- a/scripts/verify-real-ghidra-decompiler-failure.mjs +++ b/scripts/verify-real-ghidra-decompiler-failure.mjs @@ -1,7 +1,6 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -27,6 +26,10 @@ import { completeVerifierRun, createVerifierRun, } from "./lib/verifier-run.mjs"; +import { + VerifierFixture, + withVerifierFixture, +} from "./lib/verifier-fixture.mjs"; const run = createVerifierRun(); assert.ok( @@ -50,12 +53,23 @@ const fixtures = fileURLToPath( new URL("../tests/conformance/ghidra", import.meta.url), ); const bridge = fileURLToPath(new URL("../bridge/ghidra", import.meta.url)); -const workspace = await mkdtemp(join(tmpdir(), "rea-ghidra-decompiler-")); +const workspaceFixture = await VerifierFixture.open({ + prefix: "rea-ghidra-decompiler-", +}); +const workspace = workspaceFixture.path; let cleaned = true; let report; const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); const injected = "Ghidra decompilation failed: Injected backend failure"; -try { +// The workspace stays while an owned Ghidra process or server may still use +// it: this cleanup confirms only once their cleanup was verified. +workspaceFixture.releaseAfter(() => { + if (!cleaned) + throw new Error( + `Owned Ghidra cleanup is unverified; retaining ${workspace}.`, + ); +}); +await withVerifierFixture(workspaceFixture, async () => { const targetPath = join( workspace, process.platform === "darwin" ? "cross-format.dylib" : "cross-format.so", @@ -295,9 +309,7 @@ try { scope: "Real Ghidra ELF or Mach-O analysis with an injected decompiler failure; output-contract parsing of the degraded dossier; real read-only TCP provider projection of the healthy path. No target execution; natural decompiler failures, degraded CLI/MCP projection and Windows are unverified.", }; -} finally { - if (cleaned) await rm(workspace, { recursive: true, force: true }); -} +}); console.log( JSON.stringify({ ...report, diff --git a/scripts/verify-real-ghidra-dos.mjs b/scripts/verify-real-ghidra-dos.mjs index 061f97b35d..230db3e9e1 100644 --- a/scripts/verify-real-ghidra-dos.mjs +++ b/scripts/verify-real-ghidra-dos.mjs @@ -1,8 +1,5 @@ #!/usr/bin/env node -import { - closeVerifierResources, - withVerifierCleanup, -} from "./lib/verifier-cleanup.mjs"; +import { closeVerifierResources } from "./lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { requireMcpOperationResult, @@ -10,15 +7,7 @@ import { } from "./lib/mcp-verifier-results.mjs"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { - mkdtemp, - mkdir, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -35,6 +24,10 @@ import { completeVerifierRun, } from "./lib/verifier-run.mjs"; import { verifyGhidraAddressBoundaries } from "./lib/real-ghidra-address-boundaries.mjs"; +import { + VerifierFixture, + withVerifierFixture, +} from "./lib/verifier-fixture.mjs"; // This lane intentionally has no game paths, binary seeds or compiler dependency. if (process.argv.length !== 2) @@ -67,7 +60,11 @@ if (captureDirectory !== undefined) { await mkdir(captureDirectory, { recursive: true, mode: 0o700 }); } const run = createVerifierRun(); -const workspace = await mkdtemp(join(tmpdir(), "rea-dos-proof-")); +const workspaceFixture = await VerifierFixture.open({ + prefix: "rea-dos-proof-", + private: false, +}); +const workspace = workspaceFixture.path; const runtime = join(workspace, "runtime"); await mkdir(runtime); const fixture = buildDosMzFixture(); @@ -107,7 +104,31 @@ let stderr = ""; let connected = false; let verifierRun; let report; -await withVerifierCleanup(async () => { +workspaceFixture.releaseAfter(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", + ); + 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 withVerifierFixture(workspaceFixture, async () => { try { await client.connect(transport); connected = true; @@ -508,33 +529,7 @@ await withVerifierCleanup(async () => { 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", - ); - 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 }); - }, -]); +}); assert.ok(report, "No DOS proof report was produced"); verifierRun = await completeVerifierRun(run); // Windows provider jobs are verified by their native owners, as in the 6502 lane. diff --git a/scripts/verify-real-ghidra-nativeaot.mjs b/scripts/verify-real-ghidra-nativeaot.mjs index 67bec660ae..a8daf25039 100644 --- a/scripts/verify-real-ghidra-nativeaot.mjs +++ b/scripts/verify-real-ghidra-nativeaot.mjs @@ -1,8 +1,5 @@ #!/usr/bin/env node -import { - closeVerifierResources, - withVerifierCleanup, -} from "./lib/verifier-cleanup.mjs"; +import { closeVerifierResources } from "./lib/verifier-cleanup.mjs"; import assert from "node:assert/strict"; import { requireMcpLifecycleResult, @@ -10,15 +7,7 @@ import { } from "./lib/mcp-verifier-results.mjs"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { - mkdtemp, - mkdir, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { basename, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -29,6 +18,10 @@ import { nativeDataTypeSchema } from "../dist/domain/native/nativeDataType.js"; import { nativeLoadImageSchema } from "../dist/domain/native/nativeLoadImage.js"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { + VerifierFixture, + withVerifierFixture, +} from "./lib/verifier-fixture.mjs"; const run = createVerifierRun(); const mode = process.argv[2] ?? "symbols"; if ( @@ -90,7 +83,10 @@ const target = : symbolTarget; const bytes = await readFile(target); const sha256 = hash(bytes); -const workspace = await mkdtemp(join(tmpdir(), "rea-nativeaot-proof-")); +const workspaceFixture = await VerifierFixture.open({ + prefix: "rea-nativeaot-proof-", +}); +const workspace = workspaceFixture.path; const runtime = join(workspace, "runtime"); await mkdir(runtime); const packageRoot = process.env.REA_NATIVEAOT_PROOF_PACKAGE_ROOT; @@ -161,7 +157,24 @@ let opened = false; let connected = false; let verifierRun; let report; -await withVerifierCleanup(async () => { +workspaceFixture.releaseAfter(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 withVerifierFixture(workspaceFixture, async () => { try { if (mode === "loader-failure") await prepareLoaderFailure(); await client.connect(transport); @@ -382,26 +395,7 @@ await withVerifierCleanup(async () => { console.error(stderr); throw error; } -}, [ - 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)); diff --git a/scripts/verify-real-ghidra-noreturn.mjs b/scripts/verify-real-ghidra-noreturn.mjs index 95c354a980..f19bf04bb9 100644 --- a/scripts/verify-real-ghidra-noreturn.mjs +++ b/scripts/verify-real-ghidra-noreturn.mjs @@ -1,8 +1,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -20,6 +19,10 @@ import { spawnOwnedProviderProcess, } from "../dist/process/ProviderProcess.js"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; +import { + VerifierFixture, + withVerifierFixture, +} from "./lib/verifier-fixture.mjs"; const exec = promisify(execFile); const run = createVerifierRun(); @@ -46,11 +49,22 @@ const fixtures = fileURLToPath( new URL("../tests/conformance/ghidra", import.meta.url), ); const bridge = fileURLToPath(new URL("../bridge/ghidra", import.meta.url)); -const workspace = await mkdtemp(join(tmpdir(), "rea-ghidra-noreturn-")); +const workspaceFixture = await VerifierFixture.open({ + prefix: "rea-ghidra-noreturn-", +}); +const workspace = workspaceFixture.path; let cleaned = true; let report; const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); -try { +// The workspace stays while an owned Ghidra process or server may still use +// it: this cleanup confirms only once their cleanup was verified. +workspaceFixture.releaseAfter(() => { + if (!cleaned) + throw new Error( + `Owned Ghidra cleanup is unverified; retaining ${workspace}.`, + ); +}); +await withVerifierFixture(workspaceFixture, async () => { const targetPath = join( workspace, process.platform === "darwin" ? "noreturn.dylib" : "noreturn.so", @@ -215,9 +229,7 @@ try { scope: "Real Ghidra ELF or Mach-O analysis with injected flags; real read-only TCP Evidence projection. No target execution; exact Amethyst artifact and Windows are unverified.", }; -} finally { - if (cleaned) await rm(workspace, { recursive: true, force: true }); -} +}); console.log( JSON.stringify({ ...report, diff --git a/scripts/verify-real-ghidra-raw.mjs b/scripts/verify-real-ghidra-raw.mjs index 229cb16e3b..12771410ac 100644 --- a/scripts/verify-real-ghidra-raw.mjs +++ b/scripts/verify-real-ghidra-raw.mjs @@ -2,15 +2,7 @@ import { schemaVerifierEnvironment } from "./lib/mcp-verification-environment.mj import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { - mkdtemp, - mkdir, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -34,6 +26,10 @@ import { withVerifierCleanup, closeVerifierResources, } from "./lib/verifier-cleanup.mjs"; +import { + VerifierFixture, + withVerifierFixture, +} from "./lib/verifier-fixture.mjs"; if (process.argv.length !== 2) throw new Error("Usage: node scripts/verify-real-ghidra-raw.mjs"); @@ -55,7 +51,10 @@ const installation = await inspectVerifierGhidraInstallation({ assert.equal(installation.status, "available", JSON.stringify(installation)); const run = createVerifierRun(); -const workspace = await mkdtemp(join(tmpdir(), "rea-raw-proof-")); +const workspaceFixture = await VerifierFixture.open({ + prefix: "rea-raw-proof-", +}); +const workspace = workspaceFixture.path; let fixtureCleaned = true; const runtime = join(workspace, "runtime"); await mkdir(runtime); @@ -110,7 +109,29 @@ let connected = false; let verifierRun; let report; let searchDefinition; -await withVerifierCleanup(async () => { +workspaceFixture.releaseAfter(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 withVerifierFixture(workspaceFixture, async () => { try { await client.connect(transport); connected = true; @@ -595,31 +616,7 @@ await withVerifierCleanup(async () => { 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", - ); - 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, []); diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index 43463e04de..a393baaf8b 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -493,24 +493,16 @@ "scripts/verify-published-package.mjs": 1, "scripts/verify-qwen-client.mjs": 1, "scripts/verify-real-cutter.mjs": 1, - "scripts/verify-real-ghidra-6502.mjs": 1, "scripts/verify-real-ghidra-address-context.mjs": 1, "scripts/verify-real-ghidra-checkpoints.mjs": 1, - "scripts/verify-real-ghidra-com.mjs": 1, - "scripts/verify-real-ghidra-decompile-timeout.mjs": 1, - "scripts/verify-real-ghidra-decompiler-failure.mjs": 1, "scripts/verify-real-ghidra-dol.mjs": 1, - "scripts/verify-real-ghidra-dos.mjs": 1, "scripts/verify-real-ghidra-function-bundle.mjs": 1, "scripts/verify-real-ghidra-m68k.mjs": 1, "scripts/verify-real-ghidra-mips-relocatable.mjs": 1, "scripts/verify-real-ghidra-mips-shared.mjs": 1, "scripts/verify-real-ghidra-mips.mjs": 1, - "scripts/verify-real-ghidra-nativeaot.mjs": 1, - "scripts/verify-real-ghidra-noreturn.mjs": 1, "scripts/verify-real-ghidra-ppc.mjs": 1, "scripts/verify-real-ghidra-project-coverage.mjs": 1, - "scripts/verify-real-ghidra-raw.mjs": 1, "scripts/verify-real-ghidra-riscv.mjs": 1, "scripts/verify-real-ghidra-swift.mjs": 1, "scripts/verify-real-ghidra-switch.mjs": 1, @@ -528,12 +520,7 @@ "scripts/verify-web-network-captures.mjs": 1, "scripts/verify-windows-native-inspection.mjs": 1, "scripts/verify-windows-native.mjs": 2, - "scripts/verify-windows-package.mjs": 1, - "scripts/verify/javascript/module-references.mjs": 2, - "scripts/verify/javascript/native-handoffs.mjs": 2, - "scripts/verify/javascript/output.mjs": 1, - "scripts/verify/javascript/recovery.mjs": 1, - "scripts/verify/javascript/retained-facts.mjs": 1 + "scripts/verify-windows-package.mjs": 1 }, "macho-load-command-literal": {}, "lifecycle-plain-throw": { diff --git a/scripts/verify/javascript/module-references.mjs b/scripts/verify/javascript/module-references.mjs index d5e2d22d73..31ecf9b59e 100644 --- a/scripts/verify/javascript/module-references.mjs +++ b/scripts/verify/javascript/module-references.mjs @@ -11,7 +11,7 @@ import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { artifactCliEvidence } from "../../lib/artifact-e2e.mjs"; import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs"; -import { PrivateRuntimeRoot } from "../../../dist/process/PrivateRuntimeRoot.js"; +import { VerifierFixture } from "../../lib/verifier-fixture.mjs"; import { OwnedCommandFailure, runOwnedCommand, @@ -82,7 +82,6 @@ const normalize = (rows) => ), })); let temporary; -let retainTemporary = false; await withVerifierCleanup(async () => { await client.connect(transport); transport.stderr?.resume(); @@ -266,7 +265,7 @@ await withVerifierCleanup(async () => { } let cliParity = null; if (values["cli-parity"]) { - temporary = await PrivateRuntimeRoot.create({ + temporary = await VerifierFixture.open({ prefix: "rea-module-reference-verifier-", }); const bundlePath = join(temporary.path, "bundle.json"); @@ -312,7 +311,7 @@ await withVerifierCleanup(async () => { } let tokenCosts = null; if (values["tokenizer-python"]) { - temporary ??= await PrivateRuntimeRoot.create({ + temporary ??= await VerifierFixture.open({ prefix: "rea-module-reference-verifier-", }); const input = join(temporary.path, "token-input.json"); @@ -357,9 +356,13 @@ print(json.dumps(dict(tokenizer='cl100k_base',tiktoken_version=tiktoken.__versio // An incomplete cleanup carries its supervisor and identity resources in // the error. Preserve its private files for that owner instead of erasing // recovery data while a command may still be using it. - retainTemporary = + if ( cause instanceof OwnedCommandFailure && - cause.cleanupOwner !== undefined; + cause.cleanupOwner !== undefined + ) + temporary.retain( + `Command cleanup is incomplete; retaining its private input/Evidence files at ${temporary.path}.`, + ); throw new Error( "Token accounting failed; inspect the owned-command outcome and cleanup status.", { cause }, @@ -392,15 +395,4 @@ print(json.dumps(dict(tokenizer='cl100k_base',tiktoken_version=tiktoken.__versio "First query is after shared artifact analysis; subsequent queries share one MCP session. Legacy item reads are serial; a concurrent legacy baseline is unmeasured. Filesystem/build warmness uncontrolled. Token costs count one post-change catalog, preparation request/summary and each request/structured reply once; client rendering, replay, reasoning, final answer and model latency are unmeasured. Preparation and parity/tokenization overhead are outside per-query elapsed times; external family-resource receipts include the complete verifier and children.", })}\n`, ); -}, [ - () => client.close(), - () => transport.close(), - async () => { - if (temporary === undefined) return; - if (retainTemporary) - throw new Error( - `Command cleanup is incomplete; retaining its private input/Evidence files at ${temporary.path}.`, - ); - await temporary.close(); - }, -]); +}, [() => client.close(), () => transport.close(), () => temporary?.release()]); diff --git a/scripts/verify/javascript/native-handoffs.mjs b/scripts/verify/javascript/native-handoffs.mjs index 81ce1224e2..9a713e4cce 100644 --- a/scripts/verify/javascript/native-handoffs.mjs +++ b/scripts/verify/javascript/native-handoffs.mjs @@ -11,7 +11,7 @@ import { parseArgs } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { createEvidence } from "../../../dist/domain/evidence.js"; -import { PrivateRuntimeRoot } from "../../../dist/process/PrivateRuntimeRoot.js"; +import { VerifierFixture } from "../../lib/verifier-fixture.mjs"; import { OwnedCommandFailure, runOwnedCommand, @@ -124,7 +124,6 @@ const simulatedNativeEvidence = (path, sha256) => }, ); let temporary; -let retainTemporary = false; let report; const ownedCommand = async (command, arguments_, timeoutMs = 60_000) => { try { @@ -141,8 +140,13 @@ const ownedCommand = async (command, arguments_, timeoutMs = 60_000) => { { timeoutMs, diagnosticBytes: 16 * 1024 * 1024 }, ); } catch (cause) { - retainTemporary ||= - cause instanceof OwnedCommandFailure && cause.cleanupOwner !== undefined; + if ( + cause instanceof OwnedCommandFailure && + cause.cleanupOwner !== undefined + ) + temporary.retain( + `Command cleanup is incomplete; retaining its private inputs at ${temporary.path}.`, + ); throw cause; } }; @@ -314,7 +318,7 @@ await withVerifierCleanup(async () => { } let cliParity = null; if (values["cli-parity"]) { - temporary = await PrivateRuntimeRoot.create({ + temporary = await VerifierFixture.open({ prefix: "rea-native-handoff-verifier-", }); const bundlePath = join(temporary.path, "bundle.json"); @@ -381,7 +385,7 @@ await withVerifierCleanup(async () => { ); let tokenCosts = null; if (values["tokenizer-python"]) { - temporary ??= await PrivateRuntimeRoot.create({ + temporary ??= await VerifierFixture.open({ prefix: "rea-native-handoff-verifier-", }); const input = join(temporary.path, "token-input.json"); @@ -461,15 +465,5 @@ print(json.dumps(dict(tokenizer='cl100k_base',tiktoken_version=tiktoken.__versio assert.ok(safeIdentity, "Native identity/linkage oracle failed"); assert.ok(safeApplicability, "File analysis applicability oracle failed"); } -}, [ - () => client.close(), - () => transport.close(), - async () => { - if (retainTemporary) - throw new Error( - `Command cleanup incomplete; retaining owned inputs at ${temporary?.path}.`, - ); - await temporary?.close(); - }, -]); +}, [() => client.close(), () => transport.close(), () => temporary?.release()]); console.log(serialize(report)); diff --git a/scripts/verify/javascript/output.mjs b/scripts/verify/javascript/output.mjs index e63856409a..92913f22dd 100644 --- a/scripts/verify/javascript/output.mjs +++ b/scripts/verify/javascript/output.mjs @@ -5,7 +5,7 @@ import { once } from "node:events"; import { createInterface } from "node:readline"; import { Writable } from "node:stream"; import { createReadStream, createWriteStream } from "node:fs"; -import { mkdtemp, rm, stat, statfs } from "node:fs/promises"; +import { stat, statfs } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { finished } from "node:stream/promises"; @@ -16,6 +16,10 @@ import { estimateTokenCount } from "tokenx"; import { parseCliOutputArguments } from "../../../dist/cliOutput.js"; import { createCliResultOutput } from "../../../dist/cli/cliResultOutput.js"; import { createEvidence } from "../../../dist/domain/evidence.js"; +import { + VerifierFixture, + withVerifierFixture, +} from "../../lib/verifier-fixture.mjs"; // This opt-in format-boundary check writes one temporary file larger than the // actual engine string limit. Shared leaves keep the analysis input small. @@ -29,7 +33,10 @@ assert.ok( volume.bavail * volume.bsize > count * encodedLeaf.length + 1024 ** 3, "Large-output verification needs room for one output file and a 1-GiB reserve", ); -const workspace = await mkdtemp(join(tmpdir(), "rea-javascript-output-")); +const fixture = await VerifierFixture.open({ + prefix: "rea-javascript-output-", +}); +const workspace = fixture.path; const path = join(workspace, "result.json"); let destination; let peakRss = process.memoryUsage().rss; @@ -40,7 +47,17 @@ const sampler = setInterval(sampleRss, 25); sampler.unref(); let finalReport; -try { +// The output stream writes under the root: it closes before the root is +// removed. +fixture.releaseAfter(async () => { + clearInterval(sampler); + if (destination !== undefined && !destination.closed) { + const closed = once(destination, "close"); + destination.destroy(); + await closed; + } +}); +await withVerifierFixture(fixture, async () => { const evidence = createEvidence( undefined, { id: "large-output-fixture", name: "Large output fixture", version: "1" }, @@ -189,13 +206,5 @@ try { evidence_id: evidence.evidence_id, rss: { method: "node-memoryUsage-rss-sampled", peak_bytes: peakRss }, }; -} finally { - clearInterval(sampler); - if (destination !== undefined && !destination.closed) { - const closed = once(destination, "close"); - destination.destroy(); - await closed; - } - await rm(workspace, { recursive: true, force: true }); -} +}); process.stdout.write(`${JSON.stringify(finalReport)}\n`); diff --git a/scripts/verify/javascript/recovery.mjs b/scripts/verify/javascript/recovery.mjs index 8220c849de..30729c7366 100644 --- a/scripts/verify/javascript/recovery.mjs +++ b/scripts/verify/javascript/recovery.mjs @@ -1,13 +1,12 @@ #!/usr/bin/env node import { - closeVerifierResources, - withVerifierCleanup, -} from "../../lib/verifier-cleanup.mjs"; + VerifierFixture, + withVerifierFixture, +} from "../../lib/verifier-fixture.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { readFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -26,7 +25,10 @@ if (!process.env.REA_WAKARU_COMMAND) ); const entrypoint = process.argv[2] ?? fileURLToPath(new URL("../../rea.mjs", import.meta.url)); -const root = await mkdtemp(join(tmpdir(), "rea-real-recovery-")); +const rootFixture = await VerifierFixture.open({ + prefix: "rea-real-recovery-", +}); +const root = rootFixture.path; const environment = { ...process.env, REA_LOG_LEVEL: "silent" }; const transport = new StdioClientTransport({ command: process.execPath, @@ -55,7 +57,11 @@ const call = async (name, args) => { const fingerprint = (bytes) => createHash("sha256").update(bytes).digest("hex"); const counts = []; let finalReport; -await withVerifierCleanup(async () => { +// The MCP server reads the built fixtures and writes recovered output under +// the root: the client and its transport close before the root is removed. +rootFixture.releaseAfter(() => client.close()); +rootFixture.releaseAfter(() => transport.close()); +await withVerifierFixture(rootFixture, async () => { const fixtures = await buildRecoveryFixtures( root, process.env.REA_JAVASCRIPT_FIXTURE_TOOLS, @@ -169,15 +175,7 @@ await withVerifierCleanup(async () => { results: counts, verified: 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/scripts/verify/javascript/retained-facts.mjs b/scripts/verify/javascript/retained-facts.mjs index f8603fff68..4be3461746 100644 --- a/scripts/verify/javascript/retained-facts.mjs +++ b/scripts/verify/javascript/retained-facts.mjs @@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { PrivateRuntimeRoot } from "../../../dist/process/PrivateRuntimeRoot.js"; +import { VerifierFixture } from "../../lib/verifier-fixture.mjs"; import { OwnedCommandFailure, runOwnedCommand, @@ -56,7 +56,6 @@ const client = new Client({ version: "1", }); let temporary; -let retainTemporary = false; let report; const serialize = (value) => JSON.stringify(value); const digest = (value) => @@ -76,8 +75,13 @@ const ownedCommand = async (command, arguments_) => { { timeoutMs: 120_000, diagnosticBytes: 16 * 1024 * 1024 }, ); } catch (cause) { - retainTemporary ||= - cause instanceof OwnedCommandFailure && cause.cleanupOwner !== undefined; + if ( + cause instanceof OwnedCommandFailure && + cause.cleanupOwner !== undefined + ) + temporary.retain( + `Command cleanup is incomplete; retaining its private inputs at ${temporary.path}.`, + ); throw cause; } }; @@ -111,7 +115,7 @@ await withVerifierCleanup(async () => { for await (const chunk of createReadStream(values.artifact)) artifactHash.update(chunk); const artifactSha256 = artifactHash.digest("hex"); - temporary = await PrivateRuntimeRoot.create({ + temporary = await VerifierFixture.open({ prefix: "rea-retained-facts-verifier-", }); await client.connect(transport); @@ -343,15 +347,5 @@ await withVerifierCleanup(async () => { conditions: "Fresh MCP and input hashing (file cache warmed); build/OS caches uncontrolled. First export sample includes export plus caller projection code, later samples reuse that immutable export. View samples precede diagnostic export. Task token projections include preparation, first answer, selected/full name/description/inputSchema catalog and the exact baseline program; exec tool schema, host rendering, output schemas, reasoning and billing unmeasured. At-answer cgroup counters include observer/startup overhead, before repeats/export diagnostics/parity/tokenization; final external observer measures all validation work. No application or native provider executes.", }; -}, [ - () => client.close(), - () => transport.close(), - async () => { - if (retainTemporary) - throw new Error( - `Command cleanup incomplete; retaining owned inputs at ${temporary?.path}.`, - ); - await temporary?.close(); - }, -]); +}, [() => client.close(), () => transport.close(), () => temporary?.release()]); console.log(serialize(report)); diff --git a/tests/process-global/verifierFixture.test.ts b/tests/process-global/verifierFixture.test.ts index ebefcb16db..c80dd96a87 100644 --- a/tests/process-global/verifierFixture.test.ts +++ b/tests/process-global/verifierFixture.test.ts @@ -192,3 +192,30 @@ it("allocates a plain temporary directory for child scratch space", async () => await fixture.release(); expect(await exists(fixture.path)).toBe(false); }); + +it("keeps a retained root across every release and names why", async () => { + let closes = 0; + const fixture = new VerifierFixture({ + path: "/fixture/root", + close: async () => { + closes += 1; + }, + }); + fixture.retain("Command cleanup is incomplete."); + for (const _attempt of [1, 2]) { + const failure: unknown = await fixture + .release() + .catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(AggregateError); + expect( + ((failure as AggregateError).errors as Error[]).map( + ({ message }) => message, + ), + ).toEqual([ + "Command cleanup is incomplete.", + "Retained verifier fixture /fixture/root because a cleanup that may still use it did not confirm.", + ]); + } + expect(closes).toBe(0); + expect(fixture.released).toBe(false); +});