From 1fdd3df7b4eb5b0a1e15f071c8d786c8e9ec2717 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:54:59 +0800 Subject: [PATCH] fix(hopper): preserve regex inventory facts --- src/hopper/HopperRegexSearch.test.ts | 101 +++++++++++++++++++++++++++ src/hopper/HopperRegexSearch.ts | 13 ++-- 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/src/hopper/HopperRegexSearch.test.ts b/src/hopper/HopperRegexSearch.test.ts index 2a3b4c6ad6..ea1b3abc58 100644 --- a/src/hopper/HopperRegexSearch.test.ts +++ b/src/hopper/HopperRegexSearch.test.ts @@ -8,6 +8,10 @@ import { HopperProcessError, } from "../domain/hopperErrors.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { parseConfig } from "../config/parseConfig.js"; +import { silentLogger } from "../logger.js"; +import { HopperClient } from "./HopperClient.js"; +import { HopperProvider } from "./HopperProvider.js"; const regexMatch = vi.hoisted(() => ({ afterSettle: undefined as (() => void) | undefined, @@ -27,6 +31,103 @@ vi.mock("../process/RegexMatcher.js", async (importOriginal) => { }; }); +it.each(["search_strings", "search_procedures"] as const)( + "preserves selected source metadata through the provider's %s regex boundary", + async (operation) => { + const config = parseConfig({}); + if (!config.ok) throw config.error; + const metadata = + operation === "search_strings" + ? { + provider_value: "short display", + string: { + encoding: "utf-8", + encoding_status: "observed", + termination: "present_or_not_required", + byte_length: 7, + }, + } + : { + procedure: { external: false, thunk: true, thunk_target: "0x2" }, + }; + const selected = { + address: "0x1", + value: "matched", + ...metadata, + provider_metadata: { empty: "", absent: null, zero: 0, flag: false }, + }; + const source = [ + { address: "0x1", value: "different" }, + selected, + { ...selected, value: "matched alternative" }, + ]; + const callTool = vi + .spyOn(HopperClient.prototype, "callTool") + .mockResolvedValue(ok(source)); + const provider = new HopperProvider( + config.value, + silentLogger, + {}, + "linux", + ); + const client = provider.createClient({ + path: "/tmp/fixture.hop", + sha256: "a".repeat(64), + kind: "database", + format: "analysis-database", + }); + try { + const result = await client.execute(operation, { + pattern: "^matched$", + mode: "regex", + case_sensitive: true, + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.result).toEqual([selected]); + expect(callTool).toHaveBeenCalledOnce(); + expect(callTool.mock.calls[0]?.[0]).toBe( + operation === "search_strings" ? "list_strings" : "list_procedures", + ); + expect(source[1]).toEqual(selected); + } finally { + await client.close(); + callTool.mockRestore(); + } + }, +); + +it.each(["search_strings", "search_procedures"] as const)( + "rejects malformed canonical metadata in the %s inventory", + async (operation) => { + const search = new HopperRegexSearch({ + callTool: async () => + ok([ + { + address: "0x1", + value: "matched", + ...(operation === "search_strings" + ? { string: { encoding: false } } + : { procedure: { external: "unknown" } }), + }, + ]), + }); + try { + expect( + await search.execute(operation, { + pattern: "matched", + mode: "regex", + case_sensitive: true, + }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError", operation }, + }); + } finally { + await search.close(); + } + }, +); + it.each( (["search_strings", "search_procedures"] as const).flatMap((operation) => (["remote", "capability", "timeout", "process"] as const).map((family) => ({ diff --git a/src/hopper/HopperRegexSearch.ts b/src/hopper/HopperRegexSearch.ts index b49fac82dc..f4828eb6e3 100644 --- a/src/hopper/HopperRegexSearch.ts +++ b/src/hopper/HopperRegexSearch.ts @@ -6,7 +6,7 @@ import { AnalysisCancelledError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; -import { analysisStringSchema } from "../domain/native/nativeString.js"; +import { nativeInventoryRowsSchemas } from "../domain/native/nativeInventory.js"; import { HopperRemoteError } from "../domain/hopperErrors.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -111,8 +111,13 @@ export class HopperRegexSearch { return err( analysisClientFailureForOperation(inventory.error, "hopper", operation), ); - const parsed = z.array(analysisStringSchema).safeParse(inventory.value); - if (!parsed.success) + const rows = inventory.value; + const schema = + operation === "search_strings" + ? nativeInventoryRowsSchemas.strings + : nativeInventoryRowsSchemas.names; + const parsed = schema.safeParse(rows); + if (!parsed.success || !Array.isArray(rows)) return err( new AnalysisOutputError( operation, @@ -150,6 +155,6 @@ export class HopperRegexSearch { new AnalysisCancelledError(operation, { cause: options.signal.reason }), ); const selected = new Set(matched.value); - return ok(parsed.data.filter((_, index) => selected.has(index))); + return ok(rows.filter((_, index) => selected.has(index))); } }