mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
feat(ghidra): bound one procedure's decompile without closing the session (#5662)
* feat(ghidra): bound one procedure's decompile without closing the session Fixes #5340 * fix(ghidra): report decompile limit as a request timeout Keeps BinarySession from marking every Ghidra operation unavailable after one procedure's decompile reaches the limit, and names the MCP tool-call deadline in the remediation. * fix(ghidra): budget the decompile limit and report cancellation-closed sessions Adds REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS to the derived Codex/Kimi tool deadline and forwards it through setup. Marks a remote failure that settles after caller cancellation so the timeout projection no longer promises session reuse. * fix(ghidra): align deadline bounds and cancellation outcomes * refactor(ghidra): shorten deadline recovery guidance * fix(mcp): clarify the scope of inferred decompile deadlines * test(ghidra): verify deadline workflows and document measured costs Separate failure and registration cases by their distinct contracts. Record actual Linux Ghidra, CLI and MCP evidence with measured context, elapsed time, CPU and memory, and explicit remaining coverage limits. * docs(ghidra): pin integration and distinguish baseline format failures * fix(ghidra): own failure lifecycle wording in the provider * test(ghidra): budget timeout fixture generation before allocation * fix(ghidra): align capability and traversal deadline guidance * style(config): align deadline guidance formatting * docs(ghidra): record capability and traversal verification --------- Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import { parseConfig } from "../dist/config/parseConfig.js";
|
||||
import { projectAnalysisError } from "../dist/domain/analysisErrorProjection.js";
|
||||
import { GhidraClient } from "../dist/ghidra/GhidraClient.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 {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../dist/process/OwnedCommand.js";
|
||||
import {
|
||||
assertVerifierCleanupComplete,
|
||||
completeVerifierRun,
|
||||
createVerifierRun,
|
||||
} from "./lib/verifier-run.mjs";
|
||||
|
||||
// A natural slow decompile: one function with many independent branches on
|
||||
// volatile state. Ghidra decompiles it correctly but takes far longer than the
|
||||
// configured limit, so the limit, not an injected result, stops it.
|
||||
const LIMIT_SECONDS = 1;
|
||||
// This verifier budgets one MiB for its generated C source. Bound the number
|
||||
// of lines before allocating them, including the largest admitted integer's
|
||||
// decimal representation; the joined source and line array remain bounded.
|
||||
const FIXTURE_SOURCE_BYTES = 1024 * 1024;
|
||||
const FIXTURE_PREFIX = [
|
||||
"volatile unsigned rea_state[64];",
|
||||
"__attribute__((noinline, used)) unsigned rea_slow(unsigned x) {",
|
||||
" unsigned a = x;",
|
||||
];
|
||||
const FIXTURE_SUFFIX = [
|
||||
" return a;",
|
||||
"}",
|
||||
"__attribute__((noinline, used)) int rea_small(int v) {",
|
||||
" return v + (int)rea_state[0];",
|
||||
"}",
|
||||
"",
|
||||
];
|
||||
const MAX_BRANCH_LINE_BYTES = Buffer.byteLength(
|
||||
` if (rea_state[63] & 1073741824u) a = a * 31u + ${Number.MAX_SAFE_INTEGER}u; else a ^= rea_state[63];\n`,
|
||||
);
|
||||
const FIXTURE_FIXED_BYTES = Buffer.byteLength(
|
||||
[...FIXTURE_PREFIX, ...FIXTURE_SUFFIX].join("\n"),
|
||||
);
|
||||
const MAX_BRANCHES = Math.floor(
|
||||
(FIXTURE_SOURCE_BYTES - FIXTURE_FIXED_BYTES) / MAX_BRANCH_LINE_BYTES,
|
||||
);
|
||||
const BRANCHES = Number(process.env.REA_DECOMPILE_TIMEOUT_BRANCHES ?? 6000);
|
||||
assert.ok(
|
||||
Number.isSafeInteger(BRANCHES) && BRANCHES > 0 && BRANCHES <= MAX_BRANCHES,
|
||||
`REA_DECOMPILE_TIMEOUT_BRANCHES must be an integer from 1 to ${MAX_BRANCHES} (generated source budget: ${FIXTURE_SOURCE_BYTES} bytes)`,
|
||||
);
|
||||
|
||||
const run = createVerifierRun();
|
||||
assert.ok(
|
||||
process.platform === "linux" || process.platform === "darwin",
|
||||
"This regression lane requires Linux or macOS",
|
||||
);
|
||||
const environment = {
|
||||
...process.env,
|
||||
REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: String(LIMIT_SECONDS),
|
||||
};
|
||||
const config = parseConfig(environment);
|
||||
if (!config.ok) throw config.error;
|
||||
assert.equal(config.value.ghidraDecompileTimeoutSeconds, LIMIT_SECONDS);
|
||||
const installation = await inspectVerifierGhidraInstallation({
|
||||
environment,
|
||||
installDir: config.value.ghidraInstallDir,
|
||||
javaHome: config.value.ghidraJavaHome,
|
||||
});
|
||||
assert.equal(installation.status, "available", JSON.stringify(installation));
|
||||
assert.equal(installation.providerVersion, SUPPORTED_GHIDRA_VERSION);
|
||||
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const fixtureSource = () => {
|
||||
const lines = [...FIXTURE_PREFIX];
|
||||
for (let index = 0; index < BRANCHES; index += 1)
|
||||
lines.push(
|
||||
` if (rea_state[${index % 64}] & ${(1 << (index % 31)) >>> 0}u) a = a * 31u + ${index}u; else a ^= rea_state[${(index * 7) % 64}];`,
|
||||
);
|
||||
lines.push(...FIXTURE_SUFFIX);
|
||||
const source = lines.join("\n");
|
||||
assert.ok(Buffer.byteLength(source) <= FIXTURE_SOURCE_BYTES);
|
||||
return source;
|
||||
};
|
||||
|
||||
const workspace = await mkdtemp(
|
||||
join(tmpdir(), "rea-ghidra-decompile-timeout-"),
|
||||
);
|
||||
let cleaned = true;
|
||||
let report;
|
||||
try {
|
||||
const sourcePath = join(workspace, "slow-decompile.c");
|
||||
const source = fixtureSource();
|
||||
await writeFile(sourcePath, source);
|
||||
const targetPath = join(
|
||||
workspace,
|
||||
process.platform === "darwin"
|
||||
? "slow-decompile.dylib"
|
||||
: "slow-decompile.so",
|
||||
);
|
||||
const compiler = {
|
||||
command: process.env.REA_CC ?? "cc",
|
||||
arguments: [
|
||||
...(process.platform === "darwin" ? ["-dynamiclib"] : ["-shared"]),
|
||||
"-fPIC",
|
||||
"-O0",
|
||||
sourcePath,
|
||||
"-o",
|
||||
targetPath,
|
||||
],
|
||||
};
|
||||
cleaned = false;
|
||||
try {
|
||||
await runOwnedCommand(
|
||||
{ ...compiler, runId: run.run_id, expectedCommand: null },
|
||||
{ timeoutMs: 300_000, diagnosticBytes: 1024 * 1024 },
|
||||
);
|
||||
cleaned = true;
|
||||
} catch (cause) {
|
||||
cleaned =
|
||||
cause instanceof OwnedCommandFailure && cause.cleanupOwner === undefined;
|
||||
throw cause;
|
||||
}
|
||||
const targetDigest = digest(await readFile(targetPath));
|
||||
|
||||
// One real provider session over the read-only authenticated TCP bridge.
|
||||
let bridgeClient;
|
||||
const provider = new GhidraProvider(
|
||||
config.value,
|
||||
silentLogger,
|
||||
environment,
|
||||
undefined,
|
||||
(options) => {
|
||||
// This verifier has no caller-selected run ID: each new launch receives
|
||||
// a fresh owner-generated identity, so equality detects session restarts.
|
||||
assert.equal(options.runId, undefined);
|
||||
bridgeClient = new GhidraClient({
|
||||
...options,
|
||||
transport: "authenticated-loopback-tcp",
|
||||
});
|
||||
return bridgeClient;
|
||||
},
|
||||
);
|
||||
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 = [];
|
||||
const observed = {};
|
||||
try {
|
||||
// Mach-O symbols carry a leading underscore; address the functions by
|
||||
// their entry instead of guessing the recovered name.
|
||||
const listed = await client.execute("list_procedures", {});
|
||||
if (!listed.ok) throw listed.error;
|
||||
const entry = (name) => {
|
||||
const found = listed.value.result.find(
|
||||
(item) => item.value === name || item.value === `_${name}`,
|
||||
);
|
||||
assert.ok(found, `Missing fixture function ${name}`);
|
||||
return found.address;
|
||||
};
|
||||
const slowEntry = entry("rea_slow");
|
||||
const smallEntry = entry("rea_small");
|
||||
assert.ok(bridgeClient);
|
||||
const initialSession = await bridgeClient.ping();
|
||||
if (!initialSession.ok) throw initialSession.error;
|
||||
assert.equal(initialSession.value.target.sha256, targetDigest);
|
||||
observed.initial_session_run_id = initialSession.value.run_id;
|
||||
const limitText = `exceeded the ${String(LIMIT_SECONDS)}-second limit set by REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS`;
|
||||
let started = performance.now();
|
||||
const slow = await client.execute("analyze_function", {
|
||||
procedure: slowEntry,
|
||||
});
|
||||
observed.analyze_seconds = (performance.now() - started) / 1000;
|
||||
if (!slow.ok) throw slow.error;
|
||||
const dossier = slow.value.result;
|
||||
assert.equal(dossier.facet_availability.pseudocode.available, false);
|
||||
assert.ok(
|
||||
dossier.facet_availability.pseudocode.reason.endsWith(limitText),
|
||||
dossier.facet_availability.pseudocode.reason,
|
||||
);
|
||||
assert.equal(dossier.native_api.available, false);
|
||||
assert.equal(dossier.native_value_flow.available, false);
|
||||
assert.ok(dossier.assembly.length > BRANCHES);
|
||||
assert.ok(dossier.basic_blocks.length > 0);
|
||||
assert.equal(slow.value.cachePolicy, "live");
|
||||
observed.degraded = {
|
||||
pseudocode: dossier.facet_availability.pseudocode,
|
||||
assembly_lines: dossier.assembly.length,
|
||||
basic_blocks: dossier.basic_blocks.length,
|
||||
cache_policy: slow.value.cachePolicy,
|
||||
};
|
||||
|
||||
started = performance.now();
|
||||
const pseudocode = await client.execute("procedure_pseudo_code", {
|
||||
procedure: slowEntry,
|
||||
});
|
||||
observed.pseudocode_seconds = (performance.now() - started) / 1000;
|
||||
assert.equal(pseudocode.ok, false);
|
||||
const projected = projectAnalysisError(pseudocode.error);
|
||||
assert.equal(pseudocode.error._tag, "AnalysisTimeoutError");
|
||||
assert.equal(projected.code, "provider_timeout");
|
||||
assert.equal(projected.retryable, true);
|
||||
assert.equal(projected.details.timeout_ms, LIMIT_SECONDS * 1000);
|
||||
assert.ok(projected.message.includes(limitText), projected.message);
|
||||
assert.ok(
|
||||
projected.remediation.action.includes(
|
||||
"REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS",
|
||||
),
|
||||
);
|
||||
observed.pseudocode_error = {
|
||||
code: projected.code,
|
||||
retryable: projected.retryable,
|
||||
message: projected.message,
|
||||
};
|
||||
|
||||
// The same session decompiles the next function with a fresh decompiler.
|
||||
const small = await client.execute("procedure_pseudo_code", {
|
||||
procedure: smallEntry,
|
||||
});
|
||||
if (!small.ok) throw small.error;
|
||||
assert.ok(String(small.value.result).includes("rea_state"));
|
||||
const continuedSession = await bridgeClient.ping();
|
||||
if (!continuedSession.ok) throw continuedSession.error;
|
||||
assert.equal(continuedSession.value.run_id, initialSession.value.run_id);
|
||||
assert.equal(continuedSession.value.target.sha256, targetDigest);
|
||||
observed.final_session_run_id = continuedSession.value.run_id;
|
||||
observed.same_session_next_decompile = "succeeded";
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
}
|
||||
const closed = await client.close();
|
||||
if (!closed.ok) failures.push(closed.error);
|
||||
if (failures.length > 0)
|
||||
throw new AggregateError(failures, "Decompile timeout verification failed");
|
||||
assert.equal(digest(await readFile(targetPath)), targetDigest);
|
||||
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",
|
||||
limit_seconds: LIMIT_SECONDS,
|
||||
branches: BRANCHES,
|
||||
target_sha256: targetDigest,
|
||||
source_sha256: digest(Buffer.from(source)),
|
||||
compiler,
|
||||
provider: profile.value.profile.provider,
|
||||
analysis_profile_digest: profile.value.profile.digest,
|
||||
...observed,
|
||||
verifier_run: verifierRun,
|
||||
scope:
|
||||
"Real Ghidra ELF or Mach-O analysis of a generated slow function through the read-only TCP provider: the decompile limit stops it, analyze_function degrades, procedure_pseudo_code names the setting, and the same session decompiles the next function. No target execution; Windows is unverified.",
|
||||
};
|
||||
} finally {
|
||||
if (cleaned) await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
console.log(JSON.stringify({ ...report, cleanup: "complete" }));
|
||||
@@ -142,6 +142,8 @@ try {
|
||||
assert.equal(probe.target_sha256, targetDigest);
|
||||
assert.equal(probe["ReaGhidraBridge.java"], bridgeDigest);
|
||||
for (const check of [
|
||||
"regex_failure_preserves_stage_and_pattern",
|
||||
"regex_failure_leaves_lifecycle_to_provider",
|
||||
"pseudocode_unavailable_with_failure",
|
||||
"native_api_unavailable_with_failure",
|
||||
"native_value_flow_unavailable_with_failure",
|
||||
@@ -153,6 +155,11 @@ try {
|
||||
"failure_limitation_named",
|
||||
"pseudocode_request_still_fails",
|
||||
"cancellation_still_ends_request",
|
||||
"timed_out_decompile_is_live",
|
||||
"timed_out_pseudocode_names_limit",
|
||||
"timed_out_native_api_names_limit",
|
||||
"timed_out_listing_unchanged",
|
||||
"timed_out_pseudocode_request_names_limit",
|
||||
"session_recovers_after_failure",
|
||||
])
|
||||
assert.ok(probe.checks.includes(check), check);
|
||||
@@ -221,7 +228,24 @@ try {
|
||||
assert.ok(
|
||||
!result.value.limitations.some((item) => item.includes(injected)),
|
||||
);
|
||||
const functionLimitCapability = result.value.limitations.find((item) =>
|
||||
item.includes("REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS"),
|
||||
);
|
||||
assert.ok(functionLimitCapability?.includes("per-function limit"));
|
||||
assert.ok(
|
||||
functionLimitCapability.includes(
|
||||
"caller cancellation requests session closure",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!result.value.limitations.some((item) =>
|
||||
item.includes(
|
||||
"lets each decompilation run until it completes or the caller cancels",
|
||||
),
|
||||
),
|
||||
);
|
||||
healthy = {
|
||||
function_limit_capability: functionLimitCapability,
|
||||
pseudocode_available: true,
|
||||
native_api_available: value.native_api.available,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user