Keep ordinary Ghidra facts in normalized Evidence without repeating complete raw replies. Preserve distinct overview, attestation, file-offset and pre-overlay observations through shared CLI/MCP execution. Record real application measurements, equivalent selected facts, response admission and explicit latency/coverage limits. Fixes #5337. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
220 lines
8.4 KiB
JavaScript
220 lines
8.4 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdtemp, readFile, rm } 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 { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
|
|
|
/** Exercise complete oversized results and recovery through real CLI/MCP adapters. */
|
|
export async function verifyGhidraLargeResults({
|
|
call,
|
|
reject,
|
|
target,
|
|
entrypoint,
|
|
env,
|
|
}) {
|
|
const exec = promisify(execFile);
|
|
const compiler = process.env.REA_CC ?? "cc";
|
|
try {
|
|
await exec(compiler, ["--version"], { env, timeout: 30000 });
|
|
} catch (cause) {
|
|
throw new Error(
|
|
`Ghidra large-result verification requires a working host C compiler '${compiler}'.`,
|
|
{ cause },
|
|
);
|
|
}
|
|
// Hex encoding, Evidence projection and the two MCP representations multiply
|
|
// the source size. Derive the fixture from the pinned SDK's receive budget.
|
|
const length = Math.ceil(STDIO_DEFAULT_MAX_BUFFER_SIZE / 4);
|
|
const workspace = await mkdtemp(join(tmpdir(), "rea-ghidra-large-results-"));
|
|
const path = join(workspace, "large-results");
|
|
const source = fileURLToPath(
|
|
new URL("../../tests/conformance/ghidra/large-results.c", import.meta.url),
|
|
);
|
|
try {
|
|
await exec(
|
|
compiler,
|
|
["-O0", `-DREA_PAYLOAD_BYTES=${length}`, source, "-o", path],
|
|
{ env, timeout: 30000 },
|
|
);
|
|
const bytes = await readFile(path);
|
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
await call("close_binary");
|
|
await call("open_binary", { path, provider_id: "ghidra" });
|
|
const names = await call("list_names");
|
|
const payload = names.find(({ value }) =>
|
|
/(?:^|_)rea_large_payload$/u.test(value),
|
|
);
|
|
const entry = names.find(({ value }) =>
|
|
/(?:^|_)rea_large_entry$/u.test(value),
|
|
);
|
|
assert.ok(payload);
|
|
assert.ok(entry);
|
|
const mapping = await call("address_to_file_offset", {
|
|
address: payload.address,
|
|
});
|
|
const expected = bytes.subarray(
|
|
mapping.file_offset,
|
|
mapping.file_offset + length,
|
|
);
|
|
assert.equal(expected.length, length);
|
|
assert.equal(expected[0], 0x5a);
|
|
assert.equal(expected.at(-1), 0xa5);
|
|
const before = await call("binary_session");
|
|
// An error reaches the wire once, as escaped JSON text. Each quote costs
|
|
// two request bytes but four response bytes once the echoed diagnostic is
|
|
// escaped again as text, so this request fits the SDK receive limit while
|
|
// the error response cannot.
|
|
const missingSelector =
|
|
"REA_MISSING_" +
|
|
'"'.repeat(Math.ceil(STDIO_DEFAULT_MAX_BUFFER_SIZE / 4) + 64 * 1024);
|
|
const lookupError = await reject("procedure_address", {
|
|
procedure: missingSelector,
|
|
});
|
|
const lookupId = retainedReference(lookupError, "procedure_address");
|
|
assert.equal(
|
|
lookupError.details.reported_limits.original_error_code,
|
|
"invalid_request",
|
|
);
|
|
assert.equal(
|
|
await call("procedure_address", { procedure: entry.address }),
|
|
entry.address,
|
|
);
|
|
|
|
const readError = await reject("read_bytes", {
|
|
address: payload.address,
|
|
length,
|
|
});
|
|
const readId = retainedReference(readError, "read_bytes");
|
|
// Two annotation strings repeated in text and structured content must
|
|
// exceed the receive budget even without obsolete Evidence envelopes.
|
|
// The request contains only the two strings and remains below that budget.
|
|
const comment =
|
|
"REA_BIG_COMMENT:" +
|
|
"x".repeat(Math.ceil(STDIO_DEFAULT_MAX_BUFFER_SIZE / 4));
|
|
const annotationError = await reject("annotate_native_function", {
|
|
procedure: entry.address,
|
|
name: "rea_frame_probe",
|
|
comment,
|
|
inline_comment: comment,
|
|
});
|
|
const annotationId = retainedReference(
|
|
annotationError,
|
|
"annotate_native_function",
|
|
);
|
|
assert.equal(
|
|
await call("procedure_address", { procedure: "rea_frame_probe" }),
|
|
entry.address,
|
|
"A delivery constraint must preserve the successfully committed edit",
|
|
);
|
|
const dossierError = await reject("analyze_function", {
|
|
procedure: "rea_frame_probe",
|
|
});
|
|
const dossierId = retainedReference(dossierError, "analyze_function");
|
|
const after = await call("binary_session");
|
|
assert.equal(after.analysis_run.run_id, before.analysis_run.run_id);
|
|
assert.equal(after.sha256, sha256);
|
|
assert.equal(
|
|
(await call("read_bytes", { address: payload.address, length: 1 }))
|
|
.bytes_hex,
|
|
"5a",
|
|
);
|
|
const lastAddress = `0x${(BigInt(payload.address) + BigInt(length - 1)).toString(16)}`;
|
|
assert.equal(
|
|
(await call("read_bytes", { address: lastAddress, length: 1 })).bytes_hex,
|
|
"a5",
|
|
);
|
|
const bundlePath = join(workspace, "bundle.json");
|
|
await call("export_evidence_bundle", { path: bundlePath });
|
|
const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
|
|
const retained = (id) => {
|
|
const record = bundle.records.find(
|
|
({ evidence_id }) => evidence_id === id,
|
|
);
|
|
assert.ok(record, `Missing exported retained record ${id}`);
|
|
assert.equal(record.provider.id, "ghidra");
|
|
assert.equal(record.subject.digest.sha256, sha256);
|
|
return record;
|
|
};
|
|
const originalError = bundle.records.find(
|
|
({ evidence_id }) => evidence_id === lookupId,
|
|
);
|
|
assert.ok(originalError);
|
|
assert.equal(originalError.operation, "mcp_tool_error");
|
|
assert.equal(originalError.provider.id, "rea-mcp");
|
|
assert.equal(originalError.subject, null);
|
|
assert.equal(originalError.parameters.tool_name, "procedure_address");
|
|
assert.equal(originalError.normalized_result.error.code, "invalid_request");
|
|
assert.equal(originalError.raw_result.isError, true);
|
|
assert.deepEqual(
|
|
originalError.raw_result.structuredContent,
|
|
originalError.normalized_result,
|
|
);
|
|
assert.equal(
|
|
originalError.normalized_result.error.details.operation,
|
|
"procedure_address",
|
|
);
|
|
assert.equal(
|
|
originalError.normalized_result.error.details.issues[0].message,
|
|
`Unknown Ghidra procedure name or address: ${missingSelector}`,
|
|
);
|
|
const readRecord = retained(readId);
|
|
const read = readRecord.normalized_result;
|
|
assert.equal(read.requested_bytes, length);
|
|
assert.equal(read.returned_bytes, length);
|
|
assert.equal(read.complete, true);
|
|
assert.equal(read.bytes_hex, expected.toString("hex"));
|
|
assert.equal(readRecord.raw_result, null);
|
|
const annotationRecord = retained(annotationId);
|
|
const annotation = annotationRecord.normalized_result;
|
|
assert.equal(annotation.annotations.name, "rea_frame_probe");
|
|
assert.equal(annotation.annotations.comment, comment);
|
|
assert.equal(annotation.annotations.inline_comment, comment);
|
|
assert.equal(annotationRecord.raw_result, null);
|
|
const dossierRecord = retained(dossierId);
|
|
const dossier = dossierRecord.normalized_result;
|
|
assert.equal(dossier.procedure.name, "rea_frame_probe");
|
|
assert.ok(dossier.comments.some(({ text }) => text === comment));
|
|
assert.equal(dossierRecord.raw_result, null);
|
|
const { stdout } = await exec(
|
|
process.execPath,
|
|
[
|
|
entrypoint,
|
|
"read-bytes",
|
|
path,
|
|
payload.address,
|
|
"--length",
|
|
String(length),
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{ env, timeout: 240000, maxBuffer: 72 * 1024 * 1024 },
|
|
);
|
|
const cliRead = JSON.parse(stdout);
|
|
assert.deepEqual(cliRead.normalized_result, read);
|
|
assert.equal(cliRead.raw_result, null);
|
|
assert.deepEqual(await readFile(path), bytes);
|
|
await call("close_binary");
|
|
await call("open_binary", { path: target.path, provider_id: "ghidra" });
|
|
} finally {
|
|
await rm(workspace, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function retainedReference(error, operation) {
|
|
assert.equal(error.code, "resource_constraint");
|
|
assert.equal(error.details.operation, operation);
|
|
assert.equal(error.details.resource, "transport");
|
|
const limits = error.details.reported_limits;
|
|
assert.equal(limits.boundary, "mcp-response");
|
|
assert.equal(limits.constraint, "receive-buffer");
|
|
assert.ok(limits.response_bytes_at_least > limits.result_budget_bytes);
|
|
assert.equal(limits.evidence_reference.kind, "retained-evidence");
|
|
assert.match(limits.evidence_reference.evidence_id, /^ev_[a-f0-9]{64}$/u);
|
|
return limits.evidence_reference.evidence_id;
|
|
}
|