From 3368a036ae131f16adae818878eee2164cc7a1c8 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:34:19 +0800 Subject: [PATCH] test(ghidra): verify saved project evidence bundles --- .../verify-real-ghidra-function-bundle.mjs | 178 ++++++++++++++++++ tests/conformance/ghidra/README.md | 6 + 2 files changed, 184 insertions(+) create mode 100644 scripts/verify-real-ghidra-function-bundle.mjs diff --git a/scripts/verify-real-ghidra-function-bundle.mjs b/scripts/verify-real-ghidra-function-bundle.mjs new file mode 100644 index 0000000000..59d7315285 --- /dev/null +++ b/scripts/verify-real-ghidra-function-bundle.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node + +// Exercise the public one-shot CLI on a caller-owned saved project. The report +// is intentionally synthetic; it is not a claim about Ghidra's analysis history. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const installation = process.env.GHIDRA_INSTALL_DIR; +if (!installation || !process.env.JAVA_HOME) + throw new Error( + "Set GHIDRA_INSTALL_DIR and JAVA_HOME to caller-installed Ghidra and JDK 21.", + ); +const root = await mkdtemp(join(tmpdir(), "rea-function-bundle-")); +const target = join(root, "fixture"); +const projectDir = join(root, "project"); +const scriptDir = join(root, "scripts"); +const source = join(root, "fixture.c"); +const project = join(projectDir, "SourceControl.gpr"); +const repo = resolve(process.cwd()); +const hash = (data) => createHash("sha256").update(data).digest("hex"); +const tree = async (directory, prefix = "") => { + const result = []; + for (const entry of (await readdir(directory, { withFileTypes: true })).sort( + (a, b) => a.name.localeCompare(b.name), + )) { + const path = join(directory, entry.name); + const relative = join(prefix, entry.name); + if (entry.isDirectory()) result.push(...(await tree(path, relative))); + else if (entry.isFile()) + result.push([relative, hash(await readFile(path))]); + else throw new Error(`Unexpected project entry: ${relative}`); + } + return result; +}; +const run = async (command, args, options = {}) => + exec(command, args, { + timeout: 120_000, + maxBuffer: 16 * 1024 * 1024, + ...options, + }); +try { + await mkdir(projectDir); + await mkdir(scriptDir); + await writeFile( + source, + "__attribute__((noinline)) int leaf(int x) { return x + 1; }\nint main(void) { return leaf(2); }\n", + ); + await writeFile( + join(scriptDir, "SetHistory.java"), + `import ghidra.app.script.GhidraScript; +import ghidra.program.model.listing.Program; +public class SetHistory extends GhidraScript { + public void run() throws Exception { + currentProgram.getOptions(Program.PROGRAM_INFO).setString("REA disabled analyzers", "{\\"format\\":\\"rea-ghidra-disabled-analyzers-v1\\",\\"analyzers\\":[{\\"name\\":\\"Synthetic historical control\\",\\"status\\":\\"disabled\\"}]}"); + } +} +`, + ); + await run(process.env.REA_CC ?? "cc", ["-O0", "-g", source, "-o", target]); + await run(join(installation, "support", "analyzeHeadless"), [ + projectDir, + "SourceControl", + "-import", + target, + "-scriptPath", + scriptDir, + "-postScript", + "SetHistory.java", + ]); + const before = await tree(projectDir); + const targetHash = hash(await readFile(target)); + const env = { + ...process.env, + REA_GHIDRA_PROJECT_PATH: project, + REA_GHIDRA_PROGRAM_NAME: "fixture", + }; + const cli = join(repo, "scripts", "rea.mjs"); + for (const detail of ["complete", "summary"]) { + const output = join(root, `${detail}.json`); + const { stdout } = await run( + process.execPath, + [ + cli, + "function", + target, + "main", + "--provider", + "ghidra", + "--detail", + detail, + "--evidence_output", + output, + "--format", + "json", + "--full-output", + ], + { env }, + ); + assert.equal(JSON.parse(stdout).ok, true); + const bundle = JSON.parse(await readFile(output, "utf8")); + const parent = bundle.records.find( + (record) => record.operation === "analyze_function", + ); + assert.ok(parent, "function observation missing from bundle"); + assert.ok( + parent.evidence_links.length, + "fixture did not produce a linked historical source", + ); + const records = new Map( + bundle.records.map((record) => [record.evidence_id, record]), + ); + for (const record of bundle.records) + for (const link of record.evidence_links) + assert.ok(records.has(link), `bundle omits linked Evidence ${link}`); + assert.ok( + parent.evidence_links.some( + (id) => records.get(id)?.operation === "health", + ), + "historical health source missing", + ); + } + const existing = join(root, "existing.json"); + await writeFile(existing, "caller-owned\n"); + let failure; + try { + await run( + process.execPath, + [ + cli, + "function", + target, + "main", + "--provider", + "ghidra", + "--detail", + "summary", + "--evidence_output", + existing, + "--format", + "json", + "--full-output", + ], + { env }, + ); + assert.fail("existing output unexpectedly overwritten"); + } catch (error) { + if (error.code !== 1) throw error; + failure = JSON.parse(error.stdout); + } + assert.equal(await readFile(existing, "utf8"), "caller-owned\n"); + assert.equal(failure.ok, false); + assert.equal(failure.error.details.source_closure.complete, true); + assert.ok( + failure.error.details.source_closure.records.some( + (record) => record.operation === "health", + ), + ); + assert.equal(hash(await readFile(target)), targetHash); + assert.deepEqual(await tree(projectDir), before); + process.stdout.write( + `PASS Ghidra function bundles: complete, summary, no-overwrite; target and project unchanged (${targetHash})\n`, + ); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index 580dc41820..774f37aab2 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -3,6 +3,12 @@ These source-owned fixtures are compiled at verification time; generated binaries and Ghidra projects are never committed. +For the one-shot function bundle handoff, run `npm run build:cached` and then +`node scripts/verify-real-ghidra-function-bundle.mjs` with `GHIDRA_INSTALL_DIR` +and `JAVA_HOME` set. The verifier compiles its own target, creates a project +with a synthetic history report, and exercises complete, summary, and +no-overwrite CLI delivery. It checks the target and project files for changes. + - `../c/riscv.c` and `riscv-probe.S` produce source-owned RV32/RV64 ELF images with double-float ABI declarations and an independently known instruction. `../c/riscv-float-abi.c`, `riscv-probe.S` and `riscv-isa-probe.S` produce