480 lines
15 KiB
JavaScript
480 lines
15 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
symlink,
|
|
unlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
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 { parseEvidence } from "../dist/domain/evidence.js";
|
|
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
|
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
|
|
|
if (process.argv.length !== 2)
|
|
throw new Error("Usage: node scripts/verify-real-ghidra-6502.mjs");
|
|
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
|
throw new Error(
|
|
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
|
|
);
|
|
const exec = promisify(execFile);
|
|
const run = createVerifierRun();
|
|
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
const ca65 = process.env.REA_CA65 ?? "ca65";
|
|
const ld65 = process.env.REA_LD65 ?? "ld65";
|
|
const toolchain = {};
|
|
for (const [name, command] of Object.entries({ ca65, ld65 })) {
|
|
const version = await exec(command, ["--version"], { timeout: 10000 });
|
|
toolchain[name] = (version.stdout + version.stderr).trim();
|
|
}
|
|
await mkdir(join(root, "build"), { recursive: true });
|
|
const workspace = await mkdtemp(join(root, "build", "rea-6502-proof-"));
|
|
const runtime = join(workspace, "runtime");
|
|
await mkdir(runtime);
|
|
const path = join(workspace, "image.bin");
|
|
const object = join(workspace, "image.o");
|
|
const entrypoint = join(root, "scripts", "rea.mjs");
|
|
const env = {
|
|
...process.env,
|
|
TMPDIR: runtime,
|
|
TMP: runtime,
|
|
TEMP: runtime,
|
|
REA_LOG_LEVEL: "silent",
|
|
REA_ANALYSIS_PROVIDER: "ghidra",
|
|
REA_PROCESS_RUN_ID: run.run_id,
|
|
};
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [entrypoint, "mcp"],
|
|
env,
|
|
stderr: "pipe",
|
|
});
|
|
const client = new Client({ name: "rea-real-6502-proof", version: "1" });
|
|
let stderr = "";
|
|
transport.stderr?.on("data", (chunk) => {
|
|
stderr = (stderr + chunk.toString()).slice(-65536);
|
|
});
|
|
let report;
|
|
try {
|
|
await exec(
|
|
ca65,
|
|
[join(root, "tests/conformance/ghidra/6502.s"), "-o", object],
|
|
{ timeout: 10000 },
|
|
);
|
|
await exec(
|
|
ld65,
|
|
["-C", join(root, "tests/conformance/ghidra/6502.cfg"), "-o", path, object],
|
|
{ timeout: 10000 },
|
|
);
|
|
const bytes = await readFile(path);
|
|
// Independently known opcodes and link addresses from the source fixture.
|
|
assert.equal(bytes.toString("hex"), "a907201080c90ad003a92a60a9006018690360");
|
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
await client.connect(transport);
|
|
const opened = await call("open_binary", {
|
|
path,
|
|
format: "raw-binary",
|
|
architecture: "6502",
|
|
entry_point: "0x008001",
|
|
provider_id: "ghidra",
|
|
});
|
|
assert.equal(opened.architecture, "6502");
|
|
assert.equal(opened.entry_point, "0x8001");
|
|
const status = await call("binary_session");
|
|
const image = evidence(await call("inspect_native_load_image"), sha256);
|
|
assert.equal(
|
|
image.normalized_result.status,
|
|
"verified",
|
|
JSON.stringify(image.normalized_result),
|
|
);
|
|
assert.equal(image.normalized_result.architecture, "6502");
|
|
assert.equal(image.normalized_result.base_address, "0x8001");
|
|
assert.equal(image.normalized_result.language_id, "6502:LE:16:default");
|
|
assert.equal(image.normalized_result.compiler_spec_id, "default");
|
|
assert.equal(image.normalized_result.file_bytes, bytes.length);
|
|
const memory = evidence(
|
|
await call("read_bytes", { address: "0x8001", length: bytes.length }),
|
|
sha256,
|
|
);
|
|
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
|
|
const procedures = evidence(await call("list_procedures"), sha256);
|
|
for (const address of ["0x8001", "0x8010"])
|
|
assert.ok(
|
|
procedures.normalized_result.some(
|
|
(procedure) => procedure.address === address,
|
|
),
|
|
);
|
|
const instruction = evidence(
|
|
await call("inspect_native_instruction", { address: "0x8003" }),
|
|
sha256,
|
|
);
|
|
assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR");
|
|
assert.ok(
|
|
instruction.normalized_result.references.some(
|
|
(reference) => reference.call && reference.target_address === "0x8010",
|
|
),
|
|
);
|
|
const pseudo = evidence(
|
|
await call("procedure_pseudo_code", { procedure: "0x8010" }),
|
|
sha256,
|
|
);
|
|
assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u);
|
|
const filePseudo = evidence(
|
|
await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }),
|
|
sha256,
|
|
);
|
|
assert.equal(filePseudo.normalized_result, pseudo.normalized_result);
|
|
assert.ok(
|
|
filePseudo.locations.some(
|
|
(location) => location.kind === "file-offset" && location.offset === 15,
|
|
),
|
|
);
|
|
const rejected = await client.callTool({
|
|
name: "open_binary",
|
|
arguments: {
|
|
path,
|
|
format: "raw-binary",
|
|
architecture: "6502",
|
|
entry_point: "0xffff",
|
|
},
|
|
});
|
|
assert.equal(rejected.isError, true);
|
|
assert.match(JSON.stringify(rejected.content), /6502 address width/u);
|
|
const preserved = await call("binary_session");
|
|
assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id);
|
|
assert.equal(preserved.entry_point, "0x8001");
|
|
await call("close_binary");
|
|
await client.ping();
|
|
const { stdout } = await exec(
|
|
process.execPath,
|
|
[
|
|
entrypoint,
|
|
"inspect-native-load-image",
|
|
path,
|
|
"--target-format",
|
|
"raw-binary",
|
|
"--architecture",
|
|
"6502",
|
|
"--entry-point",
|
|
"0x8001",
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
const cli = parseEvidence(JSON.parse(stdout));
|
|
assert.deepEqual(cli.normalized_result, image.normalized_result);
|
|
assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest);
|
|
assert.equal(cli.subject.digest.sha256, sha256);
|
|
const fileCli = await exec(
|
|
process.execPath,
|
|
[
|
|
entrypoint,
|
|
"decompile",
|
|
path,
|
|
"--file-offset=0xf",
|
|
"--target-format",
|
|
"raw-binary",
|
|
"--architecture",
|
|
"6502",
|
|
"--entry-point",
|
|
"0x8001",
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout));
|
|
assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result);
|
|
assert.deepEqual(fileCliEvidence.locations, filePseudo.locations);
|
|
assert.equal(fileCliEvidence.subject.digest.sha256, sha256);
|
|
assert.deepEqual(await readFile(path), bytes);
|
|
assert.deepEqual(await readdir(runtime), []);
|
|
const lowMemory = [];
|
|
// BinaryLoader maps file bytes before trying processor-defined blocks. Prove
|
|
// that overlapping zero-page/stack definitions never overlay the selected image.
|
|
for (const [entry, fullAddressSpace] of [
|
|
[0xff, false],
|
|
[0, true],
|
|
]) {
|
|
const lowPath = join(workspace, `low-${entry}.bin`);
|
|
await exec(
|
|
ld65,
|
|
[
|
|
"-C",
|
|
join(root, "tests/conformance/ghidra/6502.cfg"),
|
|
"--start-addr",
|
|
String(entry),
|
|
"-o",
|
|
lowPath,
|
|
object,
|
|
],
|
|
{ timeout: 10000 },
|
|
);
|
|
const linked = await readFile(lowPath);
|
|
const expectedCode = Buffer.from(
|
|
"a907200000c90ad003a92a60a9006018690360",
|
|
"hex",
|
|
);
|
|
expectedCode.writeUInt16LE(entry + 15, 3);
|
|
assert.deepEqual(linked, expectedCode);
|
|
const lowBytes = fullAddressSpace
|
|
? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)])
|
|
: linked;
|
|
await writeFile(lowPath, lowBytes);
|
|
const lowSha256 = createHash("sha256").update(lowBytes).digest("hex");
|
|
const lowEntry = `0x${entry.toString(16)}`;
|
|
const lowOpened = await call("open_binary", {
|
|
path: lowPath,
|
|
format: "raw-binary",
|
|
architecture: "6502",
|
|
entry_point: lowEntry,
|
|
provider_id: "ghidra",
|
|
});
|
|
assert.equal(lowOpened.entry_point, lowEntry);
|
|
const lowImage = evidence(
|
|
await call("inspect_native_load_image"),
|
|
lowSha256,
|
|
);
|
|
assert.equal(
|
|
lowImage.normalized_result.status,
|
|
"verified",
|
|
JSON.stringify(lowImage.normalized_result),
|
|
);
|
|
assert.equal(lowImage.normalized_result.base_address, lowEntry);
|
|
assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length);
|
|
const fileMappings =
|
|
lowImage.normalized_result.observations.mappings.filter(
|
|
(mapping) => mapping.source_file_index !== null,
|
|
);
|
|
assert.equal(fileMappings.length, 1);
|
|
assert.equal(
|
|
fileMappings[0].address_space,
|
|
lowImage.normalized_result.observations.default_address_space,
|
|
);
|
|
assert.equal(fileMappings[0].overlay, false);
|
|
assert.equal(fileMappings[0].start, lowEntry);
|
|
assert.equal(fileMappings[0].length, lowBytes.length);
|
|
const lowRead = evidence(
|
|
await call("read_bytes", { address: lowEntry, length: linked.length }),
|
|
lowSha256,
|
|
);
|
|
assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex"));
|
|
const lowCall = evidence(
|
|
await call("inspect_native_instruction", {
|
|
address: `0x${(entry + 2).toString(16)}`,
|
|
}),
|
|
lowSha256,
|
|
);
|
|
assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR");
|
|
assert.ok(
|
|
lowCall.normalized_result.references.some(
|
|
(reference) =>
|
|
reference.call &&
|
|
reference.target_address === `0x${(entry + 15).toString(16)}`,
|
|
),
|
|
);
|
|
await call("close_binary");
|
|
const lowCli = await exec(
|
|
process.execPath,
|
|
[
|
|
entrypoint,
|
|
"inspect-native-load-image",
|
|
lowPath,
|
|
"--target-format",
|
|
"raw-binary",
|
|
"--architecture",
|
|
"6502",
|
|
"--entry-point",
|
|
lowEntry,
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256);
|
|
assert.deepEqual(
|
|
lowCliEvidence.normalized_result,
|
|
lowImage.normalized_result,
|
|
);
|
|
assert.equal(
|
|
lowCliEvidence.analysis_profile.digest,
|
|
lowImage.analysis_profile.digest,
|
|
);
|
|
assert.deepEqual(await readFile(lowPath), lowBytes);
|
|
assert.deepEqual(await readdir(runtime), []);
|
|
lowMemory.push({
|
|
entry: lowEntry,
|
|
bytes: lowBytes.length,
|
|
sha256: lowSha256,
|
|
});
|
|
}
|
|
let windowsReparseAdmission = "not-applicable";
|
|
if (process.platform === "win32") {
|
|
const junction = join(workspace, "linked");
|
|
await symlink(workspace, junction, "junction");
|
|
try {
|
|
const linkedPath = join(junction, "image.bin");
|
|
// Reopening the same bytes through a junction must replace the direct session.
|
|
await call("open_binary", {
|
|
path,
|
|
format: "raw-binary",
|
|
architecture: "6502",
|
|
entry_point: "0x8001",
|
|
provider_id: "ghidra",
|
|
});
|
|
const directImage = evidence(
|
|
await call("inspect_native_load_image"),
|
|
sha256,
|
|
);
|
|
assert.equal(directImage.normalized_result.status, "verified");
|
|
// Canonical file identity must not bypass admission of the new path.
|
|
await call("open_binary", {
|
|
path: linkedPath,
|
|
format: "raw-binary",
|
|
architecture: "6502",
|
|
entry_point: "0x8001",
|
|
provider_id: "ghidra",
|
|
});
|
|
// open_binary selects a lazy session; the first analysis admits the snapshot.
|
|
const linkedImage = await client.callTool(
|
|
{ name: "inspect_native_load_image", arguments: {} },
|
|
{ timeout: 240000 },
|
|
);
|
|
assert.equal(linkedImage.isError, true);
|
|
assert.match(
|
|
JSON.stringify(linkedImage),
|
|
/Reparse path component is unsupported/u,
|
|
);
|
|
await call("close_binary");
|
|
const snapshotPath = join(workspace, "direct-snapshot.json");
|
|
const cliArgs = (selectedPath) => [
|
|
entrypoint,
|
|
"analyze",
|
|
selectedPath,
|
|
"--target-format",
|
|
"raw-binary",
|
|
"--architecture",
|
|
"6502",
|
|
"--entry-point",
|
|
"0x8001",
|
|
"--provider",
|
|
"ghidra",
|
|
"--snapshot",
|
|
snapshotPath,
|
|
"--json",
|
|
];
|
|
const saved = await exec(process.execPath, cliArgs(path), {
|
|
env,
|
|
timeout: 240000,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
});
|
|
assert.equal(
|
|
evidence(JSON.parse(saved.stdout), sha256).operation,
|
|
"binary_overview",
|
|
);
|
|
const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
|
|
assert.ok(
|
|
savedSnapshot.workflow_entries.some(
|
|
({ operation }) => operation === "binary_overview",
|
|
),
|
|
);
|
|
assert.deepEqual(await readdir(runtime), []);
|
|
// Replaying a matching snapshot must still reach native path admission.
|
|
await assert.rejects(
|
|
exec(process.execPath, cliArgs(linkedPath), {
|
|
env,
|
|
timeout: 240000,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
}),
|
|
(cause) => {
|
|
assert.match(
|
|
cause.stdout + cause.stderr,
|
|
/Reparse path component is unsupported/u,
|
|
);
|
|
return true;
|
|
},
|
|
);
|
|
assert.deepEqual(await readFile(path), bytes);
|
|
assert.deepEqual(await readdir(runtime), []);
|
|
windowsReparseAdmission = "cli-snapshot-mcp-rejected";
|
|
} finally {
|
|
await unlink(junction);
|
|
}
|
|
}
|
|
report = {
|
|
ok: true,
|
|
mocked: false,
|
|
lane: "ghidra-6502",
|
|
host: process.platform,
|
|
toolchain,
|
|
provider: image.provider,
|
|
target: {
|
|
sha256,
|
|
bytes: bytes.length,
|
|
architecture: "6502",
|
|
entry: "0x8001",
|
|
},
|
|
cli_mcp_load_image_parity: true,
|
|
instructions_and_call_reference: true,
|
|
decompilation: true,
|
|
file_offset_decompilation_cli_mcp: true,
|
|
invalid_mapping_preserves_session: true,
|
|
low_memory_cli_mcp_parity: lowMemory,
|
|
windows_reparse_admission: windowsReparseAdmission,
|
|
source_unchanged: true,
|
|
runtime_cleanup: "complete",
|
|
};
|
|
} catch (cause) {
|
|
if (stderr) process.stderr.write(stderr);
|
|
throw cause;
|
|
} finally {
|
|
try {
|
|
await client.close();
|
|
} finally {
|
|
await transport.close();
|
|
}
|
|
}
|
|
const verifierRun = await completeVerifierRun(run);
|
|
if (process.platform !== "win32") {
|
|
assert.equal(verifierRun.process_lineage.status, "verified");
|
|
assert.deepEqual(verifierRun.process_lineage.descendants, []);
|
|
}
|
|
// Keep the workspace for diagnosis unless verification and cleanup both pass.
|
|
await rm(workspace, { recursive: true, force: true });
|
|
process.stdout.write(
|
|
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
|
|
);
|
|
|
|
async function call(name, args = {}) {
|
|
const result = await client.callTool(
|
|
{ name, arguments: args },
|
|
{ timeout: 240000 },
|
|
);
|
|
assert.notEqual(result.isError, true, JSON.stringify(result));
|
|
if (["open_binary", "binary_session", "close_binary"].includes(name))
|
|
return requireMcpOperationResult(result, name);
|
|
return result.structuredContent;
|
|
}
|
|
|
|
function evidence(value, sha256) {
|
|
const result = parseEvidence(value);
|
|
assert.equal(result.subject.digest.sha256, sha256);
|
|
assert.equal(result.subject.architecture, "6502");
|
|
assert.equal(result.subject.format, "raw-binary");
|
|
return result;
|
|
}
|