test(hopper): bind string assertions to observed source extents
Remove OS-based assumptions about long and non-ASCII string recognition. Require the dedicated fragmented fixture, independently verify full source bytes, and reject gaps, overlaps, incomplete reads and corrupted decoding. Report unrecognized native strings explicitly and retain the actual procedure count. Verified through 14 verifier regressions and the complete macOS arm64 Hopper 6.6.0-demo CLI/MCP lane with cleanup.
This commit is contained in:
parent
b1b0211c28
commit
02987d8e8c
|
|
@ -10,6 +10,25 @@ interface StringExpectation {
|
|||
readonly recognitionOptional?: boolean;
|
||||
}
|
||||
|
||||
/** Verify native fragments against a complete, independently read source extent. */
|
||||
export function verifyHopperStringFragments(
|
||||
call: VerifierCall,
|
||||
inventory: readonly z.output<typeof analysisStringSchema>[],
|
||||
expectation: {
|
||||
readonly value: string;
|
||||
readonly symbol: string;
|
||||
readonly recognitionOptional?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
readonly sourceBytes: number;
|
||||
readonly recognition: {
|
||||
readonly address: string;
|
||||
readonly state: "observed" | "not_observed";
|
||||
readonly decodedObject: "verified" | "unverified";
|
||||
};
|
||||
readonly fragments: readonly z.output<typeof analysisStringSchema>[];
|
||||
}>;
|
||||
|
||||
/** Verify source bytes, observed decoding, address filters and literal searches. */
|
||||
export function verifyHopperStringObjects(
|
||||
call: VerifierCall,
|
||||
|
|
|
|||
|
|
@ -106,27 +106,18 @@ async function verifySearchModes(call, address) {
|
|||
[{ address, value: "REA_Search_Upper" }],
|
||||
);
|
||||
const strings = await call("list_strings");
|
||||
const linux = process.platform === "linux";
|
||||
const fullLiteral = "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle";
|
||||
// Recognition is a provider/loader capability, not an adapter guarantee.
|
||||
// Always retain the original size in raw-byte coverage; Linux additionally
|
||||
// requires an extent observed to produce native fragmented string objects.
|
||||
const fullBytes = Buffer.from(fullLiteral + "\0");
|
||||
const raw = await call("read_bytes", {
|
||||
address: `${fixtureSymbolPrefix}rea_long_literal`,
|
||||
length: fullBytes.length,
|
||||
const fullLiteral = await verifyHopperStringFragments(call, strings, {
|
||||
symbol: `${fixtureSymbolPrefix}rea_long_literal`,
|
||||
value: "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle",
|
||||
recognitionOptional: true,
|
||||
});
|
||||
assert.equal(raw.complete, true);
|
||||
assert.equal(raw.bytes_hex, fullBytes.toString("hex"));
|
||||
const long = linux
|
||||
? "REA_FRAGMENTED_LITERAL_" + "x".repeat(512) + "fragment_tail"
|
||||
: fullLiteral;
|
||||
const longItem = strings.find(
|
||||
(item) => long.startsWith(item.value) && item.value.startsWith("REA_"),
|
||||
);
|
||||
assert.ok(longItem, "fixture omitted its real long literal");
|
||||
if (!linux) assert.equal(longItem.address, raw.address);
|
||||
assert.ok(long.startsWith(longItem.value));
|
||||
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) =>
|
||||
|
|
@ -154,33 +145,13 @@ async function verifySearchModes(call, address) {
|
|||
"function dossier discarded typed string provenance",
|
||||
);
|
||||
|
||||
const fragments = strings.filter((item) => {
|
||||
const offset = BigInt(item.address) - BigInt(longItem.address);
|
||||
return offset >= 0n && offset < BigInt(Buffer.byteLength(long));
|
||||
});
|
||||
assert.equal(fragments.map((item) => item.value).join(""), long);
|
||||
for (const fragment of fragments) {
|
||||
assert.equal(
|
||||
Buffer.byteLength(fragment.value),
|
||||
fragment.string.byte_length -
|
||||
(fragment.string.termination === "missing" ? 0 : 1),
|
||||
);
|
||||
const bytes = await call("read_bytes", {
|
||||
address: fragment.address,
|
||||
length: fragment.string.byte_length,
|
||||
});
|
||||
const expected = Buffer.from(
|
||||
fragment.value + (fragment.string.termination === "missing" ? "" : "\0"),
|
||||
);
|
||||
assert.equal(bytes.bytes_hex, expected.toString("hex"));
|
||||
}
|
||||
assert.deepEqual(
|
||||
await call("search_strings", {
|
||||
pattern: linux ? "fragment_tail" : "needle",
|
||||
pattern: "fragment_tail",
|
||||
mode: "literal",
|
||||
case_sensitive: true,
|
||||
}),
|
||||
[fragments.at(-1)],
|
||||
[fragmentedLiteral.fragments.at(-1)],
|
||||
);
|
||||
const expected = strings.filter((item) =>
|
||||
/^REA_C_(?:ENTRY|LEAF)$/u.test(item.value),
|
||||
|
|
@ -201,7 +172,7 @@ async function verifySearchModes(call, address) {
|
|||
value: "REA_UTF8_é_😀",
|
||||
encoding: "utf-8",
|
||||
symbol: `${fixtureSymbolPrefix}rea_utf8_literal`,
|
||||
recognitionOptional: linux,
|
||||
recognitionOptional: true,
|
||||
},
|
||||
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" },
|
||||
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" },
|
||||
|
|
@ -209,33 +180,90 @@ async function verifySearchModes(call, address) {
|
|||
value: "REA_LITERAL_…",
|
||||
encoding: "utf-8",
|
||||
symbol: `${fixtureSymbolPrefix}rea_ellipsis_literal`,
|
||||
recognitionOptional: linux,
|
||||
recognitionOptional: true,
|
||||
},
|
||||
{
|
||||
value: "REA_LATIN1_ÿ",
|
||||
encoding: "latin-1",
|
||||
symbol: `${fixtureSymbolPrefix}rea_latin1_literal`,
|
||||
recognitionOptional: linux,
|
||||
recognitionOptional: true,
|
||||
},
|
||||
],
|
||||
strings,
|
||||
);
|
||||
return {
|
||||
fullLiteralSourceBytes: fullBytes.length,
|
||||
fragmentedLiteralSourceBytes: Buffer.byteLength(long),
|
||||
fullLiteralRecognition: {
|
||||
address: raw.address,
|
||||
state: strings.some((item) => item.address === raw.address)
|
||||
? "observed"
|
||||
: "not_observed",
|
||||
// Linux validates the original through raw bytes; its required decoded
|
||||
// fragment/dossier checks exercise the separate supported-size literal.
|
||||
decodedObject: linux ? "unverified" : "verified",
|
||||
},
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ const verifyCurrentTarget = async (client, options) => {
|
|||
);
|
||||
return {
|
||||
procedure: dossier.procedure,
|
||||
procedureCount: procedures.total,
|
||||
procedureCount: procedures.length,
|
||||
boundedPseudocodeChars: boundedPseudocode.length,
|
||||
analyzedPseudocodeChars: [...dossier.pseudocode].length,
|
||||
callerCount: relationships.procedure_callers.length,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ volatile int rea_c_global = 7;
|
|||
/* Keep the full-size literal even when a loader does not type it as a string. */
|
||||
__attribute__((used)) const char rea_long_literal[] =
|
||||
"REA_LONG_LITERAL_" REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 "needle";
|
||||
/* Linux Hopper 6.4.2 recognizes this extent and splits it into native objects. */
|
||||
/* Verified Hopper loaders recognize this extent and split it into native objects. */
|
||||
__attribute__((used)) const char rea_fragmented_literal[] =
|
||||
"REA_FRAGMENTED_LITERAL_" REA_LONG_256 REA_LONG_256 "fragment_tail";
|
||||
__attribute__((used)) const char rea_utf8_literal[] = "REA_UTF8_é_😀";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import { verifyHopperStringObjects } from "../../../../scripts/lib/real-hopper-search.mjs";
|
||||
import {
|
||||
verifyHopperStringFragments,
|
||||
verifyHopperStringObjects,
|
||||
} from "../../../../scripts/lib/real-hopper-search.mjs";
|
||||
|
||||
const value = "REA_UTF8_é_😀";
|
||||
const address = "0x402020";
|
||||
|
|
@ -22,6 +25,100 @@ const expectation = {
|
|||
recognitionOptional: true,
|
||||
};
|
||||
|
||||
const fragments = [
|
||||
{
|
||||
...item,
|
||||
value: "REA_",
|
||||
string: { ...item.string, termination: "missing" as const, byte_length: 4 },
|
||||
},
|
||||
{
|
||||
...item,
|
||||
address: "0x402024",
|
||||
value: value.slice(4),
|
||||
string: { ...item.string, byte_length: bytes.length - 4 },
|
||||
},
|
||||
] as const;
|
||||
|
||||
const fragmentBytes = async (
|
||||
name: string,
|
||||
args: Record<string, unknown> = {},
|
||||
) => {
|
||||
if (name !== "read_bytes") throw new Error(`Unmodeled call: ${name}`);
|
||||
const offset =
|
||||
args.address === expectation.symbol
|
||||
? 0
|
||||
: Number(BigInt(String(args.address)) - BigInt(address));
|
||||
return {
|
||||
address: `0x${(BigInt(address) + BigInt(offset)).toString(16)}`,
|
||||
complete: true,
|
||||
bytes_hex: bytes
|
||||
.subarray(offset, offset + Number(args.length))
|
||||
.toString("hex"),
|
||||
};
|
||||
};
|
||||
|
||||
it("verifies contiguous native fragments against the complete source bytes", async () => {
|
||||
const result = await verifyHopperStringFragments(
|
||||
fragmentBytes,
|
||||
fragments,
|
||||
expectation,
|
||||
);
|
||||
expect(result.recognition).toEqual({
|
||||
address,
|
||||
state: "observed",
|
||||
decodedObject: "verified",
|
||||
});
|
||||
expect(result.sourceBytes).toBe(bytes.length);
|
||||
});
|
||||
|
||||
it("reports unrecognized source bytes without accepting identical text at another address", async () => {
|
||||
const elsewhere = [{ ...item, address: "0x403000" }];
|
||||
const result = await verifyHopperStringFragments(
|
||||
fragmentBytes,
|
||||
elsewhere,
|
||||
expectation,
|
||||
);
|
||||
expect(result.recognition).toEqual({
|
||||
address,
|
||||
state: "not_observed",
|
||||
decodedObject: "unverified",
|
||||
});
|
||||
await expect(
|
||||
verifyHopperStringFragments(fragmentBytes, elsewhere, {
|
||||
...expectation,
|
||||
recognitionOptional: false,
|
||||
}),
|
||||
).rejects.toThrow(/typed string fragments/u);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["gap", [fragments[0], { ...fragments[1], address: "0x402025" }]],
|
||||
["overlap", [fragments[0], { ...fragments[1], address: "0x402023" }]],
|
||||
["missing prefix", [fragments[1]]],
|
||||
["wrong decoded value", [{ ...item, value: "X" + value.slice(1) }]],
|
||||
["missing tail", [fragments[0]]],
|
||||
])(
|
||||
"rejects %s even when fragment recognition is optional",
|
||||
async (_label, inventory) => {
|
||||
await expect(
|
||||
verifyHopperStringFragments(fragmentBytes, inventory, expectation),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects incomplete source bytes even with no native string objects", async () => {
|
||||
await expect(
|
||||
verifyHopperStringFragments(
|
||||
async (name: string, args: Record<string, unknown> = {}) => ({
|
||||
...(await fragmentBytes(name, args)),
|
||||
complete: false,
|
||||
}),
|
||||
[],
|
||||
expectation,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("keeps source identity and unknown decoding when the same text exists elsewhere", async () => {
|
||||
const report = await verifyHopperStringObjects(
|
||||
fixtureCall(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue