diff --git a/scripts/lib/real-hopper-search.d.mts b/scripts/lib/real-hopper-search.d.mts index e64031652..0a7260227 100644 --- a/scripts/lib/real-hopper-search.d.mts +++ b/scripts/lib/real-hopper-search.d.mts @@ -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[], + 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[]; +}>; + /** Verify source bytes, observed decoding, address filters and literal searches. */ export function verifyHopperStringObjects( call: VerifierCall, diff --git a/scripts/lib/real-hopper-search.mjs b/scripts/lib/real-hopper-search.mjs index 3456bc6b2..0efd2bfb7 100644 --- a/scripts/lib/real-hopper-search.mjs +++ b/scripts/lib/real-hopper-search.mjs @@ -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, diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index 8cad9a256..a82c1429a 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -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, diff --git a/tests/conformance/c/fixture.c b/tests/conformance/c/fixture.c index e1dff9a9c..4f519a922 100644 --- a/tests/conformance/c/fixture.c +++ b/tests/conformance/c/fixture.c @@ -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_Γ©_πŸ˜€"; diff --git a/tests/conformance/providers/hopper/realHopperSearch.test.ts b/tests/conformance/providers/hopper/realHopperSearch.test.ts index 10aedb433..14909c14b 100644 --- a/tests/conformance/providers/hopper/realHopperSearch.test.ts +++ b/tests/conformance/providers/hopper/realHopperSearch.test.ts @@ -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 = {}, +) => { + 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 = {}) => ({ + ...(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(),