Use the public rename API for an explicit empty existing name, retain omitted names and raw listing values, and verify fresh unnamed creation and explicit mutations by readback. Preserve independent verifier restorations and their primary failures. Refs #5475. Focused bridge regressions and the complete real-Hopper suite passed on macOS arm64 with Hopper 6.6.0-demo 42.6316032. The verifier used a temporary unavailable Ghidra override to meet its sole-candidate prerequisite; reporter Hopper 6.1 remains unverified.
621 lines
20 KiB
JavaScript
621 lines
20 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 { fileURLToPath } from "node:url";
|
|
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";
|
|
|
|
const dispatcher = fileURLToPath(new URL("../rea.mjs", import.meta.url));
|
|
|
|
/** 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)),
|
|
};
|
|
const originalBookmark = (await call("list_bookmarks")).find(
|
|
(item) => item.address === address,
|
|
);
|
|
let bookmarkReplacement;
|
|
const boundaryFailures = [];
|
|
try {
|
|
if (originalBookmark !== undefined)
|
|
assert.equal(await call("unset_bookmark", { address }), true);
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
assert.equal(
|
|
await call("set_bookmark", {
|
|
address,
|
|
name: "REA boundary bookmark before clear",
|
|
}),
|
|
true,
|
|
);
|
|
const beforeClear = (await call("list_bookmarks")).find(
|
|
(item) => item.address === address,
|
|
);
|
|
assert.deepEqual(beforeClear, {
|
|
address,
|
|
name: "REA boundary bookmark before clear",
|
|
});
|
|
assert.equal(await call("set_bookmark", { address, name: "" }), true);
|
|
const afterClear = (await call("list_bookmarks")).find(
|
|
(item) => item.address === address,
|
|
);
|
|
assert.deepEqual(afterClear, { address, name: null });
|
|
|
|
const replacementName = "REA boundary bookmark replacement";
|
|
assert.equal(
|
|
await call("set_bookmark", { address, name: replacementName }),
|
|
true,
|
|
);
|
|
const afterReplacement = (await call("list_bookmarks")).find(
|
|
(item) => item.address === address,
|
|
);
|
|
assert.deepEqual(afterReplacement, { address, name: replacementName });
|
|
assert.equal(await call("set_bookmark", { address }), true);
|
|
const afterOmittedName = (await call("list_bookmarks")).find(
|
|
(item) => item.address === address,
|
|
);
|
|
assert.deepEqual(afterOmittedName, {
|
|
address,
|
|
name: replacementName,
|
|
});
|
|
bookmarkReplacement = {
|
|
existingNameCleared: afterClear?.name === null,
|
|
nonemptyNameReplaced: afterReplacement?.name === replacementName,
|
|
omittedNamePreserved: afterOmittedName?.name === replacementName,
|
|
};
|
|
|
|
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",
|
|
);
|
|
} catch (error) {
|
|
boundaryFailures.push(error);
|
|
throw error;
|
|
} finally {
|
|
const restorations = [
|
|
() => call("set_address_name", { address, name: originalName ?? "" }),
|
|
() => call("set_comment", { address, comment: originalComment ?? "" }),
|
|
() =>
|
|
call("set_inline_comment", {
|
|
address,
|
|
comment: originalInlineComment ?? "",
|
|
}),
|
|
async () => {
|
|
await call("unset_bookmark", { address });
|
|
if (originalBookmark === undefined) return;
|
|
assert.equal(
|
|
await call("set_bookmark", {
|
|
address,
|
|
...(originalBookmark.name === null
|
|
? {}
|
|
: { name: originalBookmark.name }),
|
|
}),
|
|
true,
|
|
"Could not restore the original disposable-target bookmark",
|
|
);
|
|
const restored = (await call("list_bookmarks")).find(
|
|
(item) => item.address === address,
|
|
);
|
|
assert.deepEqual(restored, originalBookmark);
|
|
},
|
|
];
|
|
const restorationErrors = [];
|
|
for (const restore of restorations) {
|
|
try {
|
|
await restore();
|
|
} catch (error) {
|
|
restorationErrors.push(error);
|
|
}
|
|
}
|
|
if (restorationErrors.length > 0) {
|
|
throw new AggregateError(
|
|
[...boundaryFailures, ...restorationErrors],
|
|
"Could not restore all disposable Hopper boundary state",
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
schemaCount: catalog.tools.length,
|
|
successfulCalls,
|
|
rejectedCalls,
|
|
rejectedOperations: [...rejectedOperations],
|
|
foreignDocumentVerified: foreignDocument !== undefined,
|
|
navigationAndText,
|
|
workflows,
|
|
search,
|
|
bookmarkReplacement,
|
|
};
|
|
}
|
|
|
|
/** 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,
|
|
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 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");
|
|
};
|