Files
rea/scripts/verify-real-ghidra-decompiler-failure.mjs
T
Syed Rishad c374c2fe72 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.
2026-10-20 12:08:08 +00:00

283 lines
9.7 KiB
JavaScript

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",
}),
);