Files
reverse-engineering-anything/experiments/analysis-efficiency/ghidra-project-reuse.mjs
T
19447516a4 feat(ghidra): reuse existing projects through verified copies (#7236)
Reuse selected Ghidra projects through verified private read-only copies, with shared CLI/MCP configuration, committed project identity, import-SHA verification and explicit historical/unknown analysis semantics. Preserve exact cancellation and platform capability boundaries.

Actual Ghidra 12.1.4/JDK21 Linux CLI/MCP investigations matched fresh import on 652-function ls and 14,338-function libcrypto inventories and pseudocode; authenticated complete Evidence, mismatch/staleness refusal, saved historical reports and shared /tmp projects verified. 958 focused tests passed with four platform skips; build/static/docs/format passed. Real macOS/Windows reuse remains unverified; Windows reuse is unavailable. See PR verification for measured whole-task costs and final CI attribution.

Co-authored-by: akushonkamen <akushonkamen@163.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-26 22:31:13 +08:00

378 lines
12 KiB
JavaScript

/** Equivalent bounded Ghidra investigations with fresh import or an existing project. */
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { createWriteStream } from "node:fs";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { finished } from "node:stream/promises";
import { pathToFileURL } from "node:url";
import { parseArgs, promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs";
const { values } = parseArgs({
options: Object.fromEntries(
[
"runtime",
"output",
"profile",
"target",
"receive-bytes",
"families",
"ghidra",
"java",
"project",
"program",
"control",
"expected",
"heap",
"history",
].map((key) => [key, { type: "string" }]),
),
});
for (const key of ["runtime", "output", "target", "ghidra", "java"]) {
assert.ok(values[key], `${key} is required`);
values[key] = resolve(values[key]);
}
assert.ok(["full", "compact"].includes(values.profile));
const receiveBytes = Number(values["receive-bytes"] ?? 10 * 1024 * 1024);
assert.ok(Number.isSafeInteger(receiveBytes) && receiveBytes > 0);
await mkdir(values.output);
const report = {
profile: values.profile,
families: values.families ?? null,
receive_bytes: receiveBytes,
runtime: values.runtime,
target: values.target,
project: values.project ?? null,
expected: values.expected ?? "success",
calls: [],
};
const transcript = [];
let account, client, transport, serverLog, serverLogFinished;
let phase = "initialize";
const began = performance.now();
try {
account = await mkdtemp(join(tmpdir(), "rea-output-profile-"));
report.target_sha256 = createHash("sha256")
.update(await readFile(values.target))
.digest("hex");
report.revision = (
await promisify(execFile)("git", [
"-C",
values.runtime,
"rev-parse",
"HEAD",
])
).stdout.trim();
transport = new StdioClientTransport({
command: process.execPath,
args: [join(values.runtime, "scripts/rea.mjs"), "mcp"],
cwd: values.runtime,
maxBufferSize: receiveBytes,
env: {
PATH: process.env.PATH,
GHIDRA_INSTALL_DIR: values.ghidra,
JAVA_HOME: values.java,
GHIDRA_HEADLESS_MAXMEM: values.heap ?? "1536M",
JAVA_TOOL_OPTIONS: "-XX:ActiveProcessorCount=2",
REA_GHIDRA_STARTUP_TIMEOUT_MS: "600000",
...(values.project === undefined
? {}
: { REA_GHIDRA_PROJECT_PATH: values.project }),
...(values.program === undefined
? {}
: { REA_GHIDRA_PROGRAM_NAME: values.program }),
HOME: account,
USERPROFILE: account,
NODE_OPTIONS: process.env.NODE_OPTIONS ?? "--max-old-space-size=3072",
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
REA_MCP_OUTPUT_SCHEMA_PROFILE: values.profile,
...(values.families === undefined
? {}
: { REA_MCP_TOOL_FAMILIES: values.families }),
},
stderr: "pipe",
});
const start = transport.start.bind(transport);
transport.start = async () => {
const onerror = transport.onerror;
transport.onerror = (cause) => {
(report.transport_errors ??= []).push(String(cause));
onerror?.(cause);
};
const receive = transport.onmessage;
transport.onmessage = (message) => {
transcript.push({ direction: "response", phase, message });
receive?.(message);
};
await start();
};
const send = transport.send.bind(transport);
transport.send = async (message) => {
transcript.push({ direction: "request", phase, message });
await send(message);
};
serverLog = createWriteStream(join(values.output, "server.log"));
serverLogFinished = finished(serverLog);
serverLogFinished.catch(() => {});
transport.stderr.pipe(serverLog);
client = new Client({
name: "rea-output-profile-investigation",
version: "1",
});
await client.connect(transport);
phase = "discovery-cold";
const discoveryBegan = performance.now();
const catalog = await client.listTools({}, { timeout: 15000 });
report.discovery_ms = performance.now() - discoveryBegan;
report.catalog_bytes = Buffer.byteLength(JSON.stringify(catalog));
report.tools = catalog.tools.length;
await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool]));
const call = async (name, args, label) => {
phase = label;
const at = performance.now();
const result = await client.callTool(
{ name, arguments: args },
{ timeout: 900000, toolDefinition: definitions.get(name) },
);
assert.notEqual(
result.isError,
true,
`${name}: ${JSON.stringify(result).slice(0, 500)}`,
);
report.calls.push({ name, phase, elapsed_ms: performance.now() - at });
return result.structuredContent;
};
let sourceCommitment, projectResources, resolveProject;
if (values.project !== undefined) {
({ resolveGhidraProject: resolveProject } = await import(
pathToFileURL(
join(values.runtime, "dist/ghidra/GhidraProjectSnapshot.js"),
).href
));
const { ArtifactResourceScope } = await import(
pathToFileURL(
join(values.runtime, "dist/artifacts/ArtifactResourceScope.js"),
).href
);
projectResources = new ArtifactResourceScope();
try {
sourceCommitment = await resolveProject(
{
path: values.project,
...(values.program === undefined
? {}
: { programName: values.program }),
},
{ path: values.target },
projectResources,
);
} finally {
await projectResources.close();
}
report.project_sha256 = sourceCommitment.sha256;
}
await call(
"open_binary",
{ path: values.target, provider_id: "ghidra" },
"open",
);
if (["mismatch", "stale"].includes(values.expected)) {
phase = "mismatched-project";
const marker =
values.expected === "stale" ? await readFile(values.project) : undefined;
if (marker !== undefined)
await writeFile(
values.project,
Buffer.concat([marker, Buffer.from("\n")]),
);
let rejected;
try {
rejected = await client.callTool(
{ name: "list_procedures", arguments: { detail: "summary" } },
{ timeout: 900000, toolDefinition: definitions.get("list_procedures") },
);
} finally {
if (marker !== undefined) await writeFile(values.project, marker);
}
assert.equal(rejected.isError, true);
assert.match(
JSON.stringify(rejected),
values.expected === "stale"
? /changed while being read|artifact.changed/iu
: /imported-byte digest does not match/u,
);
await writeFile(
join(values.output, "rejection.json"),
JSON.stringify(rejected),
);
report[`rejected_${values.expected}`] = true;
} else {
const summary = await call(
"list_procedures",
{ detail: "summary" },
"inventory-cold",
);
const parentId = summary.normalized_result.parent_evidence_id;
const count = summary.normalized_result.summary.row_count;
assert.ok(count > 0);
report.procedure_count = count;
const page = await call(
"inspect_analysis_view",
{
source: { kind: "retained-evidence", evidence_id: parentId },
view: {
kind: "native-inventory",
offset: Math.floor(count / 2),
limit: Math.min(count - Math.floor(count / 2), 32),
fields: ["value", "procedure"],
},
},
"selected-procedures",
);
const selected = page.normalized_result.rows
.map((row) => row.record)
.filter(
(row) =>
row.procedure?.external === false && row.procedure?.thunk === false,
)
.slice(0, 2);
assert.equal(selected.length, 2);
await call("binary_overview", {}, "overview");
for (const row of selected)
await call(
"procedure_pseudo_code",
{ procedure: row.address },
`pseudocode:${row.address}`,
);
const warm = await call(
"list_procedures",
{ detail: "summary" },
"inventory-warm",
);
assert.equal(warm.normalized_result.parent_evidence_id, parentId);
const bundlePath = join(values.output, "complete-evidence.json");
await call(
"export_evidence_bundle",
{ path: bundlePath },
"complete-export",
);
await call("close_binary", {}, "close");
await client.close();
await transport.close();
const { parseEvidenceBundle } = await import(
pathToFileURL(join(values.runtime, "dist/domain/evidenceBundle.js")).href
);
const bundle = parseEvidenceBundle(
JSON.parse(await readFile(bundlePath, "utf8")),
);
const parent = bundle.records.find(
(record) => record.evidence_id === parentId,
);
assert.ok(parent);
if (values.history === "required") {
const historical = bundle.records.filter(
(record) =>
record.operation === "health" &&
record.parameters.facet === "historical-project-analysis-reports",
);
assert.equal(historical.length, 1);
assert.equal(historical[0].normalized_result.origin, "caller-project");
assert.ok(historical[0].normalized_result.analysis_seeds);
assert.ok(historical[0].normalized_result.disabled_analyzers);
report.historical_evidence_id = historical[0].evidence_id;
report.historical_evidence_linked =
parent.evidence_links?.includes(historical[0].evidence_id) ?? false;
}
assert.equal(parent.normalized_result.length, count);
const facts = {
procedures: parent.normalized_result,
pseudocode: bundle.records
.filter((record) => record.operation === "procedure_pseudo_code")
.map((record) => ({
procedure: record.parameters.procedure,
result: record.normalized_result,
})),
};
facts.pseudocode.sort((left, right) =>
left.procedure < right.procedure
? -1
: left.procedure > right.procedure
? 1
: 0,
);
report.facts_sha256 = createHash("sha256")
.update(JSON.stringify(facts))
.digest("hex");
await writeFile(join(values.output, "facts.json"), JSON.stringify(facts));
report.analysis_profile = parent.analysis_profile;
assert.equal(parent.subject.digest.sha256, report.target_sha256);
if (sourceCommitment !== undefined) {
assert.equal(
parent.analysis_profile.parameters.existing_project.sha256,
sourceCommitment.sha256,
);
assert.ok(
parent.limitations.some(
(value) =>
value.includes("completeness") && value.includes("unknown"),
),
);
}
if (values.control !== undefined) {
const control = JSON.parse(
await readFile(join(values.control, "facts.json"), "utf8"),
);
assert.deepEqual(facts, control);
}
}
if (sourceCommitment !== undefined) {
const { ArtifactResourceScope } = await import(
pathToFileURL(
join(values.runtime, "dist/artifacts/ArtifactResourceScope.js"),
).href
);
const resources = new ArtifactResourceScope();
let after;
try {
after = await resolveProject(
{
path: values.project,
...(values.program === undefined
? {}
: { programName: values.program }),
},
{ path: values.target },
resources,
);
} finally {
await resources.close();
}
assert.deepEqual(after, sourceCommitment);
report.original_project_unchanged = true;
}
report.outcome = "passed";
} catch (cause) {
report.outcome = "failed";
report.failure = String(cause);
process.exitCode = 1;
} finally {
report.elapsed_ms = performance.now() - began;
const published = await finalizeRetainedQueryReceipt({
report,
client,
transport,
account,
serverLog,
serverLogFinished,
output: values.output,
transcript,
});
if (!published) process.exitCode = 1;
}