diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 1c97f91bd1..ac7c81b734 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -179,6 +179,7 @@ public final class ReaGhidraBridge extends HeadlessScript { private List procedureInventory; private List stringInventory; private DecompInterface decompiler; + private boolean existingProject; // 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. */ @@ -199,6 +200,7 @@ public final class ReaGhidraBridge extends HeadlessScript { Path descriptorPath = Path.of(arguments[0]); checkpointDirectory = descriptorPath.toAbsolutePath().normalize().getParent(); SessionDescriptor descriptor = readDescriptor(descriptorPath); + existingProject = descriptor.existingProject; decompileTimeoutSeconds = descriptor.decompileTimeoutSeconds(); // Windows retains a native immutable bearer descriptor lease until // runtime_close; POSIX removes it after this read. Normal cleanup removes @@ -3737,7 +3739,7 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } - private static JsonObject callRelationshipCoverage( + private JsonObject callRelationshipCoverage( CallBoundaryObservation calls, Integer missingCallers) { JsonObject coverage = completeResolvedCallCoverage(); Integer omitted = calls == null ? null : calls.unrepresentedCalleeCalls(); @@ -3754,7 +3756,7 @@ public final class ReaGhidraBridge extends HeadlessScript { if (calls == null && missingCallers == null) directions.add("callers"); directions.add("callees"); coverage.add("limited_directions", directions); - return coverage; + return qualifyProjectCallCoverage(coverage, calls, missingCallers); } JsonArray limitations = new JsonArray(); if (callerLimitation != null) limitations.add(callerLimitation); @@ -3768,6 +3770,35 @@ public final class ReaGhidraBridge extends HeadlessScript { coverage.add("limitations", limitations); coverage.add("limited_directions", directions); } + return qualifyProjectCallCoverage(coverage, calls, missingCallers); + } + + private JsonObject qualifyProjectCallCoverage( + JsonObject coverage, CallBoundaryObservation calls, Integer missingCallers) { + if (!existingProject) return coverage; + String reason = "The selected project's prior analysis completeness is unknown; " + + "the recorded call relationships may omit undiscovered callers or callees."; + String status = coverage.get("status").getAsString(); + // A single-direction read with known omissions is still partial; an + // unknown project history adds a limitation rather than erasing them. + if (status.equals("partial") && (calls == null || missingCallers == null)) { + coverage.getAsJsonArray("limitations").add(reason); + return coverage; + } + if (status.equals("unknown")) reason += " " + coverage.get("reason").getAsString(); + if (status.equals("partial")) { + for (JsonElement limitation : coverage.getAsJsonArray("limitations")) + reason += " " + limitation.getAsString(); + } + JsonArray directions = new JsonArray(); + if (missingCallers != null || (calls == null && missingCallers == null)) + directions.add("callers"); + if (calls != null || (calls == null && missingCallers == null)) + directions.add("callees"); + coverage.addProperty("status", "unknown"); + coverage.addProperty("reason", reason); + coverage.remove("limitations"); + coverage.add("limited_directions", directions); return coverage; } diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 847edc1da1..4e094454bb 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1558,6 +1558,11 @@ For supported header-selected MIPS ELF targets, the profile also binds the selected target's ELF metadata; it does not claim a fresh-import MIPS lane was used for the saved project. A matching recorded import digest does not claim that the database is unedited. +Caller and callee coverage therefore remains unknown. A single-direction query +with known omissions remains partial and also reports the unknown project history. +Observed call edges are still available: a path to an observed caller or callee +can be reached, but a missing path is unknown when the project's prior analysis +cannot establish completeness. Saved REA seed/analyzer reports remain historical facts in retained source Evidence; they are not interpreted as overrides applied by this session. Project content participates in snapshot identity, so different project states diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index 587f7f9870..a3ec30a65e 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -45,6 +45,7 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build. | Ghidra lane | Supported runner/target | Additional local tools | | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 | +| `npm run verify:ghidra:project-coverage` | POSIX host; selected-project call coverage, real MCP, retained Evidence and an unexecuted host fixture | Host C compiler, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback, not-applicable and unknown analyzer names | Ghidra 12.1.4 and full JDK 21 | | `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:riscv` | Linux/macOS x64/arm64 host; source-owned RV32/RV64 double-float and RV32 ilp32 ELF through CLI and MCP | RISC-V Clang, LLD, LLVM readelf and objdump, Ghidra 12.1.4, and full JDK 21 | diff --git a/package.json b/package.json index 3d322f6e42..992d3af619 100644 --- a/package.json +++ b/package.json @@ -207,6 +207,7 @@ "verify:flutter": "npm run build:cached && node scripts/verify-flutter.mjs", "verify:adb": "npm run build:cached && node scripts/verify-adb.mjs", "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", + "verify:ghidra:project-coverage": "npm run build:cached && node scripts/verify-real-ghidra-project-coverage.mjs", "verify:ghidra:checkpoints": "npm run build:cached && node scripts/verify-real-ghidra-checkpoints.mjs", "verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs", "verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs", diff --git a/scripts/lib/verifier-mcp-owner.d.mts b/scripts/lib/verifier-mcp-owner.d.mts new file mode 100644 index 0000000000..2e3d36338b --- /dev/null +++ b/scripts/lib/verifier-mcp-owner.d.mts @@ -0,0 +1,21 @@ +import type { Client, Transport } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +/** Share an in-flight SDK close, including one started during failed initialization. */ +export class VerifierStdioTransport extends StdioClientTransport {} + +type VerifierConnection = { + client: Client; + transport: Transport; + definitions: Map< + string, + Awaited>["tools"][number] + >; +}; + +/** Own MCP transports from connection through discovery, use, and cleanup retry. */ +export function createVerifierMcpOwner(): { + open(client: Client, transport: Transport): Promise; + closeConnection(connection: VerifierConnection): Promise; + close(): Promise; +}; diff --git a/scripts/lib/verifier-mcp-owner.mjs b/scripts/lib/verifier-mcp-owner.mjs new file mode 100644 index 0000000000..6b6091e845 --- /dev/null +++ b/scripts/lib/verifier-mcp-owner.mjs @@ -0,0 +1,54 @@ +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { + closeVerifierResources, + withVerifierCleanup, +} from "./verifier-cleanup.mjs"; + +/** Share an in-flight SDK close, including one started during failed initialization. */ +export class VerifierStdioTransport extends StdioClientTransport { + #closing; + + close() { + if (this.#closing !== undefined) return this.#closing; + this.#closing = super.close().catch((error) => { + this.#closing = undefined; + throw error; + }); + return this.#closing; + } +} + +/** Own MCP transports from connection through discovery, use, and cleanup retry. */ +export function createVerifierMcpOwner() { + const owned = new Set(); + const closeConnection = async (connection) => { + await closeVerifierResources([ + () => connection.client.close(), + () => connection.transport.close(), + ]); + owned.delete(connection); + }; + return { + async open(client, transport) { + const connection = { client, transport, definitions: new Map() }; + owned.add(connection); + try { + await client.connect(transport); + connection.definitions = new Map( + (await client.listTools()).tools.map((tool) => [tool.name, tool]), + ); + return connection; + } catch (cause) { + return withVerifierCleanup(() => { + throw cause; + }, [() => closeConnection(connection)]); + } + }, + closeConnection, + close() { + return closeVerifierResources( + [...owned].map((connection) => () => closeConnection(connection)), + ); + }, + }; +} diff --git a/scripts/verify-real-ghidra-project-coverage.mjs b/scripts/verify-real-ghidra-project-coverage.mjs new file mode 100644 index 0000000000..3b7093d15b --- /dev/null +++ b/scripts/verify-real-ghidra-project-coverage.mjs @@ -0,0 +1,421 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + OwnedCommandFailure, + runOwnedCommand, +} from "../dist/process/OwnedCommand.js"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { Client } from "@modelcontextprotocol/client"; +import { createAnalysisProfile } from "../dist/domain/analysisProfile.js"; +import { createEvidenceBundle } from "../dist/domain/evidenceBundle.js"; +import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs"; +import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; +import { + createVerifierMcpOwner, + VerifierStdioTransport, +} from "./lib/verifier-mcp-owner.mjs"; +import { + requireMcpOutput, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; + +export async function verifyProjectCoverage(run) { + const installDir = process.env.GHIDRA_INSTALL_DIR; + if (installDir === undefined || !isAbsolute(installDir)) + throw new Error( + "Set GHIDRA_INSTALL_DIR to an absolute Ghidra installation path.", + ); + const installation = await inspectVerifierGhidraInstallation({ + environment: process.env, + installDir, + ...(process.env.JAVA_HOME === undefined + ? {} + : { javaHome: process.env.JAVA_HOME }), + }); + if (installation.status !== "available") + throw new Error(`Ghidra is unavailable: ${JSON.stringify(installation)}`); + if (process.platform === "win32") + throw new Error( + "REA selected Ghidra projects are supported on POSIX hosts.", + ); + const scriptRoot = fileURLToPath( + new URL("../tests/conformance/ghidra/", import.meta.url), + ); + const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url)); + const workspace = await mkdtemp( + join(tmpdir(), "rea-ghidra-project-coverage-"), + ); + const connections = createVerifierMcpOwner(); + const target = join(workspace, "fixture"); + const projects = join(workspace, "projects"); + const env = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + JAVA_HOME: installation.javaHome, + GHIDRA_INSTALL_DIR: installation.installDir, + REA_MCP_OUTPUT_SCHEMA_PROFILE: "compact", + REA_LOG_LEVEL: "silent", + REA_PROCESS_RUN_ID: run.run_id, + }; + const hash = (data) => createHash("sha256").update(data).digest("hex"); + async function manifest(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() + ? manifest(path) + : entry.isFile() + ? [[path, hash(await readFile(path))]] + : []; + }), + ); + return files.flat().sort((a, b) => a[0].localeCompare(b[0])); + } + async function connect(environment) { + const client = new Client({ + name: "selected-project-coverage-verifier", + version: "1", + }); + const transport = new VerifierStdioTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: environment, + stderr: "pipe", + }); + return connections.open(client, transport); + } + async function call(connection, name, args = {}) { + const reply = await connection.client.callTool( + { name, arguments: args }, + { timeout: 180000, toolDefinition: connection.definitions.get(name) }, + ); + return requireMcpOutput(reply, name); + } + let commandCleanupIncomplete = false; + const command = async (executable, args, timeoutMs) => { + try { + return await runOwnedCommand( + { + command: executable, + arguments: args, + env, + runId: run.run_id, + expectedCommand: null, + }, + { timeoutMs, diagnosticBytes: 16 * 1024 * 1024 }, + ); + } catch (cause) { + commandCleanupIncomplete = + cause instanceof OwnedCommandFailure && + cause.cleanupOwner !== undefined; + throw cause; + } + }; + const result = await withVerifierCleanup(async () => { + await mkdir(projects); + // REA_CC is the invoking developer's explicitly selected compiler, not target input. + await command( + process.env.REA_CC ?? "cc", + [ + "-O0", + "-g", + "-fno-inline", + "-fno-optimize-sibling-calls", + join(scriptRoot, "selected-project-coverage.c"), + "-o", + target, + ], + 120000, + ); + for (const name of ["partial", "analyzed"]) { + const args = [projects, name, "-import", target]; + if (name === "partial") + args.push( + "-noanalysis", + "-scriptPath", + scriptRoot, + "-postScript", + "ReaSelectedProjectSeeds.java", + ); + const result = await command( + installation.analyzeHeadlessPath, + args, + 180000, + ); + if (name === "partial") + for (const seed of [ + "rea_project_leaf", + "rea_project_caller", + "rea_project_other", + ]) + assert.match( + result.stdout.text, + new RegExp(`REA_PROJECT_SEED ${seed} `), + ); + } + const before = { + target: hash(await readFile(target)), + projects: await manifest(projects), + }; + const report = []; + for (const mode of ["fresh", "partial", "analyzed"]) { + const selected = mode !== "fresh"; + const connection = await connect({ + ...env, + ...(selected + ? { + REA_GHIDRA_PROJECT_PATH: join(projects, `${mode}.gpr`), + REA_GHIDRA_PROGRAM_NAME: "fixture", + } + : {}), + }); + const bundlePath = join(workspace, `${mode}.json`); + const paths = []; + await withVerifierCleanup(async () => { + await call(connection, "open_binary", { + path: target, + provider_id: "ghidra", + }); + const inventory = ( + await call(connection, "list_procedures", { + detail: "complete", + }) + ).normalized_result; + const entry = (name) => { + const matches = inventory.filter( + (item) => item.value === name || item.value === `_${name}`, + ); + assert.equal(matches.length, 1, `Expected one entry for ${name}`); + return matches[0].address; + }; + const leaf = entry("rea_project_leaf"), + caller = entry("rea_project_caller"), + other = entry("rea_project_other"); + const direct = await call(connection, "procedure_callees", { + procedure: caller, + }); + assert.equal( + direct.relationship_coverage.status, + selected ? "unknown" : "complete", + ); + assert.equal( + direct.normalized_result.includes(leaf), + mode !== "partial", + ); + assert.equal( + direct.analysis_profile.parameters + .selected_project_relationship_coverage, + selected ? "prior-analysis-unknown-v1" : undefined, + ); + for (const [start, goal, direction] of [ + [caller, leaf, "forward"], + [leaf, caller, "backward"], + [other, caller, "backward"], + ]) { + const evidence = await call(connection, "trace_call_path", { + start, + goal, + direction, + }); + paths.push(evidence); + } + assert.equal( + paths[0].normalized_result.goal_status, + mode === "partial" ? "unknown" : "reached", + ); + assert.equal( + paths[1].normalized_result.goal_status, + mode === "partial" ? "unknown" : "reached", + ); + assert.equal(paths[2].normalized_result.goal_status, "unknown"); + if (!selected) { + assert.equal( + paths[2].normalized_result.search_scope.observed_goal_status, + "not_reached", + ); + assert.equal( + paths[2].normalized_result.search_scope.discovery, + "unknown", + ); + } + if (selected) + for (const evidence of paths) + assert.ok( + Object.values( + evidence.normalized_result.relationship_coverage, + ).some( + (coverage) => + coverage.status === "unknown" && + /prior analysis completeness is unknown/u.test( + coverage.reason, + ), + ), + ); + if (mode === "analyzed") { + const dossier = await call(connection, "analyze_function", { + procedure: entry("rea_project_callback"), + detail: "complete", + }); + assert.equal(dossier.relationship_coverage.status, "unknown"); + assert.match( + dossier.relationship_coverage.reason, + /decoded call\(s\) without destinations/u, + ); + assert.match( + dossier.relationship_coverage.reason, + /prior analysis completeness is unknown/u, + ); + const callbackPath = await call(connection, "trace_call_path", { + start: entry("rea_project_callback"), + goal: leaf, + direction: "forward", + }); + const coverages = Object.values( + callbackPath.normalized_result.relationship_coverage, + ); + assert.ok( + coverages.some( + (coverage) => + coverage.status === "partial" && + coverage.limitations.some((text) => + /decoded call\(s\) without destinations/u.test(text), + ) && + coverage.limitations.some((text) => + /prior analysis completeness is unknown/u.test(text), + ), + ), + ); + } + await call(connection, "export_evidence_bundle", { path: bundlePath }); + if (mode === "partial") { + const snapshotPath = join(workspace, "partial-snapshot.json"); + await call(connection, "close_binary", { + snapshot_path: snapshotPath, + }); + const snapshot = JSON.parse(await readFile(snapshotPath, "utf8")); + assert.equal( + snapshot.binding.analysis_profile.parameters + .selected_project_relationship_coverage, + "prior-analysis-unknown-v1", + ); + const profile = snapshot.binding.analysis_profile; + const oldParameters = { ...profile.parameters }; + delete oldParameters.selected_project_relationship_coverage; + const stalePath = join(workspace, "partial-old-profile.json"); + await writeFile( + stalePath, + JSON.stringify({ + ...snapshot, + binding: { + ...snapshot.binding, + analysis_profile: createAnalysisProfile( + profile.provider, + oldParameters, + ), + }, + entries: [], + workflow_entries: [], + evidence_bundle: createEvidenceBundle([]), + }), + ); + const staleBefore = await readFile(stalePath); + const refused = await connection.client.callTool({ + name: "open_binary", + arguments: { + path: target, + provider_id: "ghidra", + snapshot_path: stalePath, + }, + }); + const error = requireMcpToolError(refused); + assert.equal(error.code, "evidence_integrity_mismatch"); + assert.deepEqual(error.details.binding_mismatch.profile_digest, { + snapshot: createAnalysisProfile(profile.provider, oldParameters) + .digest, + selected: profile.digest, + }); + assert.deepEqual(await readFile(stalePath), staleBefore); + await call(connection, "open_binary", { + path: target, + provider_id: "ghidra", + snapshot_path: snapshotPath, + }); + const replay = await call(connection, "trace_call_path", { + start: caller, + goal: leaf, + direction: "forward", + }); + assert.deepEqual( + replay.normalized_result, + paths[0].normalized_result, + ); + await call(connection, "close_binary"); + } else await call(connection, "close_binary"); + }, [() => connections.closeConnection(connection)]); + const offline = await connect(env); + await withVerifierCleanup(async () => { + await call(offline, "import_evidence_bundle", { path: bundlePath }); + const records = ( + await call(offline, "get_evidence_bundle", { detail: "complete" }) + ).result.records; + for (const evidence of paths) { + const saved = records.find( + (record) => record.evidence_id === evidence.evidence_id, + ); + assert.ok(saved); + assert.deepEqual(saved.normalized_result, evidence.normalized_result); + assert.deepEqual(saved.limitations, evidence.limitations); + } + }, [() => connections.closeConnection(offline)]); + report.push({ + mode, + statuses: paths.map( + (evidence) => evidence.normalized_result.goal_status, + ), + }); + } + assert.deepEqual( + { + target: hash(await readFile(target)), + projects: await manifest(projects), + }, + before, + ); + return { + ok: true, + provider: installation.providerVersion, + results: report, + }; + }, [ + async () => { + await connections.close(); + if (!commandCleanupIncomplete) + await rm(workspace, { recursive: true, force: true }); + }, + ]); + return result; +} + +if ( + process.argv[1] !== undefined && + realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const run = createVerifierRun(); + const report = await verifyProjectCoverage(run); + await completeVerifierRun(run); + process.stdout.write(`${JSON.stringify(report)}\n`); +} diff --git a/scripts/verify-real-ghidra.mjs b/scripts/verify-real-ghidra.mjs index 3babee47d8..15323f9a1c 100644 --- a/scripts/verify-real-ghidra.mjs +++ b/scripts/verify-real-ghidra.mjs @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { verifyProjectCoverage } from "./verify-real-ghidra-project-coverage.mjs"; + import { execFile } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -320,6 +322,10 @@ try { const customPath = process.env.GHIDRA_TARGET_PATH; const custom = customPath === undefined ? null : await verifyTarget(customPath, "custom"); + const projectCoverage = + !crossFormat && !aarch64JumpTableOnly && process.platform !== "win32" + ? await verifyProjectCoverage(verifierRun) + : null; report = { ok: true, provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION }, @@ -340,6 +346,7 @@ try { ? (crossResults[0]?.native_api_cli ?? null) : jumpTable.native_api_cli, native_value_e2e: debug.native_values, + selected_project_coverage: projectCoverage, relocation_symbols: relocationSymbols, custom_target: custom === null ? null : summary(custom), cleanup: "complete", diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index d1fad973ea..f130e8b48a 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -486,6 +486,7 @@ export const existingGhidraAnalysisProfile = ( ...(mipsElf === undefined ? {} : { mips_elf: mipsElf }), import_mode: "existing-project-private-copy-v1", ...sharedAnalysisSemantics, + selected_project_relationship_coverage: "prior-analysis-unknown-v1", annotation_policy: "read-only-existing-project-v1", existing_project: project, original_analysis: { completeness: "unknown", settings: "unknown" }, diff --git a/tests/boundary/process/verifierMcpOwner.test.ts b/tests/boundary/process/verifierMcpOwner.test.ts new file mode 100644 index 0000000000..d44c752744 --- /dev/null +++ b/tests/boundary/process/verifierMcpOwner.test.ts @@ -0,0 +1,74 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client, type Transport } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; +import { + createVerifierMcpOwner, + VerifierStdioTransport, +} from "../../../scripts/lib/verifier-mcp-owner.mjs"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each(["initialize", "discovery"])( + "closes the real MCP child when %s fails before ownership transfers", + async (mode) => { + const workspace = await createTestTempDirectory("rea-verifier-mcp-owner-"); + const marker = join(workspace, "exit.txt"); + const client = new Client({ name: "verifier-owner-test", version: "1" }); + const transport = new VerifierStdioTransport({ + command: process.execPath, + args: [ + fileURLToPath( + new URL("../../fixtures/verifier-mcp-failure.mjs", import.meta.url), + ), + ], + env: { + PATH: process.env.PATH ?? "", + REA_TEST_EXIT_MARKER: marker, + REA_TEST_FAILURE_MODE: mode, + }, + stderr: "pipe", + }); + const owner = createVerifierMcpOwner(); + try { + await expect(owner.open(client, transport)).rejects.toThrow(); + expect(await readFile(marker, "utf8")).toBe("exited"); + } finally { + await owner.close(); + } + }, +); + +it("retains an owner after failed cleanup and retries every close", async () => { + const failure = new Error("discovery failed"); + const cleanupFailure = new Error("client close failed"); + let clientCloses = 0; + let transportCloses = 0; + const client = { + connect: async () => {}, + listTools: async () => { + throw failure; + }, + close: async () => { + if (++clientCloses === 1) throw cleanupFailure; + }, + } as unknown as Client; + const transport = { + close: async () => { + transportCloses++; + }, + } as unknown as Transport; + const owner = createVerifierMcpOwner(); + let thrown: unknown; + try { + await owner.open(client, transport); + } catch (cause) { + thrown = cause; + } + expect(thrown).toBeInstanceOf(AggregateError); + expect((thrown as AggregateError).cause).toBe(failure); + expect(transportCloses).toBe(1); + await owner.close(); + expect(clientCloses).toBe(2); + expect(transportCloses).toBe(2); +}); diff --git a/tests/conformance/ghidra/ReaSelectedProjectSeeds.java b/tests/conformance/ghidra/ReaSelectedProjectSeeds.java new file mode 100644 index 0000000000..821fa31d70 --- /dev/null +++ b/tests/conformance/ghidra/ReaSelectedProjectSeeds.java @@ -0,0 +1,26 @@ +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.address.AddressSet; +import ghidra.program.model.listing.Function; +import ghidra.program.model.symbol.SourceType; +import ghidra.program.model.symbol.Symbol; +import java.util.List; + +// Create addressable entries without decoding their instructions or analyzing calls. +public class ReaSelectedProjectSeeds extends GhidraScript { + @Override public void run() throws Exception { + for (String name : List.of("rea_project_leaf", "rea_project_caller", "rea_project_other")) { + List symbols = getSymbols(name, null); + if (symbols.isEmpty()) symbols = getSymbols("_" + name, null); + if (symbols.size() != 1) throw new IllegalStateException("Expected one symbol for " + name); + Address entry = symbols.get(0).getAddress(); + Function function = getFunctionAt(entry); + if (function == null) + function = currentProgram.getFunctionManager().createFunction( + symbols.get(0).getName(), entry, new AddressSet(entry, entry), SourceType.USER_DEFINED); + if (currentProgram.getListing().getInstructions(function.getBody(), true).hasNext()) + throw new IllegalStateException("Expected an unanalyzed function: " + name); + println("REA_PROJECT_SEED " + name + " " + entry); + } + } +} diff --git a/tests/conformance/ghidra/selected-project-coverage.c b/tests/conformance/ghidra/selected-project-coverage.c new file mode 100644 index 0000000000..2ca5904ab0 --- /dev/null +++ b/tests/conformance/ghidra/selected-project-coverage.c @@ -0,0 +1,14 @@ +volatile int rea_project_global = 7; +__attribute__((noinline, used)) int rea_project_leaf(int x) { + return x + rea_project_global; +} +__attribute__((noinline, used)) int rea_project_caller(int x) { + return rea_project_leaf(x); +} +__attribute__((noinline, used)) int rea_project_other(int x) { + return x - rea_project_global; +} +__attribute__((noinline, used)) int rea_project_callback(int (*fn)(int), int x) { + return fn(x) + rea_project_global; +} +int main(void) { return rea_project_caller(rea_project_global); } diff --git a/tests/fixtures/verifier-mcp-failure.mjs b/tests/fixtures/verifier-mcp-failure.mjs new file mode 100644 index 0000000000..fe79bce591 --- /dev/null +++ b/tests/fixtures/verifier-mcp-failure.mjs @@ -0,0 +1,36 @@ +import { writeFileSync } from "node:fs"; +import { createInterface } from "node:readline"; + +const marker = process.env.REA_TEST_EXIT_MARKER; +const mode = process.env.REA_TEST_FAILURE_MODE; +if (!marker || !mode) throw new Error("Missing verifier fixture configuration"); +const keepAlive = setInterval(() => {}, 1000); +// The test harness selects its own temporary exit marker. +process.on("exit", () => writeFileSync(marker, "exited")); +process.stdin.on("end", () => clearInterval(keepAlive)); +const input = createInterface({ input: process.stdin }); +input.on("line", (line) => { + const request = JSON.parse(line); + if (request.id === undefined) return; + if (request.method === "initialize" && mode !== "initialize") { + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + result: { + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: "verifier-failure-fixture", version: "1" }, + }, + })}\n`, + ); + return; + } + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + error: { code: -32603, message: `intentional ${mode} failure` }, + })}\n`, + ); +});