Files
rea/scripts/lib/real-hopper-navigation.mjs
BaaRai bf934d5592 fix(hopper): probe interior navigation from multi-byte instructions (#1285)
The real-Hopper navigation probe derived its interior coordinate as
anchor + 1, which assumes the fixture procedure's first instruction is
longer than one byte. Host toolchains without endbr64-style prologue
padding (Kali gcc 16, or any build with -fcf-protection=none) compile
rea_entry to start with the one-byte push rbp, so anchor + 1 is itself
an instruction boundary: goto_address correctly observes that boundary
and the boundary-contract run rejects a correct bridge.

Advance the probe to the first instruction followed by a coordinate gap
larger than one byte and derive the interior-coordinate assertions from
that instruction, so the probed coordinate is never an analyzed-object
boundary on either prologue encoding.
2026-10-10 06:07:01 +08:00

260 lines
9.1 KiB
JavaScript

import assert from "node:assert/strict";
/** Verify native cursor readback and reject unrepresentable text before mutations. */
export async function verifyHopperNavigationAndText(call, invalid, address) {
const cursor = await call("current_address");
const procedures = await call("list_procedures");
const other = procedures.find((item) => item.address !== address);
assert.ok(other, "fixture omitted a second procedure for batch validation");
const originalName = await call("address_name", { address });
const originalComment = await call("comment", { address });
const originalInline = await call("inline_comment", { address });
const originalBookmark = (await call("list_bookmarks")).find(
(item) => item.address === address,
);
try {
await verifyNavigation(call, invalid, address, {
originalName,
originalComment,
originalInline,
});
await verifyAnnotationText(call, invalid, { address, other, originalName });
return {
cursorReadback: true,
interiorObjectNavigation: true,
unmappedNavigationRejected: true,
nativeTextValidationBeforeMutation: true,
mappedAnnotationValidationBeforeMutation: true,
unselectedLabelOwnersPreserved: true,
explicitLabelSwap: true,
observedLocalMetadata: true,
};
} finally {
await call("goto_address", { address: cursor });
await call("set_comment", { address, comment: originalComment ?? "" });
await call("set_inline_comment", {
address,
comment: originalInline ?? "",
});
if (originalBookmark === undefined)
await call("unset_bookmark", { address });
else
await call("set_bookmark", {
address,
...(originalBookmark.name === null
? {}
: { name: originalBookmark.name }),
});
}
}
async function verifyNavigation(
call,
invalid,
address,
{ originalName, originalComment, originalInline },
) {
const instructions = await call("read_function_instructions", {
procedure: address,
});
const info = await call("procedure_info", { procedure: address });
assert.equal(info.classification, null);
assert.equal(info.body.available, false);
assert.match(
info.body.reason,
/public Python API does not expose complete function body ranges/u,
);
assert.ok(info.locals.length > 0, "fixture omitted real stack locals");
for (const local of info.locals) {
assert.equal(typeof local.name, "string");
assert.match(local.stack_displacement, /^-?\d+$/u);
assert.ok(local.description.includes(local.name));
assert.ok(local.description.includes(local.stack_displacement));
assert.doesNotMatch(local.description, /object at 0x/u);
}
const dossier = await call("analyze_function", { procedure: address });
assert.deepEqual(dossier.procedure.locals, info.locals);
assert.equal(dossier.procedure.classification, null);
assert.equal(dossier.procedure.body.available, false);
assert.match(
dossier.procedure.body.reason,
/public Python API does not expose complete function body ranges/u,
);
assert.equal(dossier.native_api, null);
assert.equal(dossier.native_value_flow, null);
assert.ok(
Array.isArray(dossier.limitations) && dossier.limitations.length > 0,
"function dossier omitted Hopper's unsupported-facet limitations",
);
const coordinates = instructions.instructions.map(
(line) => /^0x[0-9a-f]+/u.exec(line)?.[0],
);
assert.equal(coordinates[0], address);
assert.ok(coordinates[1], "fixture omitted a second instruction");
// The host toolchain controls the prologue encoding: without endbr64-style
// padding the first instruction is one byte, so address + 1 is itself an
// instruction boundary. Probe from inside the first multi-byte instruction
// so the interior coordinate is never an analyzed-object boundary.
let anchor = 0;
while (anchor + 1 < coordinates.length) {
if (BigInt(coordinates[anchor + 1]) - BigInt(coordinates[anchor]) > 1n)
break;
anchor += 1;
}
assert.ok(
anchor + 1 < coordinates.length,
"fixture omitted a multi-byte instruction for interior navigation",
);
const containing = coordinates[anchor];
const interior = `0x${(BigInt(containing) + 1n).toString(16)}`;
assert.equal(await call("goto_address", { address: interior }), containing);
assert.equal(await call("current_address"), containing);
assert.equal(
await call("next_address", { address: interior }),
coordinates[anchor + 1],
);
assert.equal(await call("prev_address", { address: interior }), containing);
for (const unmapped of ["0x0", "0xfffffffffffffffe"]) {
for (const operation of ["goto_address", "next_address", "prev_address"])
await invalid(
operation,
{ address: unmapped },
/Address is outside every segment/u,
);
assert.equal(await call("current_address"), containing);
for (const operation of [
"set_address_name",
"set_bookmark",
"unset_bookmark",
"set_comment",
"set_inline_comment",
])
await invalid(
operation,
{
address: unmapped,
...(operation === "unset_bookmark"
? {}
: operation.endsWith("comment")
? { comment: "must_not_apply" }
: { name: "must_not_apply" }),
},
/Address is outside every segment/u,
);
await invalid(
"set_addresses_names",
{ names: { [address]: "must_not_apply", [unmapped]: "unmapped" } },
/Address is outside every segment/u,
);
assert.equal(await call("address_name", { address }), originalName);
assert.equal(await call("comment", { address }), originalComment);
assert.equal(await call("inline_comment", { address }), originalInline);
assert.ok(
!(await call("list_bookmarks")).some((item) => item.address === unmapped),
);
}
const segments = await call("list_segments");
const first = segments.reduce((left, right) =>
BigInt(left.start) < BigInt(right.start) ? left : right,
);
const last = segments.reduce((left, right) =>
BigInt(left.end) > BigInt(right.end) ? left : right,
);
await invalid(
"prev_address",
{ address: first.start },
/No previous address exists in mapped memory/u,
);
await invalid(
"next_address",
{ address: `0x${(BigInt(last.end) - 1n).toString(16)}` },
/No adjacent analyzed object exists in mapped memory/u,
);
}
async function verifyAnnotationText(
call,
invalid,
{ address, other, originalName },
) {
const comment = "REA representation boundary 测试";
await call("set_comment", { address, comment });
await call("set_inline_comment", { address, comment });
await call("set_bookmark", { address, name: comment });
for (const [text, reason] of [
["before\0after", /contains a NUL character/u],
["before\ud800after", /contains an unpaired Unicode surrogate/u],
]) {
for (const operation of ["procedure_info", "read_bytes"])
await invalid(
operation,
operation === "procedure_info"
? { procedure: text }
: { address: text, length: 1 },
reason,
);
for (const operation of ["set_address_name", "set_bookmark"])
await invalid(operation, { address, name: text }, reason);
for (const operation of ["set_comment", "set_inline_comment"])
await invalid(operation, { address, comment: text }, reason);
await invalid(
"set_addresses_names",
{ names: { [address]: "must_not_apply", [other.address]: text } },
reason,
);
assert.equal(await call("address_name", { address }), originalName);
assert.equal(
await call("address_name", { address: other.address }),
other.value,
);
assert.equal(await call("comment", { address }), comment);
assert.equal(await call("inline_comment", { address }), comment);
assert.equal(
(await call("list_bookmarks")).find((item) => item.address === address)
?.name,
comment,
);
}
await invalid(
"set_address_name",
{ address, name: other.value },
/already assigned to/u,
);
await invalid(
"set_addresses_names",
{ names: { [address]: other.value } },
/already assigned to/u,
);
await invalid(
"set_addresses_names",
{ names: { [address]: "duplicate", [other.address]: "duplicate" } },
/assigns the same name more than once/u,
);
await invalid(
"set_addresses_names",
{ names: { [address]: "first", [address.slice(2)]: "second" } },
/selects the same address more than once/u,
);
assert.equal(await call("address_name", { address }), originalName);
assert.equal(
await call("address_name", { address: other.address }),
other.value,
);
try {
const swapped = await call("set_addresses_names", {
names: { [address]: other.value, [other.address]: originalName },
});
assert.equal(swapped[address], true);
assert.equal(swapped[other.address], true);
assert.equal(await call("address_name", { address }), other.value);
assert.equal(
await call("address_name", { address: other.address }),
originalName,
);
} finally {
await call("set_addresses_names", {
names: { [address]: originalName, [other.address]: other.value },
});
}
}