diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 1cf913a11f..fe576791cd 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -687,7 +687,7 @@ complete observation. summary keeps every decoded document and node/edge/coverage counts and retains the complete Evidence; page rows with `inspect_analysis_view` `view: {kind: "interface-builder", collection: "nodes", document: -"Contents/Resources/Base.lproj/MainMenu.nib", offset: 0, limit: 64}`. + "Contents/Resources/Base.lproj/MainMenu.nib", offset: 0, limit: 64}`. `document` selects rows whose evidence names that exact `documents[].relative_path` (nodes and edges only); `source_truncated` keeps graph incompleteness visible on every page. `verify:interface-builder` compiles diff --git a/scripts/verify-real-hopper-callers.mjs b/scripts/verify-real-hopper-callers.mjs index ca47ba5e78..7407114e6c 100644 --- a/scripts/verify-real-hopper-callers.mjs +++ b/scripts/verify-real-hopper-callers.mjs @@ -9,6 +9,7 @@ import { createRequire } from "node:module"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import { artifactMcpCall } from "./lib/artifact-e2e.mjs"; import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs"; import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js"; @@ -71,7 +72,8 @@ transport.stderr?.on("data", (chunk) => { }); const call = async (name, args, options = {}) => { process.stderr.write(`calling ${name}\n`); - const result = await client.callTool( + const result = await artifactMcpCall( + client, { name, arguments: args }, { timeout: 120000, ...options }, ); @@ -248,7 +250,8 @@ try { let partial = false; let resolved = false; try { - await client.callTool( + await artifactMcpCall( + client, { name: "get_call_graph", arguments: query }, { timeout: 120000, diff --git a/src/application/WorkflowEvidence.test.ts b/src/application/WorkflowEvidence.test.ts index 8580da8b63..deed631640 100644 --- a/src/application/WorkflowEvidence.test.ts +++ b/src/application/WorkflowEvidence.test.ts @@ -662,12 +662,20 @@ it("clears workflow state when its callback throws", async () => { execute: async (_operation, _parameters, options) => { captured = options?.workScope; captured?.set(owner, "in flight"); - return ok(createAnalysisExecution(null, { id: "fixture", name: "Fixture", version: "1" })); + return ok( + createAnalysisExecution(null, { + id: "fixture", + name: "Fixture", + version: "1", + }), + ); }, }; - await expect(executeComposedWorkflow(analysis, async (observed) => { - await observed.execute("health", {}); - throw new Error("failed workflow"); - })).rejects.toThrow("failed workflow"); + await expect( + executeComposedWorkflow(analysis, async (observed) => { + await observed.execute("health", {}); + throw new Error("failed workflow"); + }), + ).rejects.toThrow("failed workflow"); expect(captured?.get(owner)).toBeUndefined(); }); diff --git a/src/application/binary/BinarySessionLiveness.test.ts b/src/application/binary/BinarySessionLiveness.test.ts index 91227b31e6..a39c4ebec1 100644 --- a/src/application/binary/BinarySessionLiveness.test.ts +++ b/src/application/binary/BinarySessionLiveness.test.ts @@ -636,7 +636,10 @@ const killableDeepProvider = (options: { }, kill: ( state: - "exited" | "unreachable" | "unknown" | "not_started" = "exited", + | "exited" + | "unreachable" + | "unknown" + | "not_started" = "exited", ) => { alive = false; deadState = state; diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index d758ee0739..71c59942bd 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -251,6 +251,7 @@ import type { } from "./process/processCapture.js"; import type { FileOffsetPartialObservation } from "./native/fileOffsetPartialObservation.js"; import type { NativeCallPartialObservation } from "./native/nativeCallPartialObservation.js"; +import type { HopperCallerReconciliationPartial } from "./native/hopperCallerReconciliation.js"; import type { ElectronActivePartialObservation } from "./javascript/electronActiveObservation.js"; import type { JavaScriptRuntimeObservation } from "./javascript/javascriptRuntimeObservation.js"; import type { @@ -314,6 +315,7 @@ export type AnalysisPartialObservation = | FileSnapshotPartialObservation | PartialProcessCaptureObservation | NativeCallPartialObservation + | HopperCallerReconciliationPartial | FileOffsetPartialObservation | ElectronActivePartialObservation | BrowserScenarioPartialObservation diff --git a/src/domain/native/hopperCallerReconciliation.ts b/src/domain/native/hopperCallerReconciliation.ts new file mode 100644 index 0000000000..4086337f05 --- /dev/null +++ b/src/domain/native/hopperCallerReconciliation.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +import { jsonValueSchema } from "../jsonValue.js"; +import { unknownRelationshipCoverageSchema } from "../relationshipCoverage.js"; + +/** Hopper bridge page allowance for one procedure in a caller scan. */ +export const HOPPER_CALLER_PAGE_ALLOWANCE_BYTES = 512 * 1024; + +/** Encoded scan facts retained for one reconciliation. */ +export const HOPPER_CALLER_SCAN_RETENTION_BYTES = 4 * 1024 * 1024; + +const hopperCallerScanStopSchema = z.discriminatedUnion("code", [ + z.strictObject({ code: z.literal("complete") }), + z.strictObject({ + code: z.literal("work_budget"), + budget_ms: z.int().positive(), + }), + z.strictObject({ code: z.literal("inventory_changed") }), + z.strictObject({ + code: z.literal("page_allowance"), + allowance_bytes: z.literal(HOPPER_CALLER_PAGE_ALLOWANCE_BYTES), + }), + z.strictObject({ + code: z.literal("retention_budget"), + allowance_bytes: z.literal(HOPPER_CALLER_SCAN_RETENTION_BYTES), + }), + z.strictObject({ code: z.literal("cancelled") }), + z.strictObject({ code: z.literal("page_failure") }), + z.strictObject({ code: z.literal("invalid_page") }), + z.strictObject({ code: z.literal("invalid_progress") }), +]); + +/** Structured progress for one Hopper caller scan, separate from its wording. */ +export const hopperCallerScanFactsSchema = z.strictObject({ + examined_procedures: z.int().nonnegative(), + procedure_total: z.int().nonnegative().nullable(), + retained_bytes: z.int().nonnegative(), + stop: hopperCallerScanStopSchema, + instructions_complete: z.boolean(), + native_callers_complete: z.boolean().nullable(), +}); + +export type HopperCallerScanFacts = z.infer; +export type HopperCallerScanStop = HopperCallerScanFacts["stop"]; + +/** Counts collected before instruction and native-caller flags are known. */ +export type HopperCallerScanCounts = Omit< + HopperCallerScanFacts, + "instructions_complete" | "native_callers_complete" +>; + +/** Native reply and forward scan facts retained when reconciliation stops. */ +export const hopperCallerReconciliationPartialSchema = z.strictObject({ + kind: z.literal("hopper-caller-reconciliation"), + native_result: jsonValueSchema, + scan: hopperCallerScanFactsSchema.nullable(), + forward_destinations: jsonValueSchema.nullable(), + unread_page: jsonValueSchema.optional(), + coverage: unknownRelationshipCoverageSchema, +}); + +export type HopperCallerReconciliationPartial = z.infer< + typeof hopperCallerReconciliationPartialSchema +>; + +const stopDetail = (stop: HopperCallerScanStop): string => { + switch (stop.code) { + case "complete": + return ""; + case "work_budget": + return `the ${String(stop.budget_ms)} ms reconciliation work budget was reached; set REA_HOPPER_CALLER_SCAN_BUDGET_MS and restart REA to change it`; + case "inventory_changed": + return "the procedure inventory changed during reconciliation"; + case "page_allowance": + return "a single procedure exceeded the 512 KiB page allowance"; + case "retention_budget": + return `the ${String(stop.allowance_bytes)} byte reconciliation retention budget was reached`; + case "cancelled": + return "cancellation was observed after completed scan observations"; + case "page_failure": + return "a later scan page failed"; + case "invalid_page": + return "a scan page reply did not match the scan schema"; + case "invalid_progress": + return "a scan page reported inconsistent progress"; + } +}; + +/** Wording for scan facts. Tests compare this formatter and the facts separately. */ +export const formatHopperCallerScanReason = ( + facts: HopperCallerScanFacts, +): string => { + const total = + facts.procedure_total === null ? "unknown" : String(facts.procedure_total); + const detail = stopDetail(facts.stop); + let reason = `Hopper caller reconciliation examined ${String(facts.examined_procedures)} of ${total} procedures and retained ${String(facts.retained_bytes)} encoded bytes${detail === "" ? "" : `; ${detail}`}. Forward and reverse API coverage and consistency during the live scan remain unknown.`; + if (!facts.instructions_complete) + reason += + " Target instruction enumeration reached the 512 KiB allowance; incoming call-site coverage is incomplete."; + if (facts.native_callers_complete === false) + reason += " Native caller enumeration reached the 512 KiB allowance."; + return reason; +}; diff --git a/src/hopper/HopperCallerReconciliation.test.ts b/src/hopper/HopperCallerReconciliation.test.ts index 288df1c57d..058fcfb9b6 100644 --- a/src/hopper/HopperCallerReconciliation.test.ts +++ b/src/hopper/HopperCallerReconciliation.test.ts @@ -1,8 +1,10 @@ import { expect, it, vi } from "vitest"; -import { ok } from "../domain/result.js"; +import { err, ok } from "../domain/result.js"; import type { HopperClient } from "./HopperClient.js"; import { reconcileHopperCallers } from "./HopperCallerReconciliation.js"; import { AnalysisWorkScope } from "../application/AnalysisWorkScope.js"; +import { formatHopperCallerScanReason } from "../domain/native/hopperCallerReconciliation.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; const identity = (address: string) => ({ address, @@ -141,7 +143,17 @@ it("merges paged call sites and pins the selected target", async () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.result).toMatchObject({ references: [edge] }); - expect(result.value.reason).toContain("65 of 65"); + expect(result.value.scan).toMatchObject({ + examined_procedures: 65, + procedure_total: 65, + stop: { code: "complete" }, + instructions_complete: true, + native_callers_complete: null, + }); + if (result.value.scan === undefined) throw new Error("missing scan facts"); + expect(result.value.reason).toBe( + formatHopperCallerScanReason(result.value.scan), + ); expect(callTool.mock.calls[2]?.[1]).toMatchObject({ start: 64 }); }); it("does not reuse a previous scan", async () => { @@ -204,6 +216,16 @@ it("stops between pages on cancellation", async () => { if (!result.ok) { expect(result.error._tag).toBe("AnalysisCancelledError"); expect(result.error.cause).toBe("test cancellation"); + expect(result.error.partialObservation).toMatchObject({ + kind: "hopper-caller-reconciliation", + native_result: base, + coverage: { status: "unknown" }, + scan: { + examined_procedures: 64, + procedure_total: 65, + stop: { code: "cancelled" }, + }, + }); } expect(callTool).toHaveBeenCalledTimes(1); }); @@ -234,7 +256,59 @@ it("preserves cancellation while reporting the last page", async () => { 250, ); expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.cause).toBe("cancelled in progress"); + if (!result.ok) { + expect(result.error.cause).toBe("cancelled in progress"); + expect(result.error.partialObservation).toMatchObject({ + kind: "hopper-caller-reconciliation", + native_result: base, + coverage: { status: "unknown" }, + scan: { + examined_procedures: 1, + procedure_total: 1, + stop: { code: "cancelled" }, + }, + forward_destinations: [ + expect.objectContaining({ + address: target.address, + callers: [expect.objectContaining({ address: caller.address })], + }), + ], + }); + } +}); +it("retains completed pages when a later scan page fails", async () => { + let pages = 0; + const callTool = vi.fn().mockImplementation((method: string) => { + if (method === "rea_caller_scan_target") + return Promise.resolve(ok(reply(method))); + pages += 1; + if (pages === 1) return Promise.resolve(ok(page(0))); + return Promise.resolve( + err( + new AnalysisOutputError("rea_caller_scan_page", "bridge page failed"), + ), + ); + }); + const result = await reconcileHopperCallers( + { callTool } as unknown as HopperClient, + "procedure_callers", + {}, + base, + undefined, + 250, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.partialObservation).toMatchObject({ + kind: "hopper-caller-reconciliation", + native_result: base, + coverage: { status: "unknown" }, + scan: { + examined_procedures: 64, + procedure_total: 65, + stop: { code: "page_failure" }, + }, + }); }); it("reports a changing inventory as incomplete", async () => { const callTool = vi @@ -257,7 +331,16 @@ it("reports a changing inventory as incomplete", async () => { 250, ); expect(result.ok).toBe(true); - if (result.ok) expect(result.value.reason).toContain("64 of 65"); + if (!result.ok) return; + expect(result.value.scan).toMatchObject({ + examined_procedures: 64, + procedure_total: 65, + stop: { code: "inventory_changed" }, + }); + if (result.value.scan === undefined) throw new Error("missing scan facts"); + expect(result.value.reason).toBe( + formatHopperCallerScanReason(result.value.scan), + ); }); it("retains original callers and reports a procedure that exceeds the page allowance", async () => { @@ -277,8 +360,17 @@ it("retains original callers and reports a procedure that exceeds the page allow expect(result.ok).toBe(true); if (result.ok) { expect(result.value.result).toEqual(["0x1000"]); - expect(result.value.reason).toContain( - "single procedure exceeded the 512 KiB page allowance", + expect(result.value.scan).toMatchObject({ + examined_procedures: 0, + procedure_total: 65, + stop: { + code: "page_allowance", + allowance_bytes: 512 * 1024, + }, + }); + if (result.value.scan === undefined) throw new Error("missing scan facts"); + expect(result.value.reason).toBe( + formatHopperCallerScanReason(result.value.scan), ); } }); @@ -299,11 +391,16 @@ it("reports truncated target instructions and native callers", async () => { ); expect(result.ok).toBe(true); if (result.ok) { - expect(result.value.reason).toContain( - "Target instruction enumeration reached the 512 KiB allowance", - ); - expect(result.value.reason).toContain( - "Native caller enumeration reached the 512 KiB allowance", + expect(result.value.scan).toMatchObject({ + examined_procedures: 0, + procedure_total: 0, + stop: { code: "complete" }, + instructions_complete: false, + native_callers_complete: false, + }); + if (result.value.scan === undefined) throw new Error("missing scan facts"); + expect(result.value.reason).toBe( + formatHopperCallerScanReason(result.value.scan), ); } }); diff --git a/src/hopper/HopperCallerReconciliation.ts b/src/hopper/HopperCallerReconciliation.ts index 9c1cbf2218..22567b289c 100644 --- a/src/hopper/HopperCallerReconciliation.ts +++ b/src/hopper/HopperCallerReconciliation.ts @@ -17,6 +17,16 @@ import { nativeCallClassificationSchema } from "../domain/native/nativeCallRefer import { err, ok, type Result } from "../domain/result.js"; import type { HopperClient } from "./HopperClient.js"; import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; +import { + formatHopperCallerScanReason, + hopperCallerReconciliationPartialSchema, + HOPPER_CALLER_PAGE_ALLOWANCE_BYTES, + HOPPER_CALLER_SCAN_RETENTION_BYTES, + type HopperCallerReconciliationPartial, + type HopperCallerScanCounts, + type HopperCallerScanFacts, + type HopperCallerScanStop, +} from "../domain/native/hopperCallerReconciliation.js"; const compactEdgeSchema = z.strictObject({ source_address: z.string(), @@ -59,7 +69,9 @@ const referencesSchema = z.strictObject({ references: z.array(referenceEdgeSchema), }); type Operation = - "procedure_callers" | "procedure_references" | "analyze_function"; + | "procedure_callers" + | "procedure_references" + | "analyze_function"; type Identity = z.infer; type Edge = z.infer; type CompactEdge = z.infer; @@ -67,8 +79,16 @@ type Destination = { callers: Map; references: Array<{ caller: Identity; edge: CompactEdge }>; }; -type Index = { destinations: Map; reason: string }; -const MAX_RETAINED_SCAN_BYTES = 4 * 1024 * 1024; +type Index = { + destinations: Map; + counts: HopperCallerScanCounts; +}; +type ScanFailure = { + readonly error: AnalysisError; + readonly counts: HopperCallerScanCounts; + readonly destinations: Map; + readonly unreadPage?: JsonValue; +}; /** A child bridge request is a phase of the enclosing user operation. */ export function reconciliationChildOptions( @@ -109,13 +129,25 @@ async function reportScanProgress( } } +const scanCounts = ( + stop: HopperCallerScanStop, + examined: number, + procedureTotal: number | undefined, + retainedBytes: number, +): HopperCallerScanCounts => ({ + examined_procedures: examined, + procedure_total: procedureTotal ?? null, + retained_bytes: retainedBytes, + stop, +}); + async function buildIndex( client: HopperClient, operation: Operation, document: JsonValue | undefined, options: ExecutionOptions | undefined, budgetMs: number, -): Promise> { +): Promise> { const destinations = new Map(); const destination = (address: string): Destination => { let value = destinations.get(address); @@ -129,14 +161,25 @@ async function buildIndex( total: number | undefined, bytes = 0; const began = performance.now(); - let stopped = ""; + let stop: HopperCallerScanStop = { code: "complete" }; + const cancelled = (): AnalysisCancelledError => + new AnalysisCancelledError(operation, { cause: options?.signal?.reason }); + const fail = ( + error: AnalysisError, + failureStop: HopperCallerScanStop, + unreadPage?: JsonValue, + ): Result => + err({ + error, + counts: scanCounts(failureStop, start, total, bytes), + destinations, + ...(unreadPage === undefined ? {} : { unreadPage }), + }); do { if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(operation, { cause: options.signal.reason }), - ); + return fail(cancelled(), { code: "cancelled" }); if (start > 0 && performance.now() - began >= budgetMs) { - stopped = `the ${budgetMs} ms reconciliation work budget was reached; set REA_HOPPER_CALLER_SCAN_BUDGET_MS and restart REA to change it`; + stop = { code: "work_budget", budget_ms: budgetMs }; break; } if (start > 0) await reportScanProgress(options, start, total); @@ -160,20 +203,25 @@ async function buildIndex( pageOptions, ); if (!page.ok) - return err( - analysisClientFailureForOperation(page.error, "hopper", operation), - ); - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(operation, { cause: options.signal.reason }), - ); + return options?.signal?.aborted + ? fail(cancelled(), { code: "cancelled" }) + : fail( + analysisClientFailureForOperation(page.error, "hopper", operation), + { code: "page_failure" }, + ); const decoded = pageSchema.safeParse(page.value); if (!decoded.success) - return err( - new AnalysisOutputError(operation, "Invalid Hopper caller scan page", { - cause: decoded.error, - }), - ); + return options?.signal?.aborted + ? fail(cancelled(), { code: "cancelled" }, page.value) + : fail( + new AnalysisOutputError( + operation, + "Invalid Hopper caller scan page", + { cause: decoded.error }, + ), + { code: "invalid_page" }, + page.value, + ); const value = decoded.data; if ( value.start !== start || @@ -186,24 +234,34 @@ async function buildIndex( value.items.length !== value.next - start || (value.oversized && value.next !== start) ) - return err( - new AnalysisOutputError( - operation, - "Invalid Hopper caller scan progress", - ), - ); + return options?.signal?.aborted + ? fail(cancelled(), { code: "cancelled" }, page.value) + : fail( + new AnalysisOutputError( + operation, + "Invalid Hopper caller scan progress", + ), + { code: "invalid_progress" }, + page.value, + ); if (total !== undefined && value.total !== total) { - stopped = "the procedure inventory changed during reconciliation"; + stop = { code: "inventory_changed" }; break; } total = value.total; if (value.oversized) { - stopped = "a single procedure exceeded the 512 KiB page allowance"; + stop = { + code: "page_allowance", + allowance_bytes: HOPPER_CALLER_PAGE_ALLOWANCE_BYTES, + }; break; } const pageBytes = Buffer.byteLength(JSON.stringify(page.value)); - if (pageBytes > MAX_RETAINED_SCAN_BYTES - bytes) { - stopped = `the ${MAX_RETAINED_SCAN_BYTES} byte reconciliation retention budget was reached`; + if (pageBytes > HOPPER_CALLER_SCAN_RETENTION_BYTES - bytes) { + stop = { + code: "retention_budget", + allowance_bytes: HOPPER_CALLER_SCAN_RETENTION_BYTES, + }; break; } bytes += pageBytes; @@ -218,16 +276,17 @@ async function buildIndex( } } start = value.next; + if (options?.signal?.aborted) + return fail(cancelled(), { code: "cancelled" }); await reportScanProgress(options, start, total); + if (options?.signal?.aborted) + return fail(cancelled(), { code: "cancelled" }); } while (start < (total ?? 0)); await reportScanProgress(options, start, total, true); - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(operation, { cause: options.signal.reason }), - ); + if (options?.signal?.aborted) return fail(cancelled(), { code: "cancelled" }); return ok({ destinations, - reason: `Hopper caller reconciliation examined ${start} of ${total ?? 0} procedures and retained ${bytes} encoded bytes${stopped ? `; ${stopped}` : ""}. Forward and reverse API coverage and consistency during the live scan remain unknown.`, + counts: scanCounts(stop, start, total, bytes), }); } @@ -240,19 +299,69 @@ export const reconcileHopperCallers = async ( budgetMs: number, ): Promise< Result< - { result: JsonValue; rawResult: JsonValue; reason: string }, + { + result: JsonValue; + rawResult: JsonValue; + reason: string; + scan?: HopperCallerScanFacts; + }, AnalysisError > > => { + const retainNative = ( + error: AnalysisError, + partial: { + readonly scan: HopperCallerScanFacts | null; + readonly destinations: Map | null; + readonly unreadPage?: JsonValue; + }, + ): AnalysisError => { + const observation: HopperCallerReconciliationPartial = { + kind: "hopper-caller-reconciliation", + native_result: original, + scan: partial.scan, + forward_destinations: + partial.destinations === null + ? null + : [...partial.destinations.entries()].map(([address, value]) => ({ + address, + callers: [...value.callers.values()], + references: value.references, + })), + coverage: { + status: "unknown", + reason: + partial.scan === null + ? "Hopper caller reconciliation did not examine the procedure inventory; unexamined procedure relationships are unknown." + : formatHopperCallerScanReason(partial.scan), + limited_directions: + operation === "analyze_function" + ? ["callers", "callees"] + : ["callers"], + }, + ...(partial.unreadPage === undefined + ? {} + : { unread_page: partial.unreadPage }), + }; + error.retainPartialObservation( + hopperCallerReconciliationPartialSchema.parse(observation), + ); + return error; + }; const base = operation === "procedure_callers" ? callersBaseSchema.safeParse(original) : undefined; if (base !== undefined && !base.success) return err( - new AnalysisOutputError(operation, "Invalid Hopper caller observation", { - cause: base.error, - }), + retainNative( + new AnalysisOutputError( + operation, + "Invalid Hopper caller observation", + { cause: base.error }, + ), + { scan: null, destinations: null }, + ), ); const parsed = operation === "procedure_callers" @@ -264,9 +373,14 @@ export const reconcileHopperCallers = async ( : referencesSchema.safeParse(original); if (!parsed.success) return err( - new AnalysisOutputError(operation, "Invalid Hopper caller observation", { - cause: parsed.error, - }), + retainNative( + new AnalysisOutputError( + operation, + "Invalid Hopper caller observation", + { cause: parsed.error }, + ), + { scan: null, destinations: null }, + ), ); const args: Record = {}; if (parameters.document !== undefined) args.document = parameters.document; @@ -284,22 +398,35 @@ export const reconcileHopperCallers = async ( ); if (!targetResponse.ok) return err( - analysisClientFailureForOperation( - targetResponse.error, - "hopper", - operation, + retainNative( + analysisClientFailureForOperation( + targetResponse.error, + "hopper", + operation, + ), + { scan: null, destinations: null }, ), ); if (options?.signal?.aborted) return err( - new AnalysisCancelledError(operation, { cause: options.signal.reason }), + retainNative( + new AnalysisCancelledError(operation, { + cause: options.signal.reason, + }), + { scan: null, destinations: null }, + ), ); const selected = targetSchema.safeParse(targetResponse.value); if (!selected.success) return err( - new AnalysisOutputError(operation, "Invalid Hopper caller scan target", { - cause: selected.error, - }), + retainNative( + new AnalysisOutputError( + operation, + "Invalid Hopper caller scan target", + { cause: selected.error }, + ), + { scan: null, destinations: null }, + ), ); const { target, instructions, instructions_complete } = selected.data; if ( @@ -327,7 +454,24 @@ export const reconcileHopperCallers = async ( options, budgetMs, ); - if (!scanned.ok) return scanned; + if (!scanned.ok) { + const failure = scanned.error; + return err( + retainNative(failure.error, { + scan: { + ...failure.counts, + instructions_complete, + native_callers_complete: base?.success + ? base.data.callers_complete + : null, + }, + destinations: failure.destinations, + ...(failure.unreadPage === undefined + ? {} + : { unreadPage: failure.unreadPage }), + }), + ); + } index = scanned.value; if (options?.workScope !== undefined) { if (indexByDocument === undefined) { @@ -410,6 +554,11 @@ export const reconcileHopperCallers = async ( const facts = referencesSchema.parse(parsed.data); result = { ...facts, references: mergeReferences(facts.references) }; } + const scan: HopperCallerScanFacts = { + ...index.counts, + instructions_complete, + native_callers_complete: base?.success ? base.data.callers_complete : null, + }; return ok({ result, rawResult: { @@ -424,6 +573,7 @@ export const reconcileHopperCallers = async ( "hopper-public-python-api:getAllCalleeProcedures and CallReference", }, }, - reason: `${index.reason}${instructions_complete ? "" : " Target instruction enumeration reached the 512 KiB allowance; incoming call-site coverage is incomplete."}${base?.success && !base.data.callers_complete ? " Native caller enumeration reached the 512 KiB allowance." : ""}`, + scan, + reason: formatHopperCallerScanReason(scan), }); }; diff --git a/src/hopper/HopperProvider.test.ts b/src/hopper/HopperProvider.test.ts index 0dd0826b19..91f00c5633 100644 --- a/src/hopper/HopperProvider.test.ts +++ b/src/hopper/HopperProvider.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { parseConfig } from "../config/parseConfig.js"; import { silentLogger } from "../logger.js"; -import { ok } from "../domain/result.js"; +import { err, ok } from "../domain/result.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; import { HopperClient } from "./HopperClient.js"; import { HopperProvider } from "./HopperProvider.js"; import { isSnapshotCacheable } from "../application/binary/AnalysisSnapshotCache.js"; @@ -257,3 +258,93 @@ describe("Hopper relationship coverage", () => { }, ); }); + +describe("Hopper caller reconciliation failures", () => { + it("keeps the native caller reply when a later scan page fails", async () => { + const config = parseConfig({}); + if (!config.ok) throw config.error; + const identity = (address: string) => ({ + address, + name: address, + symbol: null, + classification: null, + body: { available: false as const, reason: "unknown" }, + }); + const native = { + target: identity("0x1000"), + instructions: ["0x1000"], + instructions_complete: true, + callers: ["0x1111"], + callers_complete: true, + }; + let pages = 0; + const callTool = vi + .spyOn(HopperClient.prototype, "callTool") + .mockImplementation((method) => { + if (method === "rea_caller_base") return Promise.resolve(ok(native)); + if (method !== "rea_caller_scan_page") return Promise.resolve(ok(null)); + pages += 1; + if (pages === 1) + return Promise.resolve( + ok({ + start: 0, + next: 1, + total: 2, + oversized: false, + items: [ + { + caller: identity("0x3000"), + callees: ["0x1000"], + references: [], + }, + ], + }), + ); + return Promise.resolve( + err( + new AnalysisOutputError( + "rea_caller_scan_page", + "bridge page failed", + ), + ), + ); + }); + const client = new HopperProvider( + config.value, + silentLogger, + {}, + "linux", + ).createClient({ + path: "/tmp/fixture.hop", + sha256: "a".repeat(64), + kind: "database", + format: "analysis-database", + }); + try { + const observed = await client.execute("procedure_callers", { + procedure: "0x1000", + }); + expect(observed.ok).toBe(false); + if (observed.ok) return; + expect(observed.error.partialObservation).toMatchObject({ + kind: "hopper-caller-reconciliation", + native_result: native, + coverage: { status: "unknown", limited_directions: ["callers"] }, + scan: { + examined_procedures: 1, + procedure_total: 2, + stop: { code: "page_failure" }, + }, + forward_destinations: [ + expect.objectContaining({ + address: "0x1000", + callers: [expect.objectContaining({ address: "0x3000" })], + }), + ], + }); + } finally { + await client.close(); + callTool.mockRestore(); + } + }); +}); diff --git a/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts b/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts index 120b6c0b88..a7a89571ae 100644 --- a/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts +++ b/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts @@ -292,13 +292,29 @@ it.each(["profileless", "uncacheable"] as const)( {}, ); if (!execution.ok) return execution; - expect(execution.value.evidenceId).toBeUndefined(); + if (mode === "profileless") + expect(execution.value.evidenceId).toBeUndefined(); + else { + expect(execution.value.evidenceId).toEqual(expect.any(String)); + expect(execution.value.evidenceId).not.toBe(spoofedEvidenceId); + } return ok(execution.value.result); }, ); if (!composed.ok) throw composed.error; - expect(composed.value.evidenceLinks).toBeUndefined(); - expect(fixture.session.exportEvidenceBundle().records).toEqual([]); + if (mode === "profileless") { + expect(composed.value.evidenceLinks).toBeUndefined(); + expect(fixture.session.exportEvidenceBundle().records).toEqual([]); + } else { + const evidenceId = composed.value.evidenceLinks?.[0]; + expect(evidenceId).toEqual(expect.any(String)); + expect(evidenceId).not.toBe(spoofedEvidenceId); + expect( + fixture.session + .exportEvidenceBundle() + .records.map((record) => record.evidence_id), + ).toEqual([evidenceId]); + } }, ); diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 535144585b..c1e56cac2f 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -1076,12 +1076,22 @@ describe("snapshot cache eligibility", () => { const parameters: Readonly> = live ? { address: "0x1000", document: "main" } : {}; + const evidenceIds: Array = []; for (let attempt = 0; attempt < 2; attempt += 1) { const result = await session.execute("address_name", parameters); - expect(result).toMatchObject({ ok: true }); - if (result.ok) expect(result.value.evidenceId).toBeUndefined(); + if (!result.ok) throw result.error; + evidenceIds.push(result.value.evidenceId); + } + const records = session.exportEvidenceBundle().records; + if (live) { + expect(evidenceIds).toEqual([expect.any(String), evidenceIds[0]]); + expect(records.map((record) => record.evidence_id)).toEqual( + expect.arrayContaining([source.evidence_id, evidenceIds[0]]), + ); + } else { + expect(evidenceIds).toEqual([undefined, undefined]); + expect(records).toEqual([source]); } - expect(session.exportEvidenceBundle().records).toEqual([source]); expect(session.exportAnalysisSnapshot()).toMatchObject({ ok: true, value: { entries: [] }, @@ -1122,20 +1132,20 @@ describe("snapshot cache eligibility", () => { await session.close(); } }); +}); - it("does not snapshot reads that depend on the provider cursor", async () => { - const [first] = await createBinarySessionTargets(); - const calls: string[] = []; - const session = createTestBinarySession(createCacheProvider(calls)); - await openAndExecuteAddressName(session, first, {}); - expect((await session.execute("address_name", {})).ok).toBe(true); - expect(calls).toEqual(["address_name", "address_name"]); - expect(session.exportAnalysisSnapshot()).toMatchObject({ - ok: true, - value: { entries: [] }, - }); - await session.close(); +it("does not snapshot reads that depend on the provider cursor", async () => { + const [first] = await createBinarySessionTargets(); + const calls: string[] = []; + const session = createTestBinarySession(createCacheProvider(calls)); + await openAndExecuteAddressName(session, first, {}); + expect((await session.execute("address_name", {})).ok).toBe(true); + expect(calls).toEqual(["address_name", "address_name"]); + expect(session.exportAnalysisSnapshot()).toMatchObject({ + ok: true, + value: { entries: [] }, }); + await session.close(); }); describe("provider health and availability", () => {