mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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.
This commit is contained in:
1 parent
0c440bfc07
commit
c374c2fe72
19 files changed
+971
-31
No files matched your search
@@ -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<PcodeOpAST> 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<String> limitations, JsonObject relationshipCoverage) {}
|
||||
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations, JsonObject relationshipCoverage, boolean decompilerFailed) {}
|
||||
private record CallBoundaryObservation(List<String> limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls) {}
|
||||
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
|
||||
private record SwiftLiteralLayout(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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<Record<string, JsonValue>>;
|
||||
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 (
|
||||
|
||||
@@ -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" };
|
||||
|
||||
|
||||
@@ -38,14 +38,21 @@ export const executeComposedWorkflow = async (
|
||||
) => Promise<Result<JsonValue, AnalysisError>>,
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly result: JsonValue; readonly limitations: readonly string[] },
|
||||
{
|
||||
readonly result: JsonValue;
|
||||
readonly limitations: readonly string[];
|
||||
readonly cachePolicy?: "live";
|
||||
},
|
||||
AnalysisError
|
||||
>
|
||||
> => {
|
||||
const limitations = new Map<string, SourceLimitation>();
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -424,6 +424,7 @@ const functionBoundaryObservationOperations: ReadonlySet<GhidraFunctionOperation
|
||||
const functionObservationEnvelope = z.strictObject({
|
||||
value: z.unknown(),
|
||||
limitations: z.array(z.string()),
|
||||
cache_policy: z.literal("live").exactOptional(),
|
||||
relationship_coverage: relationshipCoverageSchema.exactOptional(),
|
||||
selection: z
|
||||
.strictObject({
|
||||
@@ -437,6 +438,7 @@ const functionObservationEnvelope = z.strictObject({
|
||||
export interface ParsedGhidraFunctionResult {
|
||||
readonly value: JsonValue;
|
||||
readonly rawResult?: JsonValue;
|
||||
readonly cachePolicy?: "live";
|
||||
readonly limitations: readonly string[];
|
||||
readonly locations?: readonly EvidenceLocation[];
|
||||
readonly relationshipCoverage?: RelationshipCoverage;
|
||||
@@ -450,6 +452,7 @@ export const parseGhidraFunctionResult = (
|
||||
let observation: {
|
||||
readonly value: unknown;
|
||||
readonly limitations: readonly string[];
|
||||
readonly cache_policy?: "live";
|
||||
readonly relationship_coverage?: RelationshipCoverage;
|
||||
readonly selection?: z.infer<
|
||||
typeof functionObservationEnvelope
|
||||
@@ -459,6 +462,8 @@ export const parseGhidraFunctionResult = (
|
||||
const envelope = functionObservationEnvelope.safeParse(value);
|
||||
if (
|
||||
!envelope.success ||
|
||||
(operation !== "analyze_function" &&
|
||||
envelope.data.cache_policy !== undefined) ||
|
||||
(operation !== "analyze_function" &&
|
||||
operation !== "procedure_callers" &&
|
||||
operation !== "procedure_callees" &&
|
||||
@@ -482,6 +487,9 @@ export const parseGhidraFunctionResult = (
|
||||
? ok({
|
||||
value: jsonValueSchema.parse(parsed.data),
|
||||
limitations: observation.limitations,
|
||||
...(observation.cache_policy === undefined
|
||||
? {}
|
||||
: { cachePolicy: observation.cache_policy }),
|
||||
...(observation.relationship_coverage === undefined
|
||||
? {}
|
||||
: { relationshipCoverage: observation.relationship_coverage }),
|
||||
|
||||
@@ -1298,6 +1298,52 @@ describe("Ghidra result projection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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"],
|
||||
|
||||
@@ -121,6 +121,7 @@ export type GhidraProviderClientFactory = (
|
||||
type GhidraProviderClientInstance = ReturnType<GhidraProviderClientFactory>;
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<void>((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<typeof fixture>["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."] },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user