Files
rea/scripts/lib/real-hopper-boundaries.mjs

531 lines
17 KiB
JavaScript

import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { copyFile, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { promisify } from "node:util";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import {
mcpTextValue,
requireMcpEvidenceResult,
requireMcpOperationResult,
requireMcpLifecycleResult,
} from "./mcp-verifier-results.mjs";
import { verifyHopperCliSelectors } from "./real-hopper-cli-selectors.mjs";
import { verifyLinuxHopperLaunchIsolation } from "./real-hopper-session-isolation.mjs";
import { verifyHopperNavigationAndText } from "./real-hopper-navigation.mjs";
import { verifyHopperWorkflows } from "./real-hopper-workflows.mjs";
import {
verifyHopperSearch,
verifyHopperRegexIsolation,
} from "./real-hopper-search.mjs";
/** Exercise real Hopper annotation and malformed-input boundaries over MCP. */
export async function verifyHopperBoundaryContracts(
client,
options,
procedure,
targetPath,
) {
const catalog = await client.listTools();
const ajv = new Ajv({ strict: false });
addFormats(ajv);
const validators = new Map();
for (const tool of catalog.tools) {
assert.ok(
ajv.validateSchema(tool.inputSchema),
`${tool.name} input schema`,
);
assert.ok(tool.outputSchema, `${tool.name} omitted its output schema`);
assert.ok(
ajv.validateSchema(tool.outputSchema),
`${tool.name} output schema`,
);
validators.set(
tool.name,
(tool.outputSchema?.$id && ajv.getSchema(tool.outputSchema.$id)) ||
ajv.compile(tool.outputSchema),
);
}
let successfulCalls = 0;
let rejectedCalls = 0;
const rejectedOperations = new Set();
const call = async (name, args = {}) => {
const reply = await client.callTool({ name, arguments: args }, options);
const result = requireMcpOperationResult(reply, name);
const validate = validators.get(name);
assert.ok(validate, `${name} is missing from tools/list`);
assert.ok(
validate(reply.structuredContent),
`${name}: ${JSON.stringify(validate.errors)}`,
);
assert.deepEqual(reply.structuredContent, JSON.parse(mcpTextValue(reply)));
successfulCalls += 1;
return result;
};
const invalid = async (name, args, reason, failedOperation = name) => {
const reply = await client.callTool({ name, arguments: args }, options);
assert.equal(reply.isError, true, `${name} accepted invalid input`);
const { error } = JSON.parse(mcpTextValue(reply));
assert.equal(error.code, "invalid_request");
assert.equal(error.category, "invalid_input");
assert.equal(error.details.operation, failedOperation);
assert.match(error.message, reason);
rejectedCalls += 1;
rejectedOperations.add(name);
};
const address = procedure.address;
const originalName = await call("address_name", { address });
const originalComment = await call("comment", { address });
const originalInlineComment = await call("inline_comment", { address });
const activeDocument = await call("current_document");
const foreignDocument = (await call("list_documents")).find(
(name) => name !== activeDocument,
);
const navigationAndText = await verifyHopperNavigationAndText(
call,
invalid,
address,
);
const workflows = await verifyHopperWorkflows(call, invalid, procedure);
const search = {
...(await verifyHopperSearch(call, invalid, procedure)),
...(await verifyHopperRegexIsolation(client, options, call, address)),
};
try {
if (foreignDocument !== undefined) {
await invalid(
"read_bytes",
{ address, length: 8, document: foreignDocument },
/belongs to another target/u,
);
assert.equal(await call("current_document"), activeDocument);
}
for (const label of [undefined, "", "REA boundary bookmark 测试"]) {
assert.equal(
await call("set_bookmark", {
address,
...(label === undefined ? {} : { name: label }),
}),
true,
);
const bookmarks = await call("list_bookmarks");
const bookmark = bookmarks.find((item) => item.address === address);
assert.ok(bookmark, "set_bookmark did not create a bookmark");
assert.equal(bookmark.name, label || null);
const context = await call("inspect_address_context", { address });
assert.equal(context.bookmarks.state, "available");
assert.deepEqual(context.bookmarks.value, [bookmark]);
assert.equal(await call("unset_bookmark", { address }), true);
assert.ok(
!(await call("list_bookmarks")).some(
(item) => item.address === address,
),
);
}
for (const [setter, getter] of [
["set_comment", "comment"],
["set_inline_comment", "inline_comment"],
]) {
const comment = "REA boundary comment 测试";
assert.equal(await call(setter, { address, comment }), true);
assert.equal(await call(getter, { address }), comment);
assert.equal(await call(setter, { address, comment: "" }), true);
assert.equal(await call(getter, { address }), null);
}
const inline = "REA inline\nsecond line";
const exact = await call("set_inline_comment", {
address,
comment: inline,
});
const observed = await call("inline_comment", { address });
assert.equal(
exact,
observed === inline,
"inline normalization must remain visible",
);
await invalid(
"set_addresses_names",
{
names: {
[address]: "rea_boundary_should_not_be_applied",
__rea_missing_batch_address__: "missing",
},
},
/Unknown Hopper address or name: __rea_missing_batch_address__/u,
);
assert.equal(await call("address_name", { address }), originalName);
assert.equal(
(await call("procedure_info", { procedure: address })).name,
originalName,
);
const renamed = "rea_boundary_renamed";
assert.equal(
await call("set_addresses_names", { names: { [address]: renamed } }).then(
(value) => value[address],
),
true,
);
assert.equal(await call("address_name", { address }), renamed);
const matches = await call("search_procedures", {
pattern: renamed,
case_sensitive: true,
});
assert.ok(
matches.some(
(item) => item.address === address && item.value === renamed,
),
);
assert.equal(
(await call("analyze_function", { procedure: address })).procedure.name,
renamed,
);
assert.equal(
await call("set_address_name", { address, name: "add" }),
true,
);
assert.equal(
await call("procedure_address", { procedure: "add" }),
address,
);
assert.equal(
(await call("procedure_info", { procedure: "add" })).entrypoint,
address,
);
assert.equal(
(await call("read_bytes", { address: "add", length: 8 })).address,
address,
);
assert.equal(
await call("procedure_address", { procedure: address.slice(2) }),
address,
);
for (const query of ["-1", "0x10000000000000000", "0xffffffffffffffff"]) {
for (const name of [
"read_bytes",
"address_to_file_offset",
"resolve_containing_procedure",
"procedure_info",
]) {
await invalid(
name,
name === "procedure_info"
? { procedure: query }
: {
address: query,
...(name === "read_bytes" ? { length: 8 } : {}),
},
/usable unsigned 64-bit range/u,
);
}
}
await invalid(
"read_bytes",
{ address: "0x0", length: 8 },
/Address is outside every segment/u,
);
await invalid(
"procedure_info",
{ procedure: address, document: "__rea_missing_document__" },
/Unknown Hopper document/u,
);
await invalid(
"search_procedures",
{ pattern: "[", mode: "regex" },
/Invalid regex pattern/u,
);
// Invalid calls must leave the same authenticated bridge usable.
assert.equal(
(await call("read_bytes", { address, length: 8 })).returned_bytes,
8,
);
const mapping = await call("address_to_file_offset", { address });
const bytes = await call("read_bytes", { address, length: 8 });
const file = await readFile(targetPath);
assert.equal(
bytes.bytes_hex,
file
.subarray(mapping.file_offset, mapping.file_offset + 8)
.toString("hex"),
);
const segments = await call("list_segments");
const containing = segments.find(
(segment) =>
BigInt(segment.start) <= BigInt(address) &&
BigInt(address) < BigInt(segment.end),
);
assert.ok(containing, "fixture procedure has no containing segment");
const lastByte = `0x${(BigInt(containing.end) - 1n).toString(16)}`;
const prefix = await call("read_bytes", { address: lastByte, length: 8 });
assert.equal(prefix.returned_bytes, 1);
assert.equal(prefix.bytes_hex.length, 2);
assert.equal(prefix.complete, false);
const synthetic = segments.find(
(segment) => segment.name === "External Symbols",
);
assert.ok(synthetic, "fixture omitted synthetic external-symbol memory");
await invalid(
"address_to_file_offset",
{ address: synthetic.start },
/no authoritative file-offset mapping/u,
);
const health = (await call("binary_session")).provider_operation_health;
assert.equal(
health.state,
"idle",
"a live GUI bridge must remain healthy after its launcher helper exits",
);
} finally {
await call("set_address_name", { address, name: originalName ?? "" });
await call("set_comment", { address, comment: originalComment ?? "" });
await call("set_inline_comment", {
address,
comment: originalInlineComment ?? "",
});
await call("unset_bookmark", { address });
}
return {
schemaCount: catalog.tools.length,
successfulCalls,
rejectedCalls,
rejectedOperations: [...rejectedOperations],
foreignDocumentVerified: foreignDocument !== undefined,
navigationAndText,
workflows,
search,
};
}
/** Prove actual document disposal and shared CLI/MCP behavior on disposable copies. */
export async function verifyHopperLifecycleAndCli(client, options, targets) {
const directory = await realpath(
await mkdtemp(join(tmpdir(), "hopper-boundary-")),
);
const suffix = basename(directory);
const primary = join(directory, `primary-${suffix}`);
const secondary = join(directory, `secondary-${suffix}`);
const call = async (name, args = {}) =>
requireMcpOperationResult(
await client.callTool({ name, arguments: args }, options),
name,
);
const cli = promisify(execFile);
const runCli = (args) => {
const pending = cli(process.execPath, args, {
timeout: 180_000,
maxBuffer: 1_048_576,
});
if (pending.child.pid !== undefined)
targets.ownedProcessIds.add(pending.child.pid);
return pending;
};
try {
await copyFile(targets.primary, primary);
await copyFile(targets.secondary, secondary);
await call("open_binary", { path: primary });
const primaryProcedures = await call("list_procedures");
const entry = primaryProcedures.find((item) =>
item.value.endsWith("rea_entry"),
);
assert.ok(entry, "primary fixture omitted its entry procedure");
const expectedFunction = await call("analyze_function", {
procedure: entry.value,
});
const firstDocument = await call("current_document");
const linuxApplicationIsolation =
process.platform === "linux"
? await verifyLinuxHopperLaunchIsolation({
call,
runCli,
dispatcher: new URL("../rea.mjs", import.meta.url).pathname,
primary,
secondary,
})
: null;
await call("open_binary", { path: secondary });
const secondDocument = await call("current_document");
assert.notEqual(firstDocument, secondDocument);
assert.ok(
!(await call("list_documents")).includes(firstDocument),
"target switching retained the old native document",
);
const procedures = await call("list_procedures");
await verifyBatchCancellation(client, options, procedures);
const address = procedures[0].address;
const expected = await call("read_bytes", {
address,
length: 8,
document: secondDocument,
});
await call("open_binary", { path: primary });
const query = "REA_C_LEAF __rea_missing_phrase__";
const expectedTrace = await call("trace_feature", { query });
assert.deepEqual(expectedTrace.matches, []);
await call("close_binary");
const dispatcher = new URL("../rea.mjs", import.meta.url).pathname;
const common = [
"--length",
"8",
"--provider",
"hopper",
"--format",
"json",
];
const { stdout } = await runCli([
dispatcher,
"read-bytes",
secondary,
address,
...common,
]);
const evidence = JSON.parse(stdout);
assert.equal(evidence.provider.id, "hopper");
assert.equal(evidence.subject.local_path, secondary);
assert.deepEqual(evidence.normalized_result, expected);
const traced = await runCli([
dispatcher,
"trace",
primary,
query,
"--provider",
"hopper",
"--format",
"json",
]);
const traceEvidence = JSON.parse(traced.stdout);
assert.equal(traceEvidence.subject.local_path, primary);
assert.deepEqual(traceEvidence.normalized_result, expectedTrace);
const analyzed = await runCli([
dispatcher,
"function",
primary,
entry.value,
"--provider",
"hopper",
"--format",
"json",
]);
assert.deepEqual(
JSON.parse(analyzed.stdout).normalized_result,
expectedFunction,
);
let cliTerminalFunctionParity = null;
let cliNamedSelectorParity = null;
if (targets.unicode !== undefined) {
const objc = join(directory, `objc-${suffix}`);
await copyFile(targets.unicode, objc);
await call("open_binary", { path: objc });
const procedures = await call("list_procedures");
const delegate = procedures.find(
(item) => item.value === "-[REAWidget delegate]",
);
assert.ok(delegate, "Objective-C fixture omitted its tail-call method");
const expected = await call("analyze_function", {
procedure: delegate.address,
});
await call("close_binary");
const analyzed = await runCli([
dispatcher,
"function",
objc,
delegate.address,
"--provider",
"hopper",
"--format",
"json",
]);
assert.deepEqual(JSON.parse(analyzed.stdout).normalized_result, expected);
cliTerminalFunctionParity = true;
await call("open_binary", { path: objc });
cliNamedSelectorParity = await verifyHopperCliSelectors(
call,
runCli,
dispatcher,
objc,
);
}
let failure;
try {
await runCli([
dispatcher,
"read-bytes",
secondary,
"0x10000000000000000",
...common,
]);
} catch (error) {
assert.equal(error.code, 1);
failure = JSON.parse(error.stdout);
}
assert.ok(failure, "CLI accepted an overflowing address");
assert.equal(failure.code, "invalid_request");
assert.equal(failure.category, "invalid_input");
assert.match(failure.message, /usable unsigned 64-bit range/u);
await call("open_binary", { path: primary });
assert.ok(
!(await call("list_documents")).includes(secondDocument),
"MCP or CLI cleanup retained the closed native document",
);
await call("close_binary");
return {
targetSwitchDisposedDocument: true,
cliByteParity: true,
cliInvalidAddress: true,
cliLiteralTraceParity: true,
cliFunctionDossierParity: true,
cliTerminalFunctionParity,
cliNamedSelectorParity,
callerCancellationRecovered: true,
closedDocumentAbsent: true,
linuxApplicationIsolation,
};
} finally {
await call("close_binary");
await rm(directory, { recursive: true, force: true });
}
}
const verifyBatchCancellation = async (client, options, procedures) => {
const controller = new AbortController();
let cancelled = false;
await assert.rejects(
client.callTool(
{
name: "batch_decompile",
arguments: { addresses: procedures.map((item) => item.address) },
},
{
...options,
signal: controller.signal,
onprogress: (update) => {
options.onprogress?.(update);
// Cancel between real decompilations, after Hopper served one item.
if (update.message?.includes("Hopper bridge completed request")) {
cancelled = true;
controller.abort();
}
},
},
),
/aborted/u,
);
assert.ok(cancelled, "cancellation did not follow a real Hopper response");
const recovered = requireMcpEvidenceResult(
await client.callTool({ name: "list_procedures", arguments: {} }, options),
"list_procedures",
);
assert.deepEqual(recovered, procedures);
const status = requireMcpLifecycleResult(
await client.callTool({ name: "binary_session", arguments: {} }, options),
"binary_session",
);
assert.equal(status.provider_operation_health.state, "idle");
};