mirror of
https://github.com/morluto/rea.git
synced 2026-10-31 20:06:14 +00:00
Move shared function, dossier, inventory, reference and output semantics from the legacy Hopper module to native domain owners. Migrate provider, adapter, test and verifier consumers directly.
766 lines
27 KiB
JavaScript
766 lines
27 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { Client } from "@modelcontextprotocol/client";
|
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
|
import {
|
|
inspectMipsReadelf,
|
|
expectedMipsElfProfile,
|
|
inspectLegacyMipsReadelf,
|
|
assertMipsLoadedImage,
|
|
assertMipsProbe,
|
|
assertMipsGlobal,
|
|
assertMipsSoftFloatStorage,
|
|
} from "../tests/conformance/ghidra/mips-checks.mjs";
|
|
import { parseEvidence } from "../dist/domain/evidence.js";
|
|
import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
|
|
import { functionDossierSchema } from "../dist/domain/native/functionDossier.js";
|
|
import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs";
|
|
import { buildMipsFixture } from "../tests/conformance/ghidra/mips-fixture.mjs";
|
|
import {
|
|
buildPspFixture,
|
|
assertPspLoadedImage,
|
|
assertPspProbe,
|
|
} from "../tests/conformance/ghidra/psp-fixture.mjs";
|
|
import {
|
|
requireMcpToolError,
|
|
requireMcpOperationResult,
|
|
} from "./lib/mcp-verifier-results.mjs";
|
|
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
|
|
|
// Optional cross-target lane. No tool acquisition and no target execution.
|
|
const mode = process.argv[2];
|
|
const psp = mode === "--psp" || mode === "--psp-missing-extension";
|
|
const missingExtension = mode === "--psp-missing-extension";
|
|
if (process.argv.length > 3 || (mode !== undefined && !psp))
|
|
throw new Error(
|
|
"Usage: node scripts/verify-real-ghidra-mips.mjs [--psp|--psp-missing-extension]",
|
|
);
|
|
const prefix = psp ? "rea_psp" : "rea_mips";
|
|
if (!["linux", "darwin"].includes(process.platform))
|
|
throw new Error(
|
|
"MIPS verification requires a supported Linux/macOS Ghidra host",
|
|
);
|
|
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
|
throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR");
|
|
const installation = await inspectVerifierGhidraInstallation({
|
|
environment: process.env,
|
|
installDir: process.env.GHIDRA_INSTALL_DIR,
|
|
...(process.env.JAVA_HOME === undefined
|
|
? {}
|
|
: { javaHome: process.env.JAVA_HOME }),
|
|
});
|
|
if (installation.status !== "available")
|
|
throw new Error(
|
|
`Ghidra prerequisite unavailable: ${JSON.stringify(installation)}`,
|
|
);
|
|
const execute = promisify(execFile);
|
|
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
|
const run = createVerifierRun();
|
|
const workspace = await mkdtemp(join(tmpdir(), "rea-mips-proof-"));
|
|
const runtime = join(workspace, "runtime");
|
|
await mkdir(runtime);
|
|
const env = {
|
|
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
TMPDIR: runtime,
|
|
REA_LOG_LEVEL: "silent",
|
|
REA_ANALYSIS_PROVIDER: "ghidra",
|
|
REA_PROCESS_RUN_ID: run.run_id,
|
|
GHIDRA_INSTALL_DIR: installation.installDir,
|
|
JAVA_HOME: installation.javaHome,
|
|
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
|
|
};
|
|
for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
|
|
if (process.env[setting] !== undefined) env[setting] = process.env[setting];
|
|
}
|
|
|
|
/**
|
|
* Typed float signatures must use soft-float o32 storage, and callers must see
|
|
* the source constants through it rather than FPU inputs.
|
|
*/
|
|
async function verifySoftFloat(call, symbols, byteOrder) {
|
|
const boundaries = {};
|
|
for (const name of [
|
|
"rea_mips_soft_pick",
|
|
"rea_mips_soft_wide",
|
|
"rea_mips_soft_mixed",
|
|
"rea_mips_soft_struct",
|
|
]) {
|
|
const api = await call("inspect_native_api", { procedure: symbols[name] });
|
|
boundaries[name] = api.boundary;
|
|
}
|
|
assertMipsSoftFloatStorage(boundaries, byteOrder);
|
|
const caller = functionDossierSchema.parse(
|
|
await call("analyze_function", {
|
|
procedure: symbols.rea_mips_soft_caller,
|
|
detail: "complete",
|
|
}),
|
|
);
|
|
// Includes the struct call and the indirect call through a typed pointer.
|
|
assert.doesNotMatch(caller.pseudocode, /\bin_f\d+\b/u);
|
|
for (const literal of [
|
|
"1.5",
|
|
"2.5",
|
|
"3.25",
|
|
"4.75",
|
|
"5.5",
|
|
"6.125",
|
|
"7.5",
|
|
"8.5",
|
|
"9.5",
|
|
])
|
|
assert.ok(
|
|
caller.pseudocode.includes(literal),
|
|
`Soft-float caller lost argument ${literal}`,
|
|
);
|
|
return { storage: boundaries };
|
|
}
|
|
function assertIncompleteMipsInspection(error) {
|
|
assert.equal(error.code, "capability_unavailable");
|
|
assert.equal(error.details.selection_reason, "target_unsupported");
|
|
const rejection = error.details.rejections.find(
|
|
(candidate) => candidate.provider_id === "ghidra",
|
|
);
|
|
assert.equal(rejection?.code, "architecture_unsupported");
|
|
assert.match(rejection.reason, /ABI declaration inspection is incomplete/u);
|
|
assert.match(rejection.reason, /section header table/u);
|
|
}
|
|
|
|
const reports = [];
|
|
let primaryFailure;
|
|
try {
|
|
if (!psp) {
|
|
// Recordless legacy admission: ABI field 0, no ABI flags record, pinned
|
|
// 32-bit language. Independent GNU readelf checks the header interpretation.
|
|
for (const byteOrder of ["little", "big"]) {
|
|
const fixture = await buildMipsFixture(workspace, byteOrder, "hard", {
|
|
legacy: true,
|
|
});
|
|
const legacy = await inspectLegacyMipsReadelf(fixture.path, byteOrder);
|
|
const resolved = await parseBinaryTarget(fixture.path);
|
|
if (!resolved.ok) throw resolved.error;
|
|
assert.equal(resolved.value.mips.flags, 0x50000001);
|
|
assert.equal(resolved.value.mips.abiFlags, null);
|
|
assert.deepEqual(
|
|
resolved.value.mips.limitations ?? [],
|
|
legacy.limitations,
|
|
"REA legacy ABI-record absence interpretation disagrees with GNU readelf",
|
|
);
|
|
assert.equal(resolved.value.sha256, fixture.sha256);
|
|
const snapshotPath = join(workspace, `${byteOrder}-legacy.snapshot.json`);
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [entrypoint, "mcp"],
|
|
env,
|
|
stderr: "pipe",
|
|
});
|
|
const client = new Client({
|
|
name: "rea-real-mips-legacy-proof",
|
|
version: "1",
|
|
});
|
|
let stderr = "";
|
|
transport.stderr?.on("data", (chunk) => {
|
|
stderr = (stderr + chunk.toString()).slice(-65536);
|
|
});
|
|
const call = async (name, args = {}) => {
|
|
const reply = await client.callTool(
|
|
{ name, arguments: args },
|
|
undefined,
|
|
{ timeout: 180000 },
|
|
);
|
|
return requireMcpOperationResult(reply, name);
|
|
};
|
|
try {
|
|
await client.connect(transport);
|
|
const selected = await call("open_binary", {
|
|
path: fixture.path,
|
|
provider_id: "ghidra",
|
|
});
|
|
assert.equal(selected.architecture, "mips");
|
|
assert.equal(selected.sha256, fixture.sha256);
|
|
const image = await call("inspect_native_load_image");
|
|
assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
|
|
// The pinned import language is the checked one, not an auto-selection.
|
|
assert.equal(
|
|
image.observations.language_id,
|
|
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
|
|
);
|
|
const procedures = await call("list_procedures");
|
|
const entry = procedures.find((p) => p.value === "rea_mips_entry");
|
|
assert.ok(entry, "Entry function missing from legacy inventory");
|
|
assert.equal(
|
|
BigInt(entry.address),
|
|
BigInt(legacy.symbols.rea_mips_entry),
|
|
);
|
|
const probe = await call("inspect_native_instruction", {
|
|
address: legacy.symbols.rea_mips_probe,
|
|
});
|
|
const probeBranch = await call("inspect_native_instruction", {
|
|
address: `0x${(BigInt(legacy.symbols.rea_mips_probe) + 4n).toString(16)}`,
|
|
});
|
|
assertMipsProbe(
|
|
probe,
|
|
probeBranch,
|
|
byteOrder,
|
|
legacy.symbols.rea_mips_probe,
|
|
);
|
|
const dossier = functionDossierSchema.parse(
|
|
await call("analyze_function", {
|
|
procedure: entry.address,
|
|
detail: "complete",
|
|
}),
|
|
);
|
|
assert.ok(dossier.pseudocode.trim().length > 0);
|
|
const saved = await call("close_binary", {
|
|
snapshot_path: snapshotPath,
|
|
});
|
|
assert.ok(saved.evidence_records > 0);
|
|
const snapshot = parseAnalysisSnapshot(
|
|
JSON.parse(await readFile(snapshotPath, "utf8")),
|
|
);
|
|
assert.equal(snapshot.target.architecture, "mips");
|
|
const parameters = snapshot.binding.analysis_profile.parameters;
|
|
assert.equal(
|
|
parameters.language_id,
|
|
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
|
|
);
|
|
assert.equal(parameters.language_selection, "provider-pinned-v1");
|
|
assert.equal(
|
|
parameters.mips_support_lane,
|
|
"elf32-exec-arch32-legacy-pinned-v1",
|
|
);
|
|
assert.equal(parameters.mips_abi_basis, "inferred-o32-assumption-v1");
|
|
assert.ok(
|
|
snapshot.evidence_bundle.records.some((record) =>
|
|
parseEvidence(record).limitations.some((item) =>
|
|
item.includes("analysis assumption"),
|
|
),
|
|
),
|
|
"Legacy o32 assumption limitation missing from retained Evidence",
|
|
);
|
|
assert.ok(
|
|
snapshot.evidence_bundle.records.some((record) =>
|
|
parseEvidence(record).limitations.some((item) =>
|
|
item.includes("Floating-point ABI semantics are unverified"),
|
|
),
|
|
),
|
|
"Legacy floating-point uncertainty missing from retained Evidence",
|
|
);
|
|
await call("open_binary", {
|
|
path: fixture.path,
|
|
provider_id: "ghidra",
|
|
snapshot_path: snapshotPath,
|
|
});
|
|
assert.deepEqual(await call("list_procedures"), procedures);
|
|
await call("close_binary");
|
|
} catch (cause) {
|
|
throw new Error(
|
|
`MIPS legacy ${byteOrder} verification failed; MCP stderr: ${stderr}`,
|
|
{ cause },
|
|
);
|
|
} finally {
|
|
try {
|
|
await client.close();
|
|
} finally {
|
|
await transport.close();
|
|
}
|
|
}
|
|
reports.push({
|
|
...fixture,
|
|
independent_reader: legacy,
|
|
variant: "recordless-legacy-pinned",
|
|
effective_language: `MIPS:${
|
|
byteOrder === "little" ? "LE" : "BE"
|
|
}:32:default`,
|
|
procedures: "verified-through-snapshot",
|
|
checks: [
|
|
"recordless header admitted with inferred o32 assumption",
|
|
"pinned 32-bit language checked after import",
|
|
"legacy limitations retained in Evidence",
|
|
"snapshot round-trip and profile parameters",
|
|
],
|
|
status: "passed",
|
|
});
|
|
}
|
|
}
|
|
for (const [byteOrder, floatAbi] of psp
|
|
? [["little", "hard"]]
|
|
: [
|
|
["little", "hard"],
|
|
["big", "hard"],
|
|
["little", "soft"],
|
|
["big", "soft"],
|
|
]) {
|
|
const fixture = psp
|
|
? await buildPspFixture(workspace)
|
|
: await buildMipsFixture(workspace, byteOrder, floatAbi);
|
|
const independent = psp
|
|
? fixture
|
|
: await inspectMipsReadelf(fixture.path, byteOrder, floatAbi);
|
|
const resolved = await parseBinaryTarget(fixture.path);
|
|
if (!resolved.ok) throw resolved.error;
|
|
if (psp) {
|
|
assert.equal(resolved.value.mips.flags, 0x10a23001);
|
|
assert.deepEqual(
|
|
resolved.value.mips,
|
|
independent.mips,
|
|
"REA PSP ELF/ABI interpretation disagrees with GNU readelf and raw ABI bytes",
|
|
);
|
|
} else {
|
|
assert.deepEqual(
|
|
resolved.value.mips,
|
|
independent.mips,
|
|
"REA ELF/ABI interpretation disagrees with GNU readelf",
|
|
);
|
|
}
|
|
assert.equal(resolved.value.sha256, fixture.sha256);
|
|
const snapshotPath = join(
|
|
workspace,
|
|
`${byteOrder}-${floatAbi}.snapshot.json`,
|
|
);
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [entrypoint, "mcp"],
|
|
env,
|
|
stderr: "pipe",
|
|
});
|
|
const client = new Client({ name: "rea-real-mips-proof", version: "1" });
|
|
let stderr = "";
|
|
transport.stderr?.on("data", (chunk) => {
|
|
stderr = (stderr + chunk.toString()).slice(-65536);
|
|
});
|
|
const call = async (name, args = {}) => {
|
|
const reply = await client.callTool(
|
|
{ name, arguments: args },
|
|
{ timeout: 180000 },
|
|
);
|
|
return requireMcpOperationResult(reply, name);
|
|
};
|
|
try {
|
|
await client.connect(transport);
|
|
if (missingExtension) {
|
|
const rejected = await client.callTool(
|
|
{
|
|
name: "open_binary",
|
|
arguments: { path: fixture.path, provider_id: "ghidra" },
|
|
},
|
|
{ timeout: 60000 },
|
|
);
|
|
const error = requireMcpToolError(rejected);
|
|
assert.match(JSON.stringify(error), /PSP_EXTENSION_UNAVAILABLE/u);
|
|
let cliError;
|
|
try {
|
|
await execute(
|
|
process.execPath,
|
|
[
|
|
entrypoint,
|
|
"function",
|
|
fixture.path,
|
|
`${prefix}_entry`,
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{ env, timeout: 60000, maxBuffer: 1024 * 1024 },
|
|
);
|
|
} catch (cause) {
|
|
cliError = cause;
|
|
}
|
|
assert.ok(
|
|
cliError && Number.isInteger(cliError.code) && cliError.code !== 0,
|
|
"Expected a typed public rejection, not a timeout or success",
|
|
);
|
|
assert.match(
|
|
`${cliError.stdout}\n${cliError.stderr}`,
|
|
/PSP_EXTENSION_UNAVAILABLE/u,
|
|
);
|
|
reports.push({
|
|
path: fixture.path,
|
|
status: "expected-missing-extension-refusal",
|
|
cli_exit: cliError.code,
|
|
mcp_error: error,
|
|
});
|
|
continue;
|
|
}
|
|
const target = await call("open_binary", {
|
|
path: fixture.path,
|
|
provider_id: "ghidra",
|
|
});
|
|
assert.equal(target.architecture, "mips");
|
|
assert.equal(target.sha256, fixture.sha256);
|
|
// ELF load-image verification is not the DOS comparison lane. Its retained
|
|
// observations still expose the actual Ghidra language and loaded bytes.
|
|
const image = await call("inspect_native_load_image");
|
|
if (psp) assertPspLoadedImage(image.observations, fixture.sha256);
|
|
else assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
|
|
const procedures = await call("list_procedures");
|
|
const entry = procedures.find((p) => p.value === `${prefix}_entry`);
|
|
const leaf = procedures.find((p) => p.value === `${prefix}_leaf`);
|
|
assert.ok(entry, "Entry function missing from real Ghidra inventory");
|
|
assert.ok(leaf, "Leaf function missing from real Ghidra inventory");
|
|
assert.equal(
|
|
BigInt(entry.address),
|
|
BigInt(independent.symbols[`${prefix}_entry`]),
|
|
);
|
|
assert.equal(
|
|
BigInt(leaf.address),
|
|
BigInt(independent.symbols[`${prefix}_leaf`]),
|
|
);
|
|
const probe = independent.symbols[`${prefix}_probe`];
|
|
assert.ok(
|
|
procedures.some(
|
|
(p) =>
|
|
p.value === `${prefix}_probe` &&
|
|
BigInt(p.address) === BigInt(probe),
|
|
),
|
|
);
|
|
const move = await call("inspect_native_instruction", { address: probe });
|
|
const branch = await call("inspect_native_instruction", {
|
|
address: `0x${(BigInt(probe) + 4n).toString(16)}`,
|
|
});
|
|
if (psp) assertPspProbe(move, branch);
|
|
else assertMipsProbe(move, branch, byteOrder, probe);
|
|
const global = await call("read_bytes", {
|
|
address: independent.symbols[`${prefix}_global`],
|
|
length: 4,
|
|
});
|
|
assert.equal(global.returned_bytes, 4);
|
|
assertMipsGlobal(global.bytes_hex, byteOrder);
|
|
const marker = await call("read_bytes", {
|
|
address: independent.symbols[`${prefix}_marker`],
|
|
length: Buffer.byteLength(
|
|
psp
|
|
? "rea-psp-source-owned-fixture\0"
|
|
: "rea-mips-source-owned-fixture\0",
|
|
),
|
|
});
|
|
assert.equal(
|
|
marker.bytes_hex,
|
|
Buffer.from(
|
|
psp
|
|
? "rea-psp-source-owned-fixture\0"
|
|
: "rea-mips-source-owned-fixture\0",
|
|
).toString("hex"),
|
|
);
|
|
const callees = await call("procedure_callees", {
|
|
procedure: entry.address,
|
|
});
|
|
assert.ok(
|
|
callees.includes(leaf.address),
|
|
"Expected direct call not recovered",
|
|
);
|
|
const softFloat =
|
|
floatAbi === "soft"
|
|
? await verifySoftFloat(call, independent.symbols, byteOrder)
|
|
: undefined;
|
|
const assembly = await call("procedure_assembly", {
|
|
procedure: entry.address,
|
|
});
|
|
assert.ok(assembly.trim().length > 0);
|
|
const dossier = functionDossierSchema.parse(
|
|
await call("analyze_function", {
|
|
procedure: entry.address,
|
|
detail: "complete",
|
|
}),
|
|
);
|
|
assert.ok(dossier.pseudocode.trim().length > 0);
|
|
const mapping = await call("address_to_file_offset", {
|
|
address: entry.address,
|
|
});
|
|
const memory = await call("read_bytes", {
|
|
address: entry.address,
|
|
length: 16,
|
|
});
|
|
const sourceBytes = await readFile(fixture.path);
|
|
assert.equal(memory.returned_bytes, 16);
|
|
assert.equal(
|
|
memory.bytes_hex,
|
|
sourceBytes
|
|
.subarray(mapping.file_offset, mapping.file_offset + 16)
|
|
.toString("hex"),
|
|
);
|
|
const saved = await call("close_binary", {
|
|
snapshot_path: snapshotPath,
|
|
});
|
|
assert.ok(saved.evidence_records > 0);
|
|
const snapshot = parseAnalysisSnapshot(
|
|
JSON.parse(await readFile(snapshotPath, "utf8")),
|
|
);
|
|
assert.equal(snapshot.target.architecture, "mips");
|
|
assert.deepEqual(
|
|
snapshot.binding.analysis_profile.parameters.mips_elf,
|
|
expectedMipsElfProfile(independent.mips),
|
|
"Snapshot profile did not retain the independently inspected ELF/ABI declaration",
|
|
);
|
|
assert.equal(
|
|
snapshot.binding.analysis_profile.parameters.mips_soft_float_storage,
|
|
floatAbi === "soft" ? "o32-soft-float-prototype-v1" : undefined,
|
|
);
|
|
assert.equal(
|
|
snapshot.evidence_bundle.records.some((record) =>
|
|
/Soft-float prototype outcome: 4 functions and [1-9]\d* function-pointer definitions use __rea_soft_float\./u.test(
|
|
JSON.stringify(record),
|
|
),
|
|
),
|
|
floatAbi === "soft",
|
|
"Soft-float prototype outcome missing from, or unexpected in, retained Evidence",
|
|
);
|
|
assert.ok(snapshot.evidence_bundle.records.length > 0);
|
|
if (psp) {
|
|
assert.equal(
|
|
snapshot.binding.analysis_profile.parameters.language_id,
|
|
"Allegrex:LE:32:default",
|
|
);
|
|
assert.match(
|
|
snapshot.binding.analysis_profile.parameters.psp_extension.sha256,
|
|
/^[a-f0-9]{64}$/u,
|
|
);
|
|
assert.ok(
|
|
snapshot.evidence_bundle.records.some((record) =>
|
|
JSON.stringify(record).includes("VFPU prefix"),
|
|
),
|
|
"PSP limitations missing from retained Evidence",
|
|
);
|
|
}
|
|
for (const evidence of snapshot.evidence_bundle.records)
|
|
assert.equal(parseEvidence(evidence).subject?.architecture, "mips");
|
|
await call("open_binary", {
|
|
path: fixture.path,
|
|
provider_id: "ghidra",
|
|
snapshot_path: snapshotPath,
|
|
});
|
|
assert.deepEqual(await call("list_procedures"), procedures);
|
|
await call("close_binary");
|
|
|
|
if (psp) {
|
|
const alternate = join(workspace, "alternate");
|
|
await mkdir(alternate);
|
|
const other = await buildPspFixture(alternate, 11);
|
|
assert.notEqual(other.sha256, fixture.sha256);
|
|
assert.equal(
|
|
other.symbols.rea_psp_global,
|
|
fixture.symbols.rea_psp_global,
|
|
"Target-switch control must reuse the same global address",
|
|
);
|
|
for (const item of [fixture, other, fixture]) {
|
|
const selected = await call("open_binary", {
|
|
path: item.path,
|
|
provider_id: "ghidra",
|
|
});
|
|
assert.equal(selected.sha256, item.sha256);
|
|
const word = await call("read_bytes", {
|
|
address: item.symbols.rea_psp_global,
|
|
length: 4,
|
|
});
|
|
const expected = Buffer.alloc(4);
|
|
expected.writeUInt32LE(item.global_value);
|
|
assert.equal(word.bytes_hex, expected.toString("hex"));
|
|
await call("close_binary");
|
|
}
|
|
const wrongSnapshot = await client.callTool(
|
|
{
|
|
name: "open_binary",
|
|
arguments: {
|
|
path: other.path,
|
|
provider_id: "ghidra",
|
|
snapshot_path: snapshotPath,
|
|
},
|
|
},
|
|
{ timeout: 180000 },
|
|
);
|
|
requireMcpToolError(wrongSnapshot);
|
|
// A bad snapshot must not poison the next clean public session.
|
|
await call("open_binary", {
|
|
path: fixture.path,
|
|
provider_id: "ghidra",
|
|
});
|
|
await call("close_binary");
|
|
}
|
|
|
|
// Same public operation through CLI, not a private injected provider.
|
|
const cli = await execute(
|
|
process.execPath,
|
|
[
|
|
entrypoint,
|
|
"function",
|
|
fixture.path,
|
|
`${prefix}_entry`,
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
const evidence = parseEvidence(JSON.parse(cli.stdout));
|
|
assert.equal(evidence.subject?.architecture, "mips");
|
|
assert.equal(evidence.subject?.digest.sha256, fixture.sha256);
|
|
const cliDossier = functionDossierSchema.parse(
|
|
evidence.normalized_result,
|
|
);
|
|
assert.equal(cliDossier.procedure.address, dossier.procedure.address);
|
|
assert.ok(cliDossier.pseudocode.trim().length > 0);
|
|
assert.equal(
|
|
createHash("sha256")
|
|
.update(await readFile(fixture.path))
|
|
.digest("hex"),
|
|
fixture.sha256,
|
|
"Analysis modified the source executable",
|
|
);
|
|
// This upstream carved-firmware proof is specific to the generic MIPS fixture.
|
|
// PSP keeps its independent Allegrex/A-B-A checks; carved PSP is not verified.
|
|
let carved;
|
|
if (!psp) {
|
|
// A program-header ABI record survives carving, but GNU declarations in
|
|
// uncaptured section headers remain unknown: both adapters must refuse.
|
|
const read16 = (offset) =>
|
|
byteOrder === "little"
|
|
? sourceBytes.readUInt16LE(offset)
|
|
: sourceBytes.readUInt16BE(offset);
|
|
const read32 = (offset) =>
|
|
byteOrder === "little"
|
|
? sourceBytes.readUInt32LE(offset)
|
|
: sourceBytes.readUInt32BE(offset);
|
|
const sectionOffset = read32(32);
|
|
const cut = sectionOffset - 1;
|
|
const programOffset = read32(28);
|
|
const programCount = read16(44);
|
|
assert.equal(read16(42), 32);
|
|
assert.ok(cut >= programOffset + programCount * 32);
|
|
for (let i = 0; i < programCount; i += 1) {
|
|
const offset = programOffset + i * 32;
|
|
assert.ok(read32(offset + 4) + read32(offset + 16) <= cut);
|
|
}
|
|
const carvedBytes = sourceBytes.subarray(0, cut);
|
|
const carvedPath = join(
|
|
workspace,
|
|
`${byteOrder}-${floatAbi}-carved.elf`,
|
|
);
|
|
const carvedSha256 = createHash("sha256")
|
|
.update(carvedBytes)
|
|
.digest("hex");
|
|
await writeFile(carvedPath, carvedBytes);
|
|
const carvedResolved = await parseBinaryTarget(carvedPath);
|
|
if (!carvedResolved.ok) throw carvedResolved.error;
|
|
assert.deepEqual(
|
|
carvedResolved.value.mips.abiFlags,
|
|
independent.mips.abiFlags,
|
|
);
|
|
assert.match(
|
|
carvedResolved.value.mips.limitations.join(" "),
|
|
/section header table/u,
|
|
);
|
|
const carvedError = requireMcpToolError(
|
|
await client.callTool(
|
|
{
|
|
name: "open_binary",
|
|
arguments: { path: carvedPath, provider_id: "ghidra" },
|
|
},
|
|
{ timeout: 60000 },
|
|
),
|
|
);
|
|
assertIncompleteMipsInspection(carvedError);
|
|
let carvedCliError;
|
|
try {
|
|
await execute(
|
|
process.execPath,
|
|
[
|
|
entrypoint,
|
|
"function",
|
|
carvedPath,
|
|
independent.symbols.rea_mips_entry,
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{ env, timeout: 60000, maxBuffer: 1024 * 1024 },
|
|
);
|
|
} catch (cause) {
|
|
carvedCliError = cause;
|
|
}
|
|
assert.ok(
|
|
carvedCliError &&
|
|
Number.isInteger(carvedCliError.code) &&
|
|
carvedCliError.code !== 0,
|
|
"Expected an incomplete-ABI refusal, not a timeout or success",
|
|
);
|
|
assertIncompleteMipsInspection(JSON.parse(carvedCliError.stdout));
|
|
assert.deepEqual(await readFile(carvedPath), carvedBytes);
|
|
carved = {
|
|
sha256: carvedSha256,
|
|
source_bytes: carvedBytes.length,
|
|
section_header_offset: sectionOffset,
|
|
status: "expected-incomplete-abi-refusal",
|
|
limitations: carvedResolved.value.mips.limitations,
|
|
cli_exit: carvedCliError.code,
|
|
mcp_error: carvedError,
|
|
};
|
|
}
|
|
reports.push({
|
|
...fixture,
|
|
independent_reader: independent,
|
|
effective_language: image.observations.language_id,
|
|
effective_compiler_spec: image.observations.compiler_spec_id,
|
|
checks: [
|
|
"independent ELF/ABI and symbols",
|
|
"loaded Ghidra identity",
|
|
"fixed instruction bytes and immediate",
|
|
psp
|
|
? "Allegrex BITREV decoding and A-B-A target identity"
|
|
: "conditional branch destination",
|
|
"global word and marker bytes",
|
|
"CLI/MCP and snapshot identity",
|
|
...(floatAbi === "soft"
|
|
? ["soft-float o32 parameter/return storage and caller constants"]
|
|
: []),
|
|
],
|
|
...(softFloat === undefined ? {} : { soft_float: softFloat }),
|
|
procedures: procedures.length,
|
|
carved: carved ?? {
|
|
status: "not_run",
|
|
reason:
|
|
"Carved-firmware verification covers generic MIPS fixtures; carved PSP coverage is not established.",
|
|
},
|
|
status: "passed",
|
|
});
|
|
} catch (cause) {
|
|
throw new Error(
|
|
`MIPS ${byteOrder} ${floatAbi}-float verification failed; MCP stderr: ${stderr}`,
|
|
{ cause },
|
|
);
|
|
} finally {
|
|
try {
|
|
await client.close();
|
|
} finally {
|
|
await transport.close();
|
|
}
|
|
}
|
|
}
|
|
} catch (cause) {
|
|
primaryFailure = cause;
|
|
}
|
|
const completed = await completeVerifierRun(run);
|
|
if (
|
|
completed.process_lineage.status !== "verified" ||
|
|
completed.process_lineage.descendants.length !== 0
|
|
)
|
|
throw new Error(
|
|
`Cleanup unverified; retained diagnostic workspace: ${workspace}`,
|
|
{ cause: primaryFailure },
|
|
);
|
|
if (primaryFailure !== undefined)
|
|
throw new Error(
|
|
`MIPS verification failed; retained workspace: ${workspace}`,
|
|
{
|
|
cause: primaryFailure,
|
|
},
|
|
);
|
|
await rm(workspace, { recursive: true, force: true });
|
|
console.log(JSON.stringify({ ...completed, fixtures: reports }, null, 2));
|