mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
* feat(mcp): support runtime analysis limit recovery * refactor(config): remove unused limit re-exports * fix(android): keep fixture release metadata dependency-free * fix(settings): preserve provider budgets and recovery facts * fix(tests): resolve lint plugins from their repository owner * refactor(mcp): verify guidance delivery without frozen wording * fix(android): align availability with runtime timeout * test(recovery): assert limit facts instead of frozen wording * fix(mcp): share runtime settings with the default JADX provider * test(mcp): bound acknowledgements and release held work on failure * fix(catalog): declare runtime settings tools as MCP-only
299 lines
11 KiB
JavaScript
299 lines
11 KiB
JavaScript
import { AnalysisSettings } from "../dist/application/AnalysisSettings.js";
|
|
import { runtimeAnalysisConfig } from "../dist/config/runtimeAnalysisSettings.js";
|
|
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 settings = new AnalysisSettings(environment);
|
|
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(
|
|
runtimeAnalysisConfig(config.value, settings.read),
|
|
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 configured decompilation limit (ghidra.decompile_timeout_seconds; launch seed 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,
|
|
};
|
|
|
|
const changed = settings.update({
|
|
changes: [
|
|
{
|
|
setting: "ghidra.decompile_timeout_seconds",
|
|
action: "set",
|
|
value: 60,
|
|
},
|
|
],
|
|
});
|
|
assert.equal(changed.ok, true);
|
|
const recovered = await settings.run(() =>
|
|
client.execute("procedure_pseudo_code", { procedure: slowEntry }),
|
|
);
|
|
if (!recovered.ok) throw recovered.error;
|
|
assert.ok(String(recovered.value.result).includes("rea_state"));
|
|
observed.runtime_recovery = {
|
|
timeout_seconds: 60,
|
|
pseudocode_bytes: Buffer.byteLength(String(recovered.value.result)),
|
|
};
|
|
|
|
// 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, a runtime update recovers the same slow function on the same Program, 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" }));
|