From 2d16a40a491067ef0bbe541feef2feeb5f3110ad Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:58:50 +0800 Subject: [PATCH] fix(ghidra): report selected project identity failures (#8379) * fix(ghidra): report selected project identity failures * fix(ghidra): retain refusal until client shutdown --- bridge/ghidra/ReaGhidraBridge.java | 29 +- docs/installation.md | 7 +- scripts/lib/native-value-e2e.mjs | 7 + scripts/lib/real-ghidra-project-identity.mjs | 249 ++++++++++++++++++ src/ghidra/GhidraAdmissionError.ts | 2 + src/ghidra/GhidraClientStartup.ts | 20 ++ src/ghidra/GhidraProvider.behavior.test.ts | 41 +++ src/ghidra/GhidraProviderClient.ts | 27 ++ .../providers/ghidra/ghidraClient.fixture.ts | 3 + .../providers/ghidra/ghidraClient.test.ts | 246 ++++++++++++----- tests/fixtures/fakeGhidra.mjs | 51 ++++ 11 files changed, 605 insertions(+), 77 deletions(-) create mode 100644 scripts/lib/real-ghidra-project-identity.mjs diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index efed6af767..c815e8edf6 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -179,6 +179,8 @@ public final class ReaGhidraBridge extends HeadlessScript { private List procedureInventory; private List stringInventory; private DecompInterface decompiler; + // A refused Program may only report its admission failure to the client. + private String identityAdmissionFailure; /** Per-function native decompiler limit from the session; 0 leaves it unbounded. */ private int decompileTimeoutSeconds; // DecompileProcess converts seconds to an int millisecond timer delay. @@ -224,14 +226,17 @@ public final class ReaGhidraBridge extends HeadlessScript { throw new IllegalStateException("Ghidra provider version does not match the session"); } String importedSha256 = currentProgram.getExecutableSHA256(); - if (importedSha256 == null || - !constantTimeEquals( - importedSha256.toLowerCase(Locale.ROOT), - descriptor.targetSha256 - )) { - throw new IllegalStateException( - "Ghidra imported-byte digest does not match the admitted target" - ); + if (importedSha256 == null || !importedSha256.matches("(?i)[0-9a-f]{64}")) { + identityAdmissionFailure = "import_identity_unavailable"; + } + else if (!constantTimeEquals(importedSha256.toLowerCase(Locale.ROOT), + descriptor.targetSha256)) { + identityAdmissionFailure = "import_identity_mismatch"; + } + if (identityAdmissionFailure != null) { + end(false); + serve(descriptor); + return; } admittedTargetSha256 = descriptor.targetSha256; // GhidraScript wraps run() in a transaction. End it before serving so @@ -405,6 +410,14 @@ public final class ReaGhidraBridge extends HeadlessScript { writeSuccess(writer, request.id, result); return; } + if (identityAdmissionFailure != null) { + // Keep the producer alive until the client has consumed the + // refusal and closed or sent shutdown. Every other request + // remains refused, including further startup attempts. + writeFailure(writer, request.id, identityAdmissionFailure, + "Ghidra imported identity admission failed"); + continue; + } decompileTimeoutSeconds = request.decompileTimeoutSeconds() == null ? descriptor.decompileTimeoutSeconds() : request.decompileTimeoutSeconds(); writeSuccess(writer, request.id, handleRequest(request, descriptor)); diff --git a/docs/installation.md b/docs/installation.md index 322a0a55aa..a2cdd53a4a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1450,8 +1450,11 @@ the original database; REA reads selected files only for fingerprinting and copy This mode does not copy unrelated sibling projects, rerun repair scripts, or admit bridge database edits. Cleanup removes the owned copy. The bridge checks the program's recorded import SHA-256 against the admitted -target digest; mismatches and project changes fail explicitly. Reopen the target -to resolve a new profile after intentionally changing the source project. +target digest. An unavailable or malformed recorded digest is reported as an +unverified identity, distinct from a known mismatch; neither admits the program +or triggers a fresh import. Select a matching target or explicitly configure a +different project or program. Changes to the source project also fail admission; +reopen the target to resolve a new profile after intentionally changing it. The profile records the caller path, resolved project/name, program and content fingerprint. Original analysis settings and completeness remain **unknown**; diff --git a/scripts/lib/native-value-e2e.mjs b/scripts/lib/native-value-e2e.mjs index a20ccb2e3a..959a62b6d8 100644 --- a/scripts/lib/native-value-e2e.mjs +++ b/scripts/lib/native-value-e2e.mjs @@ -17,6 +17,7 @@ import { requireMcpOutput, } from "./mcp-verifier-results.mjs"; import { verifyGhidraBoundaries } from "./real-ghidra-boundaries.mjs"; +import { verifyGhidraProjectIdentity } from "./real-ghidra-project-identity.mjs"; const defaultEntrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)); const environment = () => ({ @@ -317,6 +318,11 @@ async function verifyNativeValueAdapters( await client.close(); await transport.close(); } + const projectIdentity = await verifyGhidraProjectIdentity({ + target, + entrypoint, + env, + }); return { mocked: false, cli: true, @@ -324,6 +330,7 @@ async function verifyNativeValueAdapters( inline_evidence: true, direct_function_comparison: true, boundaries, + project_identity: projectIdentity, nodes: cliGraph.total_nodes, edges: cliGraph.total_edges, decompilations: cliGraph.decompilations, diff --git a/scripts/lib/real-ghidra-project-identity.mjs b/scripts/lib/real-ghidra-project-identity.mjs new file mode 100644 index 0000000000..1c068ab2ed --- /dev/null +++ b/scripts/lib/real-ghidra-project-identity.mjs @@ -0,0 +1,249 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + appendFile, + copyFile, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { basename, join } from "node:path"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { requireMcpToolError } from "./mcp-verifier-results.mjs"; + +const exec = promisify(execFile); + +/** Verify selected-project identity refusal through real CLI and MCP adapters. */ +export async function verifyGhidraProjectIdentity({ target, entrypoint, env }) { + if (process.platform === "win32") + return { + status: "not-applicable", + reason: "Existing Ghidra projects are not admitted on Windows.", + }; + assert.ok(env.GHIDRA_INSTALL_DIR, "Ghidra installation is required"); + const root = await mkdtemp(join(env.TMPDIR, "rea-project-identity-")); + const targetPath = target.path; + const different = join(root, "different-target"); + const headless = join(env.GHIDRA_INSTALL_DIR, "support", "analyzeHeadless"); + const fixtureEnv = { + ...env, + HOME: root, + XDG_CONFIG_HOME: join(root, "config"), + XDG_CACHE_HOME: join(root, "cache"), + XDG_DATA_HOME: join(root, "data"), + JDK_JAVA_OPTIONS: `-Duser.home="${root}"`, + }; + const program = basename(targetPath); + const known = join(root, "known.gpr"); + const unknown = join(root, "unknown.gpr"); + const runtimeRootsBefore = (await readdir(env.TMPDIR)) + .filter((name) => name.startsWith("rea-ghidra-")) + .sort(); + const sha = async (path) => + createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + try { + await copyFile(targetPath, different); + await appendFile(different, "\nREA_IDENTITY_VERIFIER\n"); + assert.notEqual(await sha(different), await sha(targetPath)); + await writeFile( + join(root, "ClearImportDigest.java"), + `import ghidra.app.script.GhidraScript; +public class ClearImportDigest extends GhidraScript { + @Override public void run() { + currentProgram.setExecutableSHA256(null); + println("REA_DIGEST_AFTER=" + currentProgram.getExecutableSHA256()); + } +}\n`, + ); + await exec( + headless, + [root, "known", "-import", targetPath, "-noanalysis"], + { env: fixtureEnv, timeout: 180000, maxBuffer: 8 * 1024 * 1024 }, + ); + const cleared = await exec( + headless, + [ + root, + "unknown", + "-import", + targetPath, + "-noanalysis", + "-scriptPath", + root, + "-postScript", + "ClearImportDigest.java", + ], + { env: fixtureEnv, timeout: 180000, maxBuffer: 8 * 1024 * 1024 }, + ); + assert.match(cleared.stdout, /REA_DIGEST_AFTER=unknown/u); + const before = await projectState([known, unknown, targetPath, different]); + + const knownEnv = { + ...env, + REA_GHIDRA_PROJECT_PATH: known, + REA_GHIDRA_PROGRAM_NAME: program, + }; + const unknownEnv = { + ...env, + REA_GHIDRA_PROJECT_PATH: unknown, + REA_GHIDRA_PROGRAM_NAME: program, + }; + const mismatch = await selectedProjectMcp({ + entrypoint, + env: knownEnv, + first: different, + matching: targetPath, + expected: "import_identity_mismatch", + }); + const unavailable = await selectedProjectMcp({ + entrypoint, + env: unknownEnv, + first: targetPath, + expected: "import_identity_unavailable", + }); + for (const [label, selectedEnv, input, expected] of [ + ["mismatch", knownEnv, different, "import_identity_mismatch"], + ["unavailable", unknownEnv, targetPath, "import_identity_unavailable"], + ]) { + let failed; + try { + await exec( + process.execPath, + [ + entrypoint, + "decompile", + input, + "0x0", + "--provider", + "ghidra", + "--format", + "json", + ], + { env: selectedEnv, timeout: 180000, maxBuffer: 8 * 1024 * 1024 }, + ); + } catch (cause) { + assert.equal(cause.code, 1, `${label} CLI exit`); + failed = JSON.parse(cause.stdout); + } + assert.ok(failed, `${label} CLI must refuse`); + assert.equal(failed.code, "configuration_invalid"); + assert.equal(failed.details.diagnostics.remote_code, expected); + } + assert.deepEqual( + await projectState([known, unknown, targetPath, different]), + before, + ); + assert.deepEqual( + (await readdir(env.TMPDIR)) + .filter((name) => name.startsWith("rea-ghidra-")) + .sort(), + runtimeRootsBefore, + "Selected-project checks left a Ghidra runtime directory", + ); + return { + status: "verified", + mismatch, + unavailable, + cli_refusals: true, + source_files_unchanged: true, + }; + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function selectedProjectMcp({ + entrypoint, + env, + first, + matching, + expected, +}) { + const client = new Client({ + name: "rea-project-identity-verifier", + version: "1", + }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", + }); + try { + await client.connect(transport); + const tools = new Map( + (await client.listTools()).tools.map((tool) => [tool.name, tool]), + ); + const call = (name, arguments_) => + client.callTool( + { name, arguments: arguments_ }, + { timeout: 180000, toolDefinition: tools.get(name) }, + ); + assert.notEqual( + (await call("open_binary", { path: first, provider_id: "ghidra" })) + .isError, + true, + ); + const rejected = requireMcpToolError(await call("list_documents", {})); + assert.equal( + rejected.code, + "configuration_invalid", + JSON.stringify({ + code: rejected.code, + message: rejected.message, + failure_kind: rejected.details?.diagnostics?.failure_kind, + failure_message: rejected.details?.diagnostics?.failure_message, + stdout: rejected.details?.diagnostics?.stdout?.text?.slice(-2500), + stderr: rejected.details?.diagnostics?.stderr?.text?.slice(-1000), + }), + ); + assert.equal(rejected.details.diagnostics.remote_code, expected); + if (matching !== undefined) { + assert.notEqual( + (await call("open_binary", { path: matching, provider_id: "ghidra" })) + .isError, + true, + ); + assert.notEqual((await call("list_documents", {})).isError, true); + } + assert.notEqual((await call("close_binary", {})).isError, true); + return { + refused: expected, + matching_target_reopened: matching !== undefined, + }; + } finally { + await client.close(); + await transport.close(); + } +} + +async function projectState(paths) { + const files = []; + const walk = async (dir) => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) await walk(path); + else if (entry.isFile()) files.push(path); + } + }; + for (const path of paths) { + files.push(path); + if (path.endsWith(".gpr")) await walk(`${path.slice(0, -4)}.rep`); + } + files.sort(); + return Promise.all( + files.map(async (path) => [ + path, + createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + ]), + ); +} diff --git a/src/ghidra/GhidraAdmissionError.ts b/src/ghidra/GhidraAdmissionError.ts index f6ecbf7890..428c0170fa 100644 --- a/src/ghidra/GhidraAdmissionError.ts +++ b/src/ghidra/GhidraAdmissionError.ts @@ -28,6 +28,8 @@ const GHIDRA_ADMISSION_CODES = [ "prepared_options_exceed_bound", "output_unobserved", "handshake_identity", + "import_identity_unavailable", + "import_identity_mismatch", "response_shape", "response_json", "seed_path_differs", diff --git a/src/ghidra/GhidraClientStartup.ts b/src/ghidra/GhidraClientStartup.ts index 092e2d7840..bab00101eb 100644 --- a/src/ghidra/GhidraClientStartup.ts +++ b/src/ghidra/GhidraClientStartup.ts @@ -8,6 +8,7 @@ import { type GhidraSessionFailureFactory, } from "./GhidraSessionError.js"; import type { GhidraSessionInfo } from "./GhidraSessionValues.js"; +import { GhidraAdmissionError } from "./GhidraAdmissionError.js"; /** Inputs needed to complete the post-connection bridge handshake. */ export interface GhidraStartupHandshakeOptions { @@ -42,6 +43,25 @@ export async function completeGhidraStartupHandshake( options.deadline.interruption !== undefined ? interruptionFailure(options) : ping.error; + if ( + failure.kind === "remote" && + (failure.remoteCode === "import_identity_unavailable" || + failure.remoteCode === "import_identity_mismatch") + ) { + const admitted = options.failure( + "admission", + failure.message, + new GhidraAdmissionError(failure.remoteCode, undefined, { + cause: failure, + }), + { remoteCode: failure.remoteCode }, + ); + return err( + failure.cleanupFailure === undefined + ? admitted + : admitted.withCleanupFailure(failure.cleanupFailure), + ); + } return err(failure); } const parsed = options.parseSessionInfo(ping.value); diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index b5c94aaf9a..4018194eb9 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -89,6 +89,7 @@ import { } from "./GhidraSessionValues.js"; import type { Result } from "../domain/result.js"; import { GhidraSessionError } from "./GhidraSessionError.js"; +import { GhidraAdmissionError } from "./GhidraAdmissionError.js"; import { silentLogger } from "../logger.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -4173,6 +4174,46 @@ describe("Ghidra deadline guidance", () => { }); }); +it.each([ + ["import_identity_unavailable", "did not report a valid imported SHA-256"], + ["import_identity_mismatch", "differs from the selected target"], +] as const)( + "reports fresh-import %s without blaming project configuration", + async (code, message) => { + const failure = new GhidraSessionError( + "admission", + "Identity admission failed", + {}, + { + cause: new GhidraAdmissionError(code), + remoteCode: code, + }, + ); + const ghidra = provider(installationHost(), () => ({ + start: async () => err(failure), + callTool: async () => err(failure), + close: async () => ok(null), + })); + onTestFinished(async () => { + await ghidra.closeOwnedResources(); + }); + const target = executableTarget("elf", "x86_64"); + const profile = await ghidra.resolveAnalysisProfile(target); + if (!profile.ok || profile.value.profile === null) + throw new Error("Expected profile"); + const result = await ghidra + .createClient(target, profile.value.profile) + .execute("list_documents", {}); + if (result.ok) throw new Error("Expected identity admission failure"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("execution_failure"); + expect(projected.message).toContain(message); + expect(JSON.stringify(projected.details)).not.toContain( + "REA_GHIDRA_PROJECT_PATH", + ); + }, +); + describe("Ghidra startup deadline for a finished invocation", () => { const startupLimit = { setting: "ghidra.startup_timeout_ms", diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 0468cd4a36..c7b69896ad 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -9,6 +9,7 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup. import { artifactFilePrimaryFacts } from "../artifacts/ArtifactFileOperation.js"; import { fileURLToPath } from "node:url"; import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js"; +import { GhidraAdmissionError } from "./GhidraAdmissionError.js"; import { createAnalysisExecution, @@ -1239,6 +1240,32 @@ const projectPrimarySessionError = ( physicalCause.reason, context, ); + if ( + failure.kind === "admission" && + failure.cause instanceof GhidraAdmissionError && + (failure.cause.code === "import_identity_unavailable" || + failure.cause.code === "import_identity_mismatch") + ) { + const unavailable = failure.cause.code === "import_identity_unavailable"; + const constraint = unavailable + ? "The Ghidra program did not report a valid imported SHA-256, so REA cannot verify its target identity" + : "The Ghidra program's imported SHA-256 differs from the selected target's SHA-256"; + const remediationAction = unavailable + ? "Select a Ghidra program whose imported identity can be verified, or investigate the missing identity in Ghidra. Configure a different project or program when starting REA. REA did not fall back to a fresh import." + : "Select a target matching the configured program and retry, or configure a matching project or program when starting REA. Resolve any reported cleanup before reopening. REA did not fall back to a fresh import."; + if (limits.ghidraExistingProject !== undefined) + return new ConfigurationError(constraint, { + ...context, + settings: [{ setting: "REA_GHIDRA_PROJECT_PATH", constraint }], + remediationAction, + }); + return new ProviderAdapterError("ghidra", operation, { + ...context, + userMessage: `${constraint}.`, + remediationAction: + "Inspect the retained Ghidra diagnostics and target identity before retrying the import.", + }); + } // A well-formed address that lacks one exact source offset keeps the // bridge's reason and the caller's exact spelling, not schema advice. const fileOffset = diff --git a/tests/boundary/providers/ghidra/ghidraClient.fixture.ts b/tests/boundary/providers/ghidra/ghidraClient.fixture.ts index 045a9065db..91e9f5bbe9 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.fixture.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.fixture.ts @@ -29,6 +29,9 @@ type FixtureMode = | "remote_error" | "decompile_timed_out" | "remote_startup_error" + | "import_identity_unavailable" + | "import_identity_mismatch" + | "late_identity_failure" | "shutdown_error" | "invalid_shutdown_ack" | "hang_after_start" diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index a07c00c5c4..6e481ad4af 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -29,6 +29,7 @@ import type { ProgressUpdate } from "../../../../src/application/ProgressReporte import { err, ok } from "../../../../src/domain/result.js"; import * as GhidraTransport from "../../../../src/ghidra/GhidraTransport.js"; import { GhidraClient } from "../../../../src/ghidra/GhidraClient.js"; +import { GhidraAdmissionError } from "../../../../src/ghidra/GhidraAdmissionError.js"; import { ProviderRunLineage } from "../../../../src/process/ProviderRunLineage.js"; import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js"; import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; @@ -1017,6 +1018,45 @@ it("retains launcher diagnostics when an input error rejects the startup handsha expect(result.error.diagnostics).not.toHaveProperty("diagnostic_scope"); }); +it.each( + ( + ["import_identity_unavailable", "import_identity_mismatch"] as const + ).flatMap((code) => + (HOST_TRANSPORT === "unix-socket" + ? ([HOST_TRANSPORT, "authenticated-loopback-tcp"] as const) + : ([HOST_TRANSPORT] as const) + ).map((transport) => [code, transport] as const), + ), +)( + "retains structured %s from %s startup and cleans the failed bridge", + async (code, transport) => { + const launcher = new FixtureLauncher(code); + const client = clientFor(launcher, { transport }); + const result = await client.start(); + if (result.ok) throw new Error("Expected identity admission failure"); + expect(result.error.kind).toBe("admission"); + expect(result.error.cause).toBeInstanceOf(GhidraAdmissionError); + expect(result.error.cause).toMatchObject({ code }); + expect(client.isReady()).toBe(false); + await expect(waitForExit(launcher.processes[0])).resolves.toBe(true); + expect(launcher.requests).toEqual(["ping", "shutdown"]); + await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +it("does not classify a later remote error as an identity admission failure", async () => { + const client = clientFor(new FixtureLauncher("late_identity_failure")); + expect((await client.start()).ok).toBe(true); + const result = await client.callTool("list_strings", {}); + if (result.ok) throw new Error("Expected the fixture's later remote failure"); + expect(result.error.kind).toBe("remote"); + expect(result.error.remoteCode).toBe("import_identity_mismatch"); + expect(result.error.cause).not.toBeInstanceOf(GhidraAdmissionError); + expect(client.isReady()).toBe(true); +}); + describe("GhidraClient", () => { it("completes an exact, fragmented post-analysis handshake", async () => { const launcher = new FixtureLauncher("fragmented"); @@ -2073,10 +2113,73 @@ describe("GhidraClient shutdown diagnostics", () => { ); }); -it("preserves failed startup and retained Ghidra cleanup capabilities until a verified retry", async () => { - const fixture = new FixtureLauncher("wrong_identity"); - let cleanupAllowed = false; - let cleanupAttempts = 0; +it.each([ + ["wrong_identity", "protocol"], + ["import_identity_unavailable", "admission"], +] as const)( + "preserves %s and retained Ghidra cleanup until a verified retry", + async (mode, kind) => { + const fixture = new FixtureLauncher(mode); + let cleanupAllowed = false; + let cleanupAttempts = 0; + const launcher: GhidraLauncher = { + launch: async (session) => { + const launched = await fixture.launch(session); + if (!launched.ok) return launched; + return ok({ + ...launched.value, + cleanup: async () => { + cleanupAttempts += 1; + return cleanupAllowed + ? { cleaned: true as const, signaled: false } + : { + cleaned: false as const, + reason: "fixture process cleanup unconfirmed", + }; + }, + }); + }, + }; + const client = clientFor(launcher); + onTestFinished(async () => { + cleanupAllowed = true; + await client.close(); + }); + const failed = await client.start(); + expect(failed).toMatchObject({ + ok: false, + error: { kind, cleanupFailure: { cleanupIncomplete: true } }, + }); + if (failed.ok) throw new Error("Expected failed startup"); + if (mode === "import_identity_unavailable") + expect(failed.error.cause).toMatchObject({ code: mode }); + const runtime = fixture.runtimeRoots[0]; + if (runtime === undefined) + throw new Error("Fixture runtime was not captured"); + await access(runtime); + expect(cleanupAttempts).toBe(1); + expect(await client.close()).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + cleanupAllowed = true; + expect(await client.close()).toEqual(ok(null)); + expect(failed.error.cleanupFailure?.cleanupIncomplete).toBe(false); + expect(cleanupAttempts).toBe(3); + await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it("retains the identity refusal when cancellation arrives during final startup cleanup", async () => { + const fixture = new FixtureLauncher("import_identity_mismatch"); + let entered: (() => void) | undefined; + let release: (() => void) | undefined; + const cleanupStarted = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); const launcher: GhidraLauncher = { launch: async (session) => { const launched = await fixture.launch(session); @@ -2084,39 +2187,40 @@ it("preserves failed startup and retained Ghidra cleanup capabilities until a ve return ok({ ...launched.value, cleanup: async () => { - cleanupAttempts += 1; - return cleanupAllowed - ? { cleaned: true as const, signaled: false } - : { - cleaned: false as const, - reason: "fixture process cleanup unconfirmed", - }; + entered?.(); + await gate; + return { cleaned: true as const, signaled: false }; }, }); }, }; - const client = clientFor(launcher); - onTestFinished(async () => { - cleanupAllowed = true; - await client.close(); - }); - expect(await client.start()).toMatchObject({ - ok: false, - error: { kind: "protocol", cleanupFailure: { cleanupIncomplete: true } }, - }); - const runtime = fixture.runtimeRoots[0]; - if (runtime === undefined) - throw new Error("Fixture runtime was not captured"); - await access(runtime); - expect(cleanupAttempts).toBe(1); - expect(await client.close()).toMatchObject({ - ok: false, - error: { cleanupIncomplete: true }, - }); - cleanupAllowed = true; - expect(await client.close()).toEqual(ok(null)); - expect(cleanupAttempts).toBe(3); - await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" }); + const client = clientFor(launcher, { startupTimeoutMs: 10_000 }); + const controller = new AbortController(); + const started = client.start(controller.signal); + await withVerifierCleanup(async () => { + await awaitAcknowledgement( + cleanupStarted, + started, + "identity refusal cleanup", + ); + controller.abort("cancel during final cleanup"); + release?.(); + const result = await started; + if (result.ok) throw new Error("Expected the earlier identity refusal"); + expect(result.error.kind).toBe("admission"); + expect(result.error.cause).toMatchObject({ + code: "import_identity_mismatch", + }); + expect(client.isReady()).toBe(false); + await expect(access(fixture.runtimeRoots[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, [ + () => release?.(), + async () => { + await client.close(); + }, + ]); }); describe.skipIf(process.platform === "win32")( @@ -2370,42 +2474,50 @@ it.each([null, "caller stopped", new Error("caller stopped")])( }, ); -it("preserves a handshake protocol error observed before the final startup caller cancels", async () => { - const controller = new AbortController(); - let cancelled = false; - const logger = pino( - { - level: "warn", - hooks: { - logMethod(args, method) { - const fields = args[0]; - if ( - typeof fields === "object" && - fields !== null && - "method" in fields && - fields.method === "ping" && - !cancelled - ) { - cancelled = true; - controller.abort(null); - } - method.apply(this, args); +it.each([ + ["malformed", "protocol"], + ["import_identity_unavailable", "admission"], +] as const)( + "preserves %s observed before the final startup caller cancels", + async (mode, kind) => { + const controller = new AbortController(); + let cancelled = false; + const logger = pino( + { + level: "warn", + hooks: { + logMethod(args, method) { + const fields = args[0]; + if ( + typeof fields === "object" && + fields !== null && + "method" in fields && + fields.method === "ping" && + !cancelled + ) { + cancelled = true; + controller.abort(null); + } + method.apply(this, args); + }, }, }, - }, - { write: () => {} }, - ); - const launcher = new FixtureLauncher("malformed"); - const client = clientFor(launcher, { logger }); - const result = await client.start(controller.signal); - expect(cancelled).toBe(true); - if (result.ok) throw new Error("Expected handshake protocol failure"); - expect(result.error.kind).toBe("protocol"); - expect(await waitForExit(launcher.processes[0])).toBe(true); - await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ - code: "ENOENT", - }); -}); + { write: () => {} }, + ); + const launcher = new FixtureLauncher(mode); + const client = clientFor(launcher, { logger }); + const result = await client.start(controller.signal); + expect(cancelled).toBe(true); + if (result.ok) throw new Error("Expected handshake protocol failure"); + expect(result.error.kind).toBe(kind); + if (mode === "import_identity_unavailable") + expect(result.error.cause).toMatchObject({ code: mode }); + expect(await waitForExit(launcher.processes[0])).toBe(true); + await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); it("redacts the transport credential when refreshing startup diagnostics", async () => { let token = ""; diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index 5ef111d92b..22b8336b23 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -182,6 +182,40 @@ const handleRequest = (socket, server, request, state) => { handlePing(socket, request, state); return; } + if ( + ["import_identity_unavailable", "import_identity_mismatch"].includes( + state.mode, + ) && + request.method !== "shutdown" + ) { + socket.write( + `${JSON.stringify({ + id: request.id, + ok: false, + error: { + code: state.mode, + message: "Ghidra imported identity admission failed", + }, + })}\n`, + ); + return; + } + if ( + state.mode === "late_identity_failure" && + request.method === "list_strings" + ) { + socket.write( + `${JSON.stringify({ + id: request.id, + ok: false, + error: { + code: "import_identity_mismatch", + message: "Unexpected later failure", + }, + })}\n`, + ); + return; + } if ( state.mode === "old_oom_then_malformed" && request.method === "list_documents" && @@ -341,6 +375,23 @@ const handleRequest = (socket, server, request, state) => { }; const handlePing = (socket, request, state) => { + if ( + ["import_identity_unavailable", "import_identity_mismatch"].includes( + state.mode, + ) + ) { + socket.write( + `${JSON.stringify({ + id: request.id, + ok: false, + error: { + code: state.mode, + message: "Ghidra project identity admission failed", + }, + })}\n`, + ); + return; + } if (state.mode === "remote_startup_error") { socket.write( `${JSON.stringify({