From c374c2fe7290b914d73d9e27af38b498753ec593 Mon Sep 17 00:00:00 2001 From: Syed Rishad Date: Tue, 20 Oct 2026 08:08:08 -0400 Subject: [PATCH] fix(ghidra): preserve partial dossiers on decompiler failure (#4650) Preserve listing-backed facts and explicit unavailable decompiler facets when one function fails. Retain Evidence while excluding transient failures and derived workflows from snapshot reuse; keep healthy cache reuse and owned cleanup semantics. Fixes #4644. --- bridge/ghidra/ReaGhidraBridge.java | 60 +++- docs/mcp-contracts.md | 8 +- knip.json | 1 + .../verify-real-ghidra-decompiler-failure.mjs | 282 ++++++++++++++++++ src/application/AnalysisProvider.ts | 6 + src/application/DirectAnalysis.ts | 22 +- src/application/WorkflowEvidence.test.ts | 48 +++ src/application/WorkflowEvidence.ts | 14 +- src/application/binary/BinarySession.ts | 15 +- src/ghidra/GhidraFunctionValues.test.ts | 23 ++ src/ghidra/GhidraFunctionValues.ts | 8 + src/ghidra/GhidraProvider.behavior.test.ts | 46 +++ src/ghidra/GhidraProviderClient.ts | 8 + src/ghidra/GhidraSnapshotCache.test.ts | 63 ++++ src/server/registerEnhancedTools.ts | 6 +- tests/boundary/mcp/ghidraNativeApiMcp.test.ts | 54 ++++ .../ghidraDecompilerVerifierCleanup.test.ts | 112 +++++++ tests/conformance/ghidra/README.md | 13 + .../ghidra/ReaDecompilerFailureProbe.java | 213 +++++++++++++ 19 files changed, 971 insertions(+), 31 deletions(-) create mode 100644 scripts/verify-real-ghidra-decompiler-failure.mjs create mode 100644 tests/boundary/process/ghidraDecompilerVerifierCleanup.test.ts create mode 100644 tests/conformance/ghidra/ReaDecompilerFailureProbe.java diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 2438471c63..35a0902dfa 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -458,6 +458,7 @@ public final class ReaGhidraBridge extends HeadlessScript { FunctionAnalysisObservation observation = analyzeFunctionObservation(request.params); JsonObject result = functionObservation(observation.dossier(), observation.limitations()); result.add("relationship_coverage", observation.relationshipCoverage()); + if (observation.decompilerFailed()) result.addProperty("cache_policy", "live"); yield result; } case "set_address_name", "set_addresses_names" -> { @@ -1540,12 +1541,23 @@ public final class ReaGhidraBridge extends HeadlessScript { requireKeys(params, Set.of("procedure")); Function function = resolveProcedure(requireString(params, "procedure")); InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE); - DecompileResults decompilation = decompile(function); + DecompileResults decompilation; + String decompilerFailure = null; + try { + decompilation = decompile(function); + } catch (RequestFailure failure) { + // The listing, reference and CFG facts below do not depend on the + // native decompiler. Cancellation still ends the request. + if (!failure.code.equals("decompile_failed")) throw failure; + decompilation = null; + decompilerFailure = failure.getMessage().strip(); + } String pseudocode = decompiledText(decompilation); JsonObject availability = new JsonObject(); availability.add("pseudocode", facetAvailability( pseudocode != null, - function.isExternal() ? "Ghidra does not decompile external functions." + decompilerFailure != null ? decompilerFailure + : function.isExternal() ? "Ghidra does not decompile external functions." : function.getBody().isEmpty() ? "Ghidra has no function body to decompile." : "Ghidra completed decompilation without returning pseudocode." )); @@ -1594,9 +1606,16 @@ public final class ReaGhidraBridge extends HeadlessScript { result.add("basic_blocks", blocks); result.add( "native_api", - nativeApiBoundary(function, decompilation) + decompilerFailure == null + ? nativeApiBoundary(function, decompilation) + : unavailableNativeApiBoundary(decompilerFailure) + ); + result.add( + "native_value_flow", + decompilerFailure == null + ? nativeValueFlow(decompilation) + : unavailableNativeValueFlow(decompilerFailure) ); - result.add("native_value_flow", nativeValueFlow(decompilation)); // Successful full queries distinguish collected empty sets from missing facts. // Availability does not claim that Ghidra resolved every runtime relationship. for (String facet : List.of("comments", "callers", "callees", "references", "strings_names", "cfg")) { @@ -1643,6 +1662,12 @@ public final class ReaGhidraBridge extends HeadlessScript { !decompilation.getErrorMessage().isBlank()) { limitations.add("Ghidra decompiler diagnostic: " + decompilation.getErrorMessage()); } + if (decompilerFailure != null) { + limitations.add( + decompilerFailure + (decompilerFailure.endsWith(".") ? " " : ". ") + + "Pseudocode, the native API boundary and p-code value flow are unavailable for this procedure; assembly, comments, callers, callees, references, strings, names and basic blocks come from the Ghidra program listing and were still collected." + ); + } result.add("limitations", limitations); monitor.checkCancelled(); int missingCallers = 0; @@ -1651,25 +1676,16 @@ public final class ReaGhidraBridge extends HeadlessScript { if (unrepresentedCaller(reference, callerFunctions)) missingCallers++; } return new FunctionAnalysisObservation(result, calls.limitations(), - callRelationshipCoverage(calls, missingCallers)); + callRelationshipCoverage(calls, missingCallers), decompilerFailure != null); } private JsonObject nativeValueFlow(DecompileResults decompilation) throws Exception { HighFunction highFunction = decompilation == null ? null : decompilation.getHighFunction(); if (highFunction == null) { - JsonObject unavailable = new JsonObject(); - unavailable.addProperty("available", false); - unavailable.addProperty( - "reason", + return unavailableNativeValueFlow( "Ghidra has no HighFunction p-code model for this procedure" ); - JsonArray limitations = new JsonArray(); - limitations.add( - "No p-code operations or def-use relationships were recovered for this procedure." - ); - unavailable.add("limitations", limitations); - return unavailable; } List selected = new ArrayList<>(); @@ -2357,6 +2373,18 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } + private static JsonObject unavailableNativeValueFlow(String reason) { + JsonObject unavailable = new JsonObject(); + unavailable.addProperty("available", false); + unavailable.addProperty("reason", reason); + JsonArray limitations = new JsonArray(); + limitations.add( + "No p-code operations or def-use relationships were recovered for this procedure." + ); + unavailable.add("limitations", limitations); + return unavailable; + } + private static JsonObject unavailableNativeApiBoundary(String reason) { JsonObject unavailable = new JsonObject(); unavailable.addProperty("available", false); @@ -4875,7 +4903,7 @@ public final class ReaGhidraBridge extends HeadlessScript { private record InventoryItem(Address address, String value, JsonObject facts) {} private record AddressNameRequest(String inputAddress, Address address, String name) {} private record FunctionEntry(Function function, InventoryItem item) {} - private record FunctionAnalysisObservation(JsonObject dossier, List limitations, JsonObject relationshipCoverage) {} + private record FunctionAnalysisObservation(JsonObject dossier, List limitations, JsonObject relationshipCoverage, boolean decompilerFailed) {} private record CallBoundaryObservation(List limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls) {} private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {} private record SwiftLiteralLayout( diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index c6e4d876d4..8b36a61423 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -71,7 +71,13 @@ Call comparison additionally retains the Evidence relationship coverage checks. Ghidra and Hopper declare their observed dossier facets and complete provider-observed caller/callee sets, including empty sets. Missing decompiler output remains unavailable; complete call coverage does not claim every runtime -target. Hopper's unavailable reference-kind metadata and reported unresolved +target. When Ghidra's decompiler fails on one function, its dossier is still +returned: `pseudocode`, `native_api` and `native_value_flow` are unavailable +with the decompiler's failure as the reason, while the listing-backed facets +are collected as usual. These failed decompiler observations and workflows +derived from them retain Evidence but skip snapshot reuse, so a later request +can observe backend recovery; healthy dossiers keep their existing cache policy. +`procedure_pseudo_code` still fails for that function. Hopper's unavailable reference-kind metadata and reported unresolved calls still prevent equality conclusions for those dimensions. Analysis profiles version these declarations so older snapshots are not reused as current results. IDA declares the facets its selected adapter cannot provide; an empty diff --git a/knip.json b/knip.json index acea26bd11..c97d15a675 100644 --- a/knip.json +++ b/knip.json @@ -11,6 +11,7 @@ "scripts/verify/fridaBindingsCompatibility.ts", "scripts/verify/termux/browser.mjs", "scripts/verify-real-ghidra-noreturn.mjs", + "scripts/verify-real-ghidra-decompiler-failure.mjs", "tests/processOwnershipRunner.ts" ], "project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"], diff --git a/scripts/verify-real-ghidra-decompiler-failure.mjs b/scripts/verify-real-ghidra-decompiler-failure.mjs new file mode 100644 index 0000000000..e6455fa113 --- /dev/null +++ b/scripts/verify-real-ghidra-decompiler-failure.mjs @@ -0,0 +1,282 @@ +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 { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; +import { parseConfig } from "../dist/config/parseConfig.js"; +import { GhidraClient } from "../dist/ghidra/GhidraClient.js"; +import { parseGhidraFunctionResult } from "../dist/ghidra/GhidraFunctionValues.js"; +import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs"; +import { SUPPORTED_GHIDRA_VERSION } from "../dist/ghidra/GhidraReleasePolicy.js"; +import { GhidraProvider } from "../dist/ghidra/GhidraProvider.js"; +import { silentLogger } from "../dist/logger.js"; +import { cleanupOwnedProcessGroup } from "../dist/process/ProcessOwnership.js"; +import { + ProviderProcessSupervisor, + spawnOwnedProviderProcess, +} from "../dist/process/ProviderProcess.js"; +import { + OwnedCommandFailure, + runOwnedCommand, +} from "../dist/process/OwnedCommand.js"; +import { + assertVerifierCleanupComplete, + completeVerifierRun, + createVerifierRun, +} from "./lib/verifier-run.mjs"; + +const run = createVerifierRun(); +assert.ok( + process.platform === "linux" || process.platform === "darwin", + "This regression lane requires Linux or macOS", +); +assert.ok( + process.arch === "x64" || process.arch === "arm64", + "This regression lane requires x86-64 or arm64", +); +const config = parseConfig(process.env); +if (!config.ok) throw config.error; +const installation = await inspectVerifierGhidraInstallation({ + environment: process.env, + installDir: config.value.ghidraInstallDir, + javaHome: config.value.ghidraJavaHome, +}); +assert.equal(installation.status, "available", JSON.stringify(installation)); +assert.equal(installation.providerVersion, SUPPORTED_GHIDRA_VERSION); +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-")); +let cleaned = true; +let report; +const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const injected = "Ghidra decompilation failed: Injected backend failure"; +try { + const targetPath = join( + workspace, + process.platform === "darwin" ? "cross-format.dylib" : "cross-format.so", + ); + const compiler = { + command: process.env.REA_CC ?? "cc", + arguments: [ + ...(process.platform === "darwin" ? ["-dynamiclib"] : ["-shared"]), + "-fPIC", + "-O0", + // Bind this source-owned fixture's calls to its own definitions on ELF. + ...(process.platform === "linux" ? ["-Wl,-Bsymbolic"] : []), + join(fixtures, "cross-format.c"), + "-o", + targetPath, + ], + }; + cleaned = false; + try { + await runOwnedCommand( + { + ...compiler, + runId: run.run_id, + expectedCommand: null, + }, + { timeoutMs: 60_000, diagnosticBytes: 1024 * 1024 }, + ); + cleaned = true; + } catch (cause) { + cleaned = + cause instanceof OwnedCommandFailure && cause.cleanupOwner === undefined; + throw cause; + } + const targetDigest = digest(await readFile(targetPath)); + const bridgeDigest = digest( + await readFile(join(bridge, "ReaGhidraBridge.java")), + ); + const project = join(workspace, "project"); + await mkdir(project); + cleaned = false; + const launch = await spawnOwnedProviderProcess({ + command: installation.analyzeHeadlessPath, + arguments: [ + project, + "DecompilerFailure", + "-import", + targetPath, + "-readOnly", + "-deleteProject", + "-scriptPath", + `${fixtures};${bridge}`, + "-postScript", + join(fixtures, "ReaDecompilerFailureProbe.java"), + bridge, + ], + runId: run.run_id, + expectedCommand: null, + }); + const supervisor = new ProviderProcessSupervisor({ + ...launch, + ownsProcessLifetime: true, + cleanup: () => cleanupOwnedProcessGroup(launch.ownership), + }); + let probe; + try { + assert.ok( + await supervisor.waitForExit(180000), + "Decompiler failure probe timed out", + ); + assert.ok( + await supervisor.waitForOutputClose(1000), + "Decompiler failure probe output did not close", + ); + const snapshot = supervisor.snapshot(); + const output = `${snapshot.stdout.text}\n${snapshot.stderr.text}`; + assert.equal(snapshot.exitCode, 0, output); + const reports = [ + ...output.matchAll(/REA_DECOMPILER_FAILURE_PROBE_JSON (\{[^\r\n]*\})/gu), + ]; + assert.equal(reports.length, 1, output); + probe = JSON.parse(reports[0][1]); + assert.equal(probe.status, "passed"); + assert.equal(probe.ghidra_version, SUPPORTED_GHIDRA_VERSION); + assert.equal(probe.target_sha256, targetDigest); + assert.equal(probe["ReaGhidraBridge.java"], bridgeDigest); + for (const check of [ + "pseudocode_unavailable_with_failure", + "native_api_unavailable_with_failure", + "native_value_flow_unavailable_with_failure", + "listing_facet_unchanged_assembly", + "listing_facet_unchanged_callers", + "listing_facet_unchanged_callees", + "listing_facet_unchanged_basic_blocks", + "relationship_coverage_unchanged", + "failure_limitation_named", + "pseudocode_request_still_fails", + "cancellation_still_ends_request", + "session_recovers_after_failure", + ]) + assert.ok(probe.checks.includes(check), check); + } finally { + const stopped = await supervisor.stop(); + cleaned = stopped.status === "verified-cleanup"; + assert.ok( + cleaned, + `Owned cleanup incomplete; retained ${workspace}: ${JSON.stringify(stopped)}`, + ); + } + + // The degraded dossier must satisfy the same provider output contract REA + // applies to every bridge reply. + const degraded = parseGhidraFunctionResult( + "analyze_function", + probe.degraded, + ); + if (!degraded.ok) throw degraded.error; + assert.equal(degraded.value.cachePolicy, "live"); + const dossier = degraded.value.value; + assert.deepEqual(dossier.facet_availability.pseudocode, { + available: false, + reason: injected, + }); + assert.equal(dossier.pseudocode, ""); + assert.equal(dossier.native_api.available, false); + assert.equal(dossier.native_value_flow.available, false); + assert.ok(dossier.assembly.length > 0); + assert.ok(dossier.callers.length > 0 && dossier.callees.length > 0); + const baseline = parseGhidraFunctionResult( + "analyze_function", + probe.baseline, + ); + if (!baseline.ok) throw baseline.error; + assert.equal(baseline.value.cachePolicy, undefined); + assert.deepEqual(dossier.basic_blocks, baseline.value.value.basic_blocks); + + // The healthy path through the real provider and authenticated transport + // keeps its pseudocode and native API boundary. + const provider = new GhidraProvider( + config.value, + silentLogger, + process.env, + undefined, + (options) => + new GhidraClient({ ...options, transport: "authenticated-loopback-tcp" }), + ); + const target = await parseBinaryTarget(targetPath); + if (!target.ok) throw target.error; + const profile = await provider.resolveAnalysisProfile(target.value); + if (!profile.ok) throw profile.error; + assert.ok(profile.value.profile); + const client = provider.createClient(target.value, profile.value.profile); + cleaned = false; + const failures = []; + let healthy; + try { + const result = await client.execute("analyze_function", { + procedure: dossier.procedure.address, + }); + if (!result.ok) throw result.error; + const value = result.value.result; + assert.deepEqual(value.facet_availability.pseudocode, { available: true }); + assert.equal(value.native_api.available, true); + assert.ok( + !result.value.limitations.some((item) => item.includes(injected)), + ); + healthy = { + pseudocode_available: true, + native_api_available: value.native_api.available, + }; + } catch (cause) { + failures.push(cause); + } + const closed = await client.close(); + cleaned = closed.ok; + if (!closed.ok) failures.push(closed.error); + if (failures.length > 0) + throw new AggregateError( + failures, + "Decompiler failure provider verification failed", + ); + assert.equal(digest(await readFile(targetPath)), targetDigest); + cleaned = false; + const verifierRun = await completeVerifierRun(run); + try { + assertVerifierCleanupComplete(verifierRun.process_lineage); + } catch (cause) { + throw new Error( + `Verifier cleanup unresolved; retained ${workspace}; run ${run.run_id}`, + { cause }, + ); + } + cleaned = true; + report = { + status: "passed", + target_sha256: targetDigest, + source_sha256: digest(await readFile(join(fixtures, "cross-format.c"))), + compiler, + bridge_sha256: bridgeDigest, + provider: profile.value.profile.provider, + analysis_profile_digest: profile.value.profile.digest, + checks: probe.checks, + degraded: { + pseudocode: dossier.facet_availability.pseudocode, + native_api_available: dossier.native_api.available, + native_value_flow_available: dossier.native_value_flow.available, + assembly_lines: dossier.assembly.length, + callers: dossier.callers.length, + callees: dossier.callees.length, + basic_blocks: dossier.basic_blocks.length, + }, + healthy, + verifier_run: verifierRun, + 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, + cleanup: "complete", + }), +); diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index ebdf279365..397d5069f0 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -55,6 +55,8 @@ export interface AnalysisExecution { readonly rawResult: JsonValue | null; readonly provider: ProviderIdentity; readonly analysisProfile?: AnalysisProfileCommitment; + /** Result-specific veto of snapshot replay; Evidence remains retainable. */ + readonly cachePolicy?: "live"; readonly limitations: readonly string[]; /** Provider-owned completeness assessment for caller/callee observations. */ readonly relationshipCoverage?: RelationshipCoverage; @@ -72,6 +74,7 @@ export interface AnalysisExecution { interface CreateAnalysisExecutionOptions { readonly rawResult?: unknown; readonly analysisProfile?: AnalysisProfileCommitment; + readonly cachePolicy?: "live"; readonly limitations?: readonly string[]; readonly relationshipCoverage?: RelationshipCoverage; readonly referencesTruncated?: boolean; @@ -108,6 +111,9 @@ export function createAnalysisExecution( ? null : jsonValueSchema.parse(options.rawResult), provider, + ...(options.cachePolicy === undefined + ? {} + : { cachePolicy: options.cachePolicy }), ...(options.analysisProfile === undefined ? {} : { analysisProfile: structuredClone(options.analysisProfile) }), diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 2e3b3ca74d..99ce97d8dd 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -332,7 +332,7 @@ const runAnalysis = async ( : await session.openResolvedTarget(resolvedTarget, openOptions); if (!opened.ok) return cliError(opened.error); const evidenceProfile = analysisProfileForEvidence(session, tool); - const { output, evidence } = await executeAnalysisTool({ + const { output, evidence, cachePolicy } = await executeAnalysisTool({ session, openedTarget: opened.value, tool, @@ -362,7 +362,11 @@ const runAnalysis = async ( evidence !== undefined && session.allowsSnapshotReplay(tool) ) { - const workflowRecord = workflowSnapshotRecord(evidence, tool); + const workflowRecord = workflowSnapshotRecord( + evidence, + tool, + cachePolicy, + ); if (workflowRecord !== undefined) { const recorded = session.recordWorkflowSnapshot(workflowRecord); if (!recorded.ok) @@ -408,7 +412,11 @@ const executeAnalysisTool = async (input: { readonly arguments: Readonly>; readonly signal: AbortSignal; readonly evidenceProfile: AnalysisProfileCommitment | undefined; -}): Promise<{ readonly output: JsonValue; readonly evidence?: Evidence }> => { +}): Promise<{ + readonly output: JsonValue; + readonly evidence?: Evidence; + readonly cachePolicy?: "live"; +}> => { const { session, tool, signal, evidenceProfile } = input; if (tool === "analyze_function") { const { executeFunctionAnalysisEvidence } = @@ -444,7 +452,13 @@ const executeAnalysisTool = async (input: { upstreamProfile: evidenceProfile, upstreamLimitations: result.value.limitations, }); - return { output: evidence, evidence }; + return { + output: evidence, + evidence, + ...(result.value.cachePolicy === undefined + ? {} + : { cachePolicy: result.value.cachePolicy }), + }; } let result = await session.execute(tool, input.arguments, { signal }); if ( diff --git a/src/application/WorkflowEvidence.test.ts b/src/application/WorkflowEvidence.test.ts index d2346411fc..8234a82810 100644 --- a/src/application/WorkflowEvidence.test.ts +++ b/src/application/WorkflowEvidence.test.ts @@ -4,6 +4,7 @@ import { recordWorkflowUnknowns, createWorkflowEvidence, executeComposedWorkflow, + workflowSnapshotRecord, } from "./WorkflowEvidence.js"; import { InvestigationRecords } from "./investigation/InvestigationRecords.js"; import { UnknownRegistryError } from "../domain/unknownRegistryError.js"; @@ -17,6 +18,53 @@ import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js"; import { projectNativeApiInspection } from "./native/NativeApiInspection.js"; import { functionDossierSchema } from "../domain/hopperValues.js"; import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js"; +import { ANALYSIS_SNAPSHOT_PROFILE } from "../domain/analysisSnapshot.fixture.js"; + +it.each([false, true])( + "propagates transient=%s through composed snapshot decisions while retaining Evidence", + async (transient) => { + let calls = 0; + const composed = await executeComposedWorkflow( + { + execute: async () => + ok( + createAnalysisExecution( + "observed", + provider, + transient && calls++ === 0 + ? { cachePolicy: "live" as const } + : {}, + ), + ), + }, + async (observed) => { + await observed.execute("analyze_function", { procedure: "0x1000" }); + const last = await observed.execute("procedure_info", { + procedure: "0x1000", + }); + return last.ok ? ok(last.value.result) : last; + }, + ); + if (!composed.ok) throw composed.error; + const evidence = createWorkflowEvidence({ + target: undefined, + operation: "inspect_native_api", + parameters: {}, + result: composed.value.result, + upstreamProfile: ANALYSIS_SNAPSHOT_PROFILE, + upstreamLimitations: composed.value.limitations, + }); + expect(new InvestigationRecords().recordEvidence(evidence).ok).toBe(true); + expect( + workflowSnapshotRecord( + evidence, + "inspect_native_api", + composed.value.cachePolicy, + ) === undefined, + ).toBe(transient); + expect(evidence.normalized_result).toBe("observed"); + }, +); const provider = { id: "fixture", name: "Fixture", version: "1" }; diff --git a/src/application/WorkflowEvidence.ts b/src/application/WorkflowEvidence.ts index f707ae8092..b01a98bc4b 100644 --- a/src/application/WorkflowEvidence.ts +++ b/src/application/WorkflowEvidence.ts @@ -38,14 +38,21 @@ export const executeComposedWorkflow = async ( ) => Promise>, ): Promise< Result< - { readonly result: JsonValue; readonly limitations: readonly string[] }, + { + readonly result: JsonValue; + readonly limitations: readonly string[]; + readonly cachePolicy?: "live"; + }, AnalysisError > > => { const limitations = new Map(); + let cachePolicy: "live" | undefined; const result = await execute({ execute: async (operation, parameters, options) => { const execution = await analysis.execute(operation, parameters, options); + if (execution.ok && execution.value.cachePolicy === "live") + cachePolicy = "live"; if (execution.ok) for (const limitation of execution.value.limitations) { const fact = { @@ -62,6 +69,7 @@ export const executeComposedWorkflow = async ( return ok({ result: result.value, limitations: renderSourceLimitations([...limitations.values()]), + ...(cachePolicy === undefined ? {} : { cachePolicy }), }); }; @@ -125,8 +133,10 @@ const batchProcedureLocations = (result: JsonValue): EvidenceLocation[] => { export const workflowSnapshotRecord = ( evidence: Evidence, operation: AnalysisOperation, + cachePolicy?: "live", ): WorkflowSnapshotRecordInput | undefined => { - if (evidence.analysis_profile === null) return undefined; + if (evidence.analysis_profile === null || cachePolicy === "live") + return undefined; return { operation, parameters: evidence.parameters, diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 2ce4bc1524..b6af1d4a7d 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -788,13 +788,14 @@ export class BinarySession ); const recorded = this.recordEvidence(evidence); if (!recorded.ok) return recorded; - this.recordSnapshot({ - target: active.target, - profile, - operation: name, - parameters: arguments_, - execution: execution.value, - }); + if (execution.value.cachePolicy !== "live") + this.recordSnapshot({ + target: active.target, + profile, + operation: name, + parameters: arguments_, + execution: execution.value, + }); } return execution; } diff --git a/src/ghidra/GhidraFunctionValues.test.ts b/src/ghidra/GhidraFunctionValues.test.ts index 6ee0d39fcd..fbc5bc7a65 100644 --- a/src/ghidra/GhidraFunctionValues.test.ts +++ b/src/ghidra/GhidraFunctionValues.test.ts @@ -65,6 +65,29 @@ describe("Ghidra unresolved call locations", () => { }); describe("Ghidra function-analysis result values", () => { + it.each(["snapshot", null, 1])( + "rejects invalid result-specific cache policy %j", + (cachePolicy) => { + expect( + parseGhidraFunctionResult("analyze_function", { + value: ghidraFunctionDossier(), + limitations: [], + cache_policy: cachePolicy, + }).ok, + ).toBe(false); + }, + ); + + it("rejects result-specific cache policy outside the dossier operation", () => { + expect( + parseGhidraFunctionResult("procedure_pseudo_code", { + value: "void main() {}", + limitations: [], + cache_policy: "live", + }).ok, + ).toBe(false); + }); + it("preserves unresolved Swift jump omissions", () => { const dossier = { ...functionDossierSchema.parse(ghidraFunctionDossier()), diff --git a/src/ghidra/GhidraFunctionValues.ts b/src/ghidra/GhidraFunctionValues.ts index 2f2f7e8e7b..4d2a8dc2fe 100644 --- a/src/ghidra/GhidraFunctionValues.ts +++ b/src/ghidra/GhidraFunctionValues.ts @@ -424,6 +424,7 @@ const functionBoundaryObservationOperations: ReadonlySet { }); }); +describe("Ghidra decompiler failure projection", () => { + it("reports a procedure decompiler failure without blaming the environment", async () => { + const code = "decompile_failed"; + const message = + "Ghidra decompilation failed: Low-level Error: Fixture decompiler failure"; + const ghidra = provider(installationHost(), () => ({ + start: () => Promise.resolve(ok(sessionInfo())), + callTool: () => + Promise.resolve( + err( + new GhidraSessionError( + "remote", + message, + { remote_code: code }, + { remoteCode: code }, + ), + ), + ), + close: () => Promise.resolve(ok(null)), + })); + const client = await createElfClient(ghidra); + const result = await client.execute("procedure_pseudo_code", { + procedure: "main", + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "ProviderAdapterError" }, + }); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "execution_failure", + retryable: false, + message: `${message}. Only the decompiler failed for this procedure; the Ghidra session remains usable.`, + details: { diagnostics: { remote_code: code } }, + }); + expect(projected.message).not.toContain("rea doctor"); + expect(projected.remediation.action).toContain("analyze_function"); + expect(projected.remediation.action).toContain( + "facet_availability.pseudocode", + ); + expect(projected.remediation.action).not.toContain("rea doctor"); + } + }); +}); + describe("Ghidra PE CodeView debug symbol limitations", () => { it.each([ ["absent report", undefined, "load state unknown"], diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 4bdf2912c4..8114f1d6ee 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -121,6 +121,7 @@ export type GhidraProviderClientFactory = ( type GhidraProviderClientInstance = ReturnType; interface GhidraExecutionMetadata { + readonly cachePolicy?: "live"; readonly rawResult?: JsonValue; readonly limitations?: readonly string[]; readonly locations?: readonly EvidenceLocation[]; @@ -909,6 +910,13 @@ const projectPrimarySessionError = ( ? failure : failure.cancellation.reason, }); + if (failure.kind === "remote" && failure.remoteCode === "decompile_failed") + return new ProviderAdapterError("ghidra", operation, { + ...context, + userMessage: `${failure.message.trim().replace(/\.$/u, "")}. Only the decompiler failed for this procedure; the Ghidra session remains usable.`, + remediationAction: + "Call analyze_function (CLI: `rea function`) for the same procedure. It returns assembly, callers, callees, references and basic blocks from the Ghidra listing and records this decompiler failure under facet_availability.pseudocode. Repeating the unchanged decompile request is not expected to succeed.", + }); if (failure.kind === "analysis_timeout") return new AnalysisTimeoutError( operation, diff --git a/src/ghidra/GhidraSnapshotCache.test.ts b/src/ghidra/GhidraSnapshotCache.test.ts index 0f01593854..134a9f214f 100644 --- a/src/ghidra/GhidraSnapshotCache.test.ts +++ b/src/ghidra/GhidraSnapshotCache.test.ts @@ -31,6 +31,7 @@ const fixture = (version = "12.1.4", coverage?: RelationshipCoverage) => { if (!config.ok) throw config.error; const calls: string[] = []; let name = "original"; + let decompilerFailed = false; let cancelStarted: (() => void) | undefined; const cancellationReady = new Promise((resolve) => { cancelStarted = resolve; @@ -104,6 +105,16 @@ const fixture = (version = "12.1.4", coverage?: RelationshipCoverage) => { const dossier = functionDossierSchema.parse(ghidraFunctionDossier()); dossier.procedure.name = name; dossier.pseudocode = `void ${name}() {}`; + if (decompilerFailed) { + dossier.pseudocode = ""; + dossier.facet_availability = { + ...dossier.facet_availability, + pseudocode: { + available: false, + reason: "Injected backend failure", + }, + }; + } if (operation === "annotate_native_function") return ok({ annotations: { @@ -127,6 +138,9 @@ const fixture = (version = "12.1.4", coverage?: RelationshipCoverage) => { ? { value, limitations: [], + ...(operation === "analyze_function" && decompilerFailed + ? { cache_policy: "live" } + : {}), ...(operation === "analyze_function" && coverage !== undefined ? { relationship_coverage: coverage } : {}), @@ -140,6 +154,9 @@ const fixture = (version = "12.1.4", coverage?: RelationshipCoverage) => { return { calls, cancellationReady, + setDecompilerFailed: (failed: boolean) => { + decompilerFailed = failed; + }, session: createTestBinarySession(provider), }; }; @@ -151,6 +168,52 @@ const open = async (session: ReturnType["session"]) => { }; describe("Ghidra relationship coverage replay", () => { + it("retains degraded Evidence without replaying transient failures after recovery or import", async () => { + const initial = fixture(); + await open(initial.session); + initial.setDecompilerFailed(true); + const parameters = { procedure: "0x1000" }; + const degraded = await initial.session.execute( + "analyze_function", + parameters, + ); + if (!degraded.ok) throw degraded.error; + const snapshot = initial.session.exportAnalysisSnapshot(); + if (!snapshot.ok) throw snapshot.error; + expect(snapshot.value.entries).toEqual([]); + expect(snapshot.value.evidence_bundle.records).toHaveLength(1); + + initial.setDecompilerFailed(false); + const recovered = await initial.session.execute( + "analyze_function", + parameters, + ); + if (!recovered.ok) throw recovered.error; + expect( + functionDossierSchema.parse(recovered.value.result).pseudocode, + ).not.toBe(""); + expect( + (await initial.session.execute("analyze_function", parameters)).ok, + ).toBe(true); + expect(initial.calls).toEqual(["analyze_function", "analyze_function"]); + await initial.session.close(); + + const replay = fixture(); + expect(replay.session.importAnalysisSnapshot(snapshot.value).ok).toBe(true); + await open(replay.session); + const imported = await replay.session.execute( + "analyze_function", + parameters, + ); + if (!imported.ok) throw imported.error; + expect( + functionDossierSchema.parse(imported.value.result).pseudocode, + ).not.toBe(""); + expect(replay.calls).toEqual(["analyze_function"]); + expect(replay.session.exportEvidenceBundle().records).toHaveLength(2); + await replay.session.close(); + }); + it.each([ undefined, { status: "complete", diagnostics: ["Resolved static references only."] }, diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index 32cc4b6a1f..4421658b03 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -270,7 +270,11 @@ const executeEnhancedTool = async ( name !== "trace_native_ui_action" && registration.allowsSnapshotReplay?.(name) === true ) { - const workflowRecord = workflowSnapshotRecord(evidence, name); + const workflowRecord = workflowSnapshotRecord( + evidence, + name, + result.value.cachePolicy, + ); if (workflowRecord !== undefined) { const snapshot = registration.recordWorkflowSnapshot?.(workflowRecord); diff --git a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts index 0a31f33baf..a7811987eb 100644 --- a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts +++ b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts @@ -88,6 +88,60 @@ it.each([ ); describe("Ghidra MCP native API evidence", () => { + it("retains transient failure Evidence without freezing the composed API inspection", async () => { + let failed = true; + const harness = await connectGhidraMcp( + "ghidra-native-api-recovery", + async (operation) => { + if (operation !== "analyze_function") + throw new Error(`Unexpected operation: ${operation}`); + const dossier = functionDossierSchema.parse(ghidraFunctionDossier()); + if (failed) + dossier.native_api = { + available: false, + reason: "Injected backend failure", + residual_unknowns: ["Retry after recovery."], + }; + return ok( + jsonValueSchema.parse({ + value: dossier, + limitations: [], + ...(failed ? { cache_policy: "live" } : {}), + }), + ); + }, + ); + try { + const inspect = async () => { + const reply = await harness.mcp.callTool({ + name: "inspect_native_api", + arguments: { procedure: "fixture_main" }, + }); + expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true); + return sessionEvidence(harness.session, reply.structuredContent); + }; + const degraded = await inspect(); + expect(degraded.normalized_result).toMatchObject({ + boundary: { available: false }, + }); + const snapshot = harness.session.exportAnalysisSnapshot(); + if (!snapshot.ok) throw snapshot.error; + expect(snapshot.value.entries).toEqual([]); + expect(snapshot.value.workflow_entries).toEqual([]); + expect(harness.session.evidenceById(degraded.evidence_id)).toEqual( + degraded, + ); + failed = false; + expect((await inspect()).normalized_result).toMatchObject({ + boundary: { available: true }, + }); + await inspect(); + expect(harness.calls).toEqual(["analyze_function", "analyze_function"]); + } finally { + await harness.close(); + } + }, 30_000); + it("returns complete native API boundaries without output-cap unknowns", async () => { const harness = await connectGhidraMcp("ghidra-native-api"); const { mcp, session } = harness; diff --git a/tests/boundary/process/ghidraDecompilerVerifierCleanup.test.ts b/tests/boundary/process/ghidraDecompilerVerifierCleanup.test.ts new file mode 100644 index 0000000000..7e181f9880 --- /dev/null +++ b/tests/boundary/process/ghidraDecompilerVerifierCleanup.test.ts @@ -0,0 +1,112 @@ +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { processTest } from "../../support/process/processFixture.js"; + +processTest + .skipIf( + !["linux", "darwin"].includes(process.platform) || + !["x64", "arm64"].includes(process.arch), + ) + .for([ + { outcome: "stopped", retained: false }, + { outcome: "incomplete", retained: true }, + { outcome: "unknown", retained: true }, + ])( + "preserves the compiler failure and respects $outcome cleanup ownership", + async ({ outcome, retained }, { processes }) => { + const root = await createTestTempDirectory("rea-decompiler-cleanup-test-"); + const temporary = join(root, "temporary"); + await mkdir(temporary); + const commandUrl = new URL( + "../../../dist/process/OwnedCommand.js", + import.meta.url, + ).href; + const installationUrl = new URL( + "../../../scripts/lib/ghidra-installation-inspection.mjs", + import.meta.url, + ).href; + const policyUrl = new URL( + "../../../dist/ghidra/GhidraReleasePolicy.js", + import.meta.url, + ).href; + // Execute the production entry point and its real filesystem cleanup. + // Substitute only the two external prerequisites; no Ghidra is launched. + const commandSource = ` + export * from ${JSON.stringify(`${commandUrl}?cleanup-test-original`)}; + import { OwnedCommandFailure } from ${JSON.stringify(`${commandUrl}?cleanup-test-original`)}; + import { writeFile } from "node:fs/promises"; + export async function runOwnedCommand(command) { + await writeFile(command.arguments.at(-1), "partial compiler output"); + if (${JSON.stringify(outcome)} === "unknown") throw new Error("opaque compiler failure"); + throw new OwnedCommandFailure("process", "controlled compiler failure", null, + ${JSON.stringify(outcome)} === "incomplete" ? "cleanup refused" : null, + undefined, [], ${JSON.stringify(outcome)} === "incomplete" ? { retained: true } : undefined); + } + `; + const installationSource = ` + import { SUPPORTED_GHIDRA_VERSION } from ${JSON.stringify(policyUrl)}; + export async function inspectVerifierGhidraInstallation() { + return { status: "available", providerVersion: SUPPORTED_GHIDRA_VERSION }; + } + `; + const loader = join(root, "loader.mjs"); + await writeFile( + loader, + `import { registerHooks } from "node:module"; + registerHooks({ load(url, context, nextLoad) { + if (url === ${JSON.stringify(commandUrl)}) return { format: "module", shortCircuit: true, source: ${JSON.stringify(commandSource)} }; + if (url === ${JSON.stringify(installationUrl)}) return { format: "module", shortCircuit: true, source: ${JSON.stringify(installationSource)} }; + return nextLoad(url, context); + }});`, + ); + const result = await processes.run( + process.execPath, + [ + "--import", + loader, + fileURLToPath( + new URL( + "../../../scripts/verify-real-ghidra-decompiler-failure.mjs", + import.meta.url, + ), + ), + ], + { + env: { + ...process.env, + TMPDIR: temporary, + TMP: temporary, + TEMP: temporary, + }, + timeoutMs: 15_000, + }, + ); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.stderr).toContain( + outcome === "unknown" + ? "opaque compiler failure" + : "controlled compiler failure", + ); + const workspaces = await readdir(temporary); + expect(workspaces).toHaveLength(retained ? 1 : 0); + if (retained) { + expect(workspaces[0]).toMatch(/^rea-ghidra-decompiler-/u); + expect( + await readFile( + join( + temporary, + workspaces[0]!, + process.platform === "darwin" + ? "cross-format.dylib" + : "cross-format.so", + ), + "utf8", + ), + ).toBe("partial compiler output"); + } + }, +); diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index d2161e1ff3..bf1e1ecfd4 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -32,6 +32,19 @@ binaries and Ghidra projects are never committed. named limitations through the real read-only TCP bridge and provider Evidence projection; default Unix-socket transport, Windows, and the reported Amethyst artifact need separate verification. Fixture target code is never executed. +- `ReaDecompilerFailureProbe.java` replaces the bridge decompiler after a real + import of `cross-format.c`, so one function's decompilation fails. It checks + that `analyze_function` still returns that function's assembly, callers, + callees, references and basic blocks unchanged, marks pseudocode, native API + and p-code value flow unavailable with the decompiler's reason, and that + cancellation still ends the request. Run `npm run build:cached`, then + `node scripts/verify-real-ghidra-decompiler-failure.mjs` on Linux or macOS + x64/arm64. The lane also parses the degraded dossier with REA's Ghidra output + contract and checks the healthy provider path through the real read-only TCP bridge. + Decompiler failure marks only that observation as live: retain its Evidence, + retry after backend recovery, and keep healthy dossier snapshot reuse. + The injection does not establish which natural inputs make the decompiler + fail. `scripts/verify-real-ghidra.mjs` uses `cc`, `clang`, and `lld-link` by default; `REA_CC`, `REA_CLANG`, and `REA_LLD_LINK` can select alternate commands. The diff --git a/tests/conformance/ghidra/ReaDecompilerFailureProbe.java b/tests/conformance/ghidra/ReaDecompilerFailureProbe.java new file mode 100644 index 0000000000..6467b3b856 --- /dev/null +++ b/tests/conformance/ghidra/ReaDecompilerFailureProbe.java @@ -0,0 +1,213 @@ +//@category REA Verification + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import generic.jar.ResourceFile; +import ghidra.app.decompiler.DecompInterface; +import ghidra.app.decompiler.DecompileProcess.DisposeState; +import ghidra.app.decompiler.DecompileResults; +import ghidra.app.script.GhidraScript; +import ghidra.app.script.GhidraScriptUtil; +import ghidra.app.util.headless.HeadlessAnalyzer; +import ghidra.app.util.headless.HeadlessScript; +import ghidra.framework.Application; +import ghidra.program.model.listing.Function; +import ghidra.util.task.TaskMonitor; +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; + +/** + * Replaces the bridge decompiler after real import and analysis so one procedure's + * decompilation fails. Verifies that analyze_function still returns the listing-backed + * facts. This injection does not establish which natural inputs make Ghidra fail. + */ +public class ReaDecompilerFailureProbe extends GhidraScript { + private static final String INJECTED = "Injected backend failure"; + private static final List LISTING_FACETS = List.of( + "assembly", "comments", "callers", "callees", "incoming_references", + "outgoing_references", "referenced_strings", "referenced_names", "basic_blocks", + "unresolved_calls"); + private final JsonArray checks = new JsonArray(); + + private void require(String name, boolean condition) { + if (!condition) throw new IllegalStateException("Decompiler failure assertion failed: " + name); + checks.add(name); + } + + private Function exportedFunction(String name) { + Function selected = null; + for (Function f : currentProgram.getFunctionManager().getFunctions(true)) { + if (!f.isThunk() && (f.getName().equals(name) || f.getName().equals("_" + name)) && + currentProgram.getSymbolTable().isExternalEntryPoint(f.getEntryPoint())) { + if (selected != null) throw new IllegalStateException("Ambiguous fixture export " + name); + selected = f; + } + } + if (selected == null) throw new IllegalStateException("Missing fixture export " + name); + return selected; + } + + private GhidraScript script(String name, File expected) throws Exception { + ResourceFile source = GhidraScriptUtil.findScriptByName(name); + if (source == null || !expected.equals(new File(source.getAbsolutePath()).getCanonicalFile())) + throw new IllegalStateException("Fixture resolved an unexpected script: " + name); + GhidraScript instance = GhidraScriptUtil.getProvider(source).getScriptInstance(source, errorWriter); + instance.set(getState(), getControls()); + // Match HeadlessScript.runScript's nested-script setup before invoking + // the real request handler, which checks the current analysis status. + Method headless = HeadlessScript.class.getDeclaredMethod("setHeadlessInstance", HeadlessAnalyzer.class); + headless.setAccessible(true); + headless.invoke(instance, HeadlessAnalyzer.getInstance()); + return instance; + } + + private static DecompInterface failing(Function expected, DisposeState disposition) { + return new DecompInterface() { + @Override + public DecompileResults decompileFunction(Function function, int timeout, TaskMonitor taskMonitor) { + if (!function.equals(expected)) + throw new IllegalStateException("Unexpected decompilation of " + function.getName()); + return new DecompileResults(function, function.getProgram().getLanguage(), + function.getProgram().getCompilerSpec(), null, INJECTED, null, disposition); + } + }; + } + + private static String failureCode(Throwable failure) throws Exception { + Field code = failure.getClass().getDeclaredField("code"); + code.setAccessible(true); + return (String) code.get(failure); + } + + private static String reason(JsonObject value) { + JsonElement reason = value.get("reason"); + return reason == null ? null : reason.getAsString(); + } + + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length != 1) throw new IllegalArgumentException("Expected exact bridge directory"); + File directory = new File(args[0]).getCanonicalFile(); + GhidraScript bridge = script("ReaGhidraBridge.java", new File(directory, "ReaGhidraBridge.java")); + Field space = bridge.getClass().getDeclaredField("sessionDefaultAddressSpace"); + space.setAccessible(true); + space.set(null, currentProgram.getAddressFactory().getDefaultAddressSpace()); + Method initialize = bridge.getClass().getDeclaredMethod("initializeDecompiler"); + initialize.setAccessible(true); + initialize.invoke(bridge); + Class requestType = Class.forName(bridge.getClass().getName() + "$Request", true, bridge.getClass().getClassLoader()); + Class descriptorType = Class.forName(bridge.getClass().getName() + "$SessionDescriptor", true, bridge.getClass().getClassLoader()); + Constructor requestConstructor = requestType.getDeclaredConstructor(int.class, String.class, JsonObject.class); + requestConstructor.setAccessible(true); + Method analyze = bridge.getClass().getDeclaredMethod("handleRequest", requestType, descriptorType); + analyze.setAccessible(true); + Method pseudocode = bridge.getClass().getDeclaredMethod("procedurePseudocode", JsonObject.class); + pseudocode.setAccessible(true); + Field decompiler = bridge.getClass().getDeclaredField("decompiler"); + decompiler.setAccessible(true); + DecompInterface real = (DecompInterface) decompiler.get(bridge); + + Function target = exportedFunction("rea_cross_branch"); + String procedure = "0x" + Long.toUnsignedString(target.getEntryPoint().getOffset(), 16); + JsonObject query = new JsonObject(); + query.addProperty("procedure", procedure); + // Exercise the actual wire response producer, including cache metadata. + Object request = requestConstructor.newInstance(1, "analyze_function", query); + JsonObject pseudocodeQuery = new JsonObject(); + pseudocodeQuery.add("document", JsonNull.INSTANCE); + pseudocodeQuery.addProperty("procedure", procedure); + + JsonObject baseline; + JsonObject degraded; + try { + baseline = (JsonObject) analyze.invoke(bridge, request, null); + JsonObject value = baseline.getAsJsonObject("value"); + require("healthy_snapshot_policy_preserved", !baseline.has("cache_policy")); + require("baseline_pseudocode_available", !value.get("pseudocode").getAsString().isEmpty() && + value.getAsJsonObject("facet_availability").getAsJsonObject("pseudocode").get("available").getAsBoolean()); + require("baseline_native_api_available", value.getAsJsonObject("native_api").get("available").getAsBoolean()); + require("baseline_assembly_present", value.getAsJsonArray("assembly").size() > 0); + require("baseline_callers_present", value.getAsJsonArray("callers").size() > 0); + require("baseline_callees_present", value.getAsJsonArray("callees").size() > 0); + require("baseline_multiple_blocks_present", value.getAsJsonArray("basic_blocks").size() > 1); + + decompiler.set(bridge, failing(target, DisposeState.NOT_DISPOSED)); + degraded = (JsonObject) analyze.invoke(bridge, request, null); + JsonObject dossier = degraded.getAsJsonObject("value"); + require("decompiler_failure_is_live", degraded.get("cache_policy").getAsString().equals("live")); + String expected = "Ghidra decompilation failed: " + INJECTED; + require("dossier_returned", dossier.getAsJsonObject("procedure").get("address").getAsString() + .equals(value.getAsJsonObject("procedure").get("address").getAsString())); + JsonObject facets = dossier.getAsJsonObject("facet_availability"); + require("pseudocode_unavailable_with_failure", dossier.get("pseudocode").getAsString().isEmpty() && + !facets.getAsJsonObject("pseudocode").get("available").getAsBoolean() && + expected.equals(reason(facets.getAsJsonObject("pseudocode")))); + require("native_api_unavailable_with_failure", + !dossier.getAsJsonObject("native_api").get("available").getAsBoolean() && + expected.equals(reason(dossier.getAsJsonObject("native_api")))); + require("native_value_flow_unavailable_with_failure", + !dossier.getAsJsonObject("native_value_flow").get("available").getAsBoolean() && + expected.equals(reason(dossier.getAsJsonObject("native_value_flow")))); + for (String facet : LISTING_FACETS) + require("listing_facet_unchanged_" + facet, dossier.get(facet).equals(value.get(facet))); + for (String facet : List.of("assembly", "comments", "callers", "callees", "references", "strings_names", "cfg")) + require("facet_availability_unchanged_" + facet, + facets.get(facet).equals(value.getAsJsonObject("facet_availability").get(facet))); + require("relationship_coverage_unchanged", + degraded.get("relationship_coverage").equals(baseline.get("relationship_coverage"))); + boolean named = false; + for (JsonElement item : dossier.getAsJsonArray("limitations")) + named |= item.getAsString().startsWith(expected + ". Pseudocode, the native API boundary") && + item.getAsString().contains("were still collected"); + require("failure_limitation_named", named); + + try { + pseudocode.invoke(bridge, pseudocodeQuery); + throw new IllegalStateException("Pseudocode request unexpectedly succeeded"); + } catch (InvocationTargetException failure) { + require("pseudocode_request_still_fails", + failureCode(failure.getCause()).equals("decompile_failed") && + failure.getCause().getMessage().equals(expected)); + } + + decompiler.set(bridge, failing(target, DisposeState.DISPOSED_ON_CANCEL)); + try { + analyze.invoke(bridge, request, null); + throw new IllegalStateException("Cancelled decompilation unexpectedly returned a dossier"); + } catch (InvocationTargetException failure) { + require("cancellation_still_ends_request", + failureCode(failure.getCause()).equals("decompile_cancelled")); + } + + decompiler.set(bridge, real); + JsonObject recovered = (JsonObject) analyze.invoke(bridge, request, null); + require("session_recovers_after_failure", recovered.getAsJsonObject("value") + .getAsJsonObject("facet_availability").getAsJsonObject("pseudocode").get("available").getAsBoolean()); + } finally { + decompiler.set(bridge, real); + real.dispose(); + } + + JsonObject report = new JsonObject(); + report.addProperty("status", "passed"); + report.addProperty("ghidra_version", Application.getApplicationVersion()); + report.addProperty("target_sha256", currentProgram.getExecutableSHA256()); + byte[] bytes = Files.readAllBytes(new File(directory, "ReaGhidraBridge.java").toPath()); + report.addProperty("ReaGhidraBridge.java", + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes))); + report.add("checks", checks); + report.add("baseline", baseline); + report.add("degraded", degraded); + println("REA_DECOMPILER_FAILURE_PROBE_JSON " + report); + } +}