rea/scripts/lib/real-hopper-search.mjs

420 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from "node:assert/strict";
import { mcpTextValue } from "./mcp-verifier-results.mjs";
const fixtureSymbolPrefix = process.platform === "darwin" ? "_" : "";
/** Exercise literal/regex search, Unicode identities and cache invalidation in Hopper. */
export async function verifyHopperSearch(call, invalid, procedure) {
const address = procedure.address;
const original = await call("address_name", { address });
let stringCoverage;
try {
await verifySymbolNames(call, invalid, address);
stringCoverage = await verifySearchModes(call, address);
} finally {
await call("set_address_name", { address, name: original });
}
assert.deepEqual(
await call("search_procedures", {
pattern: original,
mode: "literal",
case_sensitive: true,
}),
[{ address, value: original }],
);
return {
unicodeIdentity: true,
literalMetacharacters: true,
longValuePreserved: true,
symbolUtf16LimitValidatedBeforeMutation: true,
regexAndCaseModes: true,
annotationCacheInvalidation: true,
stringCoverage,
};
}
async function verifySymbolNames(call, invalid, address) {
const unicode = "REA_é_e\u0301_😀_Straße_ſ_İ";
const extentNames = ["x".repeat(1024), "é".repeat(1024), "😀".repeat(512)];
for (const name of ["REA_a.b[0]", unicode, ...extentNames]) {
assert.equal(await call("set_address_name", { address, name }), true);
assert.equal(await call("address_name", { address }), name);
assert.deepEqual(await call("list_names", { address }), [
{ address, value: name },
]);
assert.deepEqual(
await call("search_procedures", {
pattern: name,
mode: "literal",
case_sensitive: true,
}),
[{ address, value: name }],
);
assert.equal(await call("procedure_address", { procedure: name }), address);
}
assert.deepEqual(
await call("search_procedures", { pattern: unicode, mode: "literal" }),
[],
);
const other = (await call("list_procedures")).find(
(item) => item.address !== address,
);
assert.ok(other);
for (const name of ["x".repeat(1025), "é".repeat(1025), "😀".repeat(513)]) {
await invalid(
"set_address_name",
{ address, name },
/UTF-16 code-unit symbol-name limit/u,
);
await invalid(
"set_addresses_names",
{ names: { [other.address]: "must_not_apply", [address]: name } },
/UTF-16 code-unit symbol-name limit/u,
);
assert.equal(await call("address_name", { address }), extentNames.at(-1));
assert.equal(
await call("address_name", { address: other.address }),
other.value,
);
}
}
async function verifySearchModes(call, address) {
await call("set_address_name", { address, name: "REA_Search_Upper" });
assert.deepEqual(
await call("search_procedures", {
pattern: "rea_search_upper",
mode: "literal",
case_sensitive: false,
}),
[{ address, value: "REA_Search_Upper" }],
);
assert.deepEqual(
await call("search_procedures", {
pattern: "rea_search_upper",
mode: "literal",
case_sensitive: true,
}),
[],
);
assert.deepEqual(
await call("search_procedures", {
pattern: "^REA_Search_(?:Upper|Lower)$",
mode: "regex",
case_sensitive: true,
}),
[{ address, value: "REA_Search_Upper" }],
);
const strings = await call("list_strings");
const fullLiteral = await verifyHopperStringFragments(call, strings, {
symbol: `${fixtureSymbolPrefix}rea_long_literal`,
value: "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle",
recognitionOptional: true,
});
const fragmentedLiteral = await verifyHopperStringFragments(call, strings, {
symbol: `${fixtureSymbolPrefix}rea_fragmented_literal`,
value: "REA_FRAGMENTED_LITERAL_" + "x".repeat(512) + "fragment_tail",
});
const [longItem] = fragmentedLiteral.fragments;
assert.ok(longItem, "fixture omitted its typed fragmented literal");
assert.ok(fragmentedLiteral.fragments.length > 1);
assert.ok(longItem.provider_value.endsWith("…"));
assert.equal(longItem.string.termination, "missing");
const literalProcedure = (await call("list_procedures")).find((item) =>
item.value.endsWith("rea_long_literal_procedure"),
);
assert.ok(literalProcedure);
const dossier = await call("analyze_function", {
procedure: literalProcedure.address,
});
const referenced = dossier.referenced_strings.find(
(item) => item.address === longItem.address,
);
assert.ok(referenced, "function dossier omitted its long literal reference");
const { source_address, ...stringRecord } = referenced;
assert.ok(
dossier.outgoing_references.some(
(edge) =>
edge.source_address === source_address &&
edge.target_address === longItem.address,
),
);
assert.deepEqual(
stringRecord,
longItem,
"function dossier discarded typed string provenance",
);
assert.deepEqual(
await call("search_strings", {
pattern: "fragment_tail",
mode: "literal",
case_sensitive: true,
}),
[fragmentedLiteral.fragments.at(-1)],
);
const expected = strings.filter((item) =>
/^REA_C_(?:ENTRY|LEAF)$/u.test(item.value),
);
assert.equal(expected.length, 2);
assert.deepEqual(
await call("search_strings", {
pattern: "^REA_C_(?:ENTRY|LEAF)$",
mode: "regex",
case_sensitive: true,
}),
expected,
);
const objects = await verifyHopperStringObjects(
call,
[
{
value: "REA_UTF8_é_😀",
encoding: "utf-8",
symbol: `${fixtureSymbolPrefix}rea_utf8_literal`,
recognitionOptional: true,
},
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" },
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" },
{
value: "REA_LITERAL_…",
encoding: "utf-8",
symbol: `${fixtureSymbolPrefix}rea_ellipsis_literal`,
recognitionOptional: true,
},
{
value: "REA_LATIN1_ÿ",
encoding: "latin-1",
symbol: `${fixtureSymbolPrefix}rea_latin1_literal`,
recognitionOptional: true,
},
],
strings,
);
return {
fullLiteralSourceBytes: fullLiteral.sourceBytes,
fragmentedLiteralSourceBytes: fragmentedLiteral.sourceBytes,
fullLiteralRecognition: fullLiteral.recognition,
...objects,
};
}
/** Bind native fragments to exact source bytes without assuming loader recognition. */
export async function verifyHopperStringFragments(
call,
strings,
{ symbol, value, recognitionOptional = false },
) {
const expectedBytes = Buffer.from(value + "\0");
const raw = await call("read_bytes", {
address: symbol,
length: expectedBytes.length,
});
assert.equal(raw.complete, true);
assert.equal(raw.bytes_hex, expectedBytes.toString("hex"));
const start = BigInt(raw.address);
const end = start + BigInt(expectedBytes.length);
const fragments = strings.filter((item) => {
const address = BigInt(item.address);
return address >= start && address < end;
});
if (fragments.length === 0 && recognitionOptional)
return {
sourceBytes: expectedBytes.length,
recognition: {
address: raw.address,
state: "not_observed",
decodedObject: "unverified",
},
fragments,
};
assert.ok(fragments.length > 0, "fixture omitted its typed string fragments");
let next = start;
for (const [index, fragment] of fragments.entries()) {
assert.equal(
BigInt(fragment.address),
next,
"string fragments have a gap, overlap or wrong source identity",
);
const terminal = index === fragments.length - 1;
assert.equal(
fragment.string.termination,
terminal ? "present_or_not_required" : "missing",
);
const expected = Buffer.from(fragment.value + (terminal ? "\0" : ""));
assert.equal(fragment.string.byte_length, expected.length);
const bytes = await call("read_bytes", {
address: fragment.address,
length: expected.length,
});
assert.equal(bytes.complete, true);
assert.equal(bytes.bytes_hex, expected.toString("hex"));
next += BigInt(expected.length);
}
assert.equal(next, end, "string fragments do not cover the source extent");
assert.equal(fragments.map((item) => item.value).join(""), value);
return {
sourceBytes: expectedBytes.length,
recognition: {
address: raw.address,
state: "observed",
decodedObject: "verified",
},
fragments,
};
}
/** Compare native string identities and values with the source fixture's bytes. */
export async function verifyHopperStringObjects(
call,
expectations,
inventory = null,
) {
// Reuse only the caller's inventory for this unchanged document, never live
// state from an earlier session. Address-filter and search checks remain real.
const strings = inventory ?? (await call("list_strings"));
const recognition = [];
for (const {
value,
encoding,
symbol,
recognitionOptional = false,
} of expectations) {
const expectedBytes = Buffer.from(
value + "\0",
encoding.replaceAll("-", ""),
);
let sourceBytes;
if (symbol !== undefined) {
const raw = await call("read_bytes", {
address: symbol,
length: expectedBytes.length,
});
sourceBytes = raw;
assert.equal(raw.complete, true);
assert.equal(raw.bytes_hex, expectedBytes.toString("hex"));
}
const item = strings.find((candidate) =>
sourceBytes === undefined
? candidate.value === value
: candidate.address === sourceBytes.address,
);
if (item === undefined && recognitionOptional) {
assert.ok(
symbol,
"optional recognition requires independent source-byte coverage",
);
recognition.push({
value,
address: sourceBytes.address,
state: "not_observed",
decodedObject: "unverified",
});
continue;
}
assert.ok(
item,
`Hopper omitted the decoded string ${JSON.stringify(value)}`,
);
assert.equal(
item.value,
value,
"native string decoding changed the source value",
);
recognition.push({
value,
address: item.address,
state: "observed",
decodedObject: "verified",
});
assert.equal(item.string.encoding, encoding);
assert.equal(item.string.encoding_status, "inferred");
assert.equal(item.string.termination, "present_or_not_required");
assert.equal(item.string.byte_length, expectedBytes.length);
const bytes =
sourceBytes ??
(await call("read_bytes", {
address: item.address,
length: expectedBytes.length,
}));
assert.equal(bytes.complete, true);
assert.equal(bytes.bytes_hex, expectedBytes.toString("hex"));
assert.deepEqual(await call("list_strings", { address: item.address }), [
item,
]);
assert.deepEqual(
await call("search_strings", {
pattern: value,
mode: "literal",
case_sensitive: true,
}),
[item],
);
}
assert.deepEqual(
await call("search_strings", { pattern: "\\xC3\\xA9", mode: "literal" }),
[],
);
return {
sourceBytesVerified: true,
recognition,
displayEscapesExcludedFromMatching: true,
};
}
/** A failed or cancelled pathological regex must leave the real Hopper API usable. */
export async function verifyHopperRegexIsolation(
client,
options,
call,
address,
) {
const original = await call("address_name", { address });
const pathological = "a".repeat(40) + "!";
try {
await call("set_address_name", { address, name: pathological });
const reply = await client.callTool(
{
name: "search_procedures",
arguments: { pattern: "(a+)+$", mode: "regex", case_sensitive: true },
},
options,
);
assert.equal(reply.isError, true);
const { error } = JSON.parse(mcpTextValue(reply));
assert.equal(error.code, "resource_constraint");
assert.equal(error.details.operation, "search_procedures");
assert.equal(error.details.resource, "cpu");
assert.equal(await call("address_name", { address }), pathological);
const controller = new AbortController();
const pending = client.callTool(
{
name: "search_procedures",
arguments: { pattern: "(a+)+$", mode: "regex", case_sensitive: true },
},
{ ...options, signal: controller.signal },
);
const cancelled = pending.catch((cause) => cause);
// The same small inventory has already completed once; leave the pathological
// matching worker running before cancelling the second caller.
await new Promise((resolve) => setTimeout(resolve, 750));
controller.abort();
await cancelled;
assert.equal(await call("address_name", { address }), pathological);
assert.deepEqual(
await call("search_procedures", {
pattern: pathological,
mode: "literal",
case_sensitive: true,
}),
[{ address, value: pathological }],
);
} finally {
await call("set_address_name", { address, name: original });
}
return {
regexDeadlinePreservesSession: true,
regexCancellationPreservesSession: true,
};
}