// Fake-backed composition coverage; real provider verification lives in // `npm run verify:hopper` and `npm run verify:ghidra`. import { describe, expect, it } from "vitest"; import { z } from "zod"; import { createAnalysisExecution, type AnalysisExecution, type AnalysisOperation, } from "../AnalysisProvider.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, } from "../../domain/analysisErrorCore.js"; import { EnhancedTools } from "../EnhancedTools.js"; import { nativeInvestigationGraphSchema, nativeInvestigationTraceSchema, } from "../../domain/native/nativeInvestigationGraph.js"; import { err, ok } from "../../domain/result.js"; import type { Result } from "../../domain/result.js"; const target = "a".repeat(64); const provider = { id: "fixture", name: "Fixture", version: "1" }; const evidence = [ { kind: "interface_builder_resource" as const, description: "Fixture UI archive", location: { address: null, file_offset: null }, artifact_path: null, artifact_sha256: null, }, ]; const graph = { target_sha256: target, provider: { id: provider.id, version: provider.version, tool_version: "fixture", }, nodes: [ { id: "button", kind: "control" as const, name: "Build", location: null, attributes: { class_name: "UIButton", interface_builder_object_id: "button", }, evidence, }, { id: "action", kind: "action" as const, name: "buildTapped:", location: null, attributes: { selector: "buildTapped:" }, evidence, }, { id: "controller", kind: "view_controller" as const, name: "BuildViewController", location: null, attributes: { class_name: "BuildViewController" }, evidence, }, { id: "selector", kind: "objc_selector" as const, name: "buildTapped:", location: null, attributes: {}, evidence, }, ], edges: [ { id: "button-action", from: "button", to: "action", relation: "target_action" as const, resolution: "observed" as const, evidence, limitations: [], }, { id: "action-selector", from: "action", to: "selector", relation: "target_action" as const, resolution: "observed" as const, evidence, limitations: [], }, { id: "action-controller", from: "action", to: "controller", relation: "target_action" as const, resolution: "observed" as const, evidence, limitations: [], }, ], coverage: [], truncated: false, }; const execution = ( operation: AnalysisOperation, result: unknown, sha256 = target, ) => ok( createAnalysisExecution(result, provider, { subject: { sha256, path: "/fixture.app", format: "mach-o", architecture: "arm64", }, }), ); const testAnalysis = ( overrides: Partial< Record< AnalysisOperation, ( parameters: Readonly>, ) => Result > > = {}, uiGraph: z.input = graph, ) => ({ execute: async ( operation: AnalysisOperation, parameters: Readonly>, ): Promise> => { const override = overrides[operation]; if (override !== undefined) return override(parameters); switch (operation) { case "procedure_address": return err( new AnalysisCapabilityUnavailableError( "fixture", operation, "Seed not available", ), ); case "procedure_references": return err( new AnalysisCapabilityUnavailableError( "fixture", operation, "No typed reference reader in fixture", ), ); case "inspect_native_dispatch_metadata": return err( new AnalysisCapabilityUnavailableError( "fixture", operation, "No binary metadata reader in fixture", ), ); case "decode_interface_builder": return execution(operation, { target_sha256: target, documents: [ { relative_path: "Base.lproj/Main.nib", archive_sha256: "b".repeat(64), document_kind: "nib", object_count: 4, connection_count: 3, hierarchy_complete: true, }, ], graph: uiGraph, limitations: [], }); case "list_names": return execution(operation, [ { address: "0x1000", name: "-[BuildViewController buildTapped:]" }, ]); case "procedure_callees": return execution( operation, parameters.procedure === "0x1000" ? ["0x2000"] : [], ); default: throw new Error(`Unexpected provider operation: ${operation}`); } }, }); describe("native UI action trace", () => { it("accepts an explicit native seed without an authored UI connection", async () => { const result = await new EnhancedTools( testAnalysis({ procedure_address: () => execution("procedure_address", "0x1000"), }), ).execute("trace_native_ui_action", { action: "0x1000" }); expect(result).toMatchObject({ ok: true, value: { start: "native:function:0x1000", nodes: expect.arrayContaining([ expect.objectContaining({ id: "native:function:0x2000" }), ]), }, }); }); it("builds the evidence path from the active app and native provider", async () => { const tools = new EnhancedTools(testAnalysis()); const result = await tools.execute("trace_native_ui_action", { action: "buildTapped:", }); if (!result.ok) throw result.error; const trace = nativeInvestigationTraceSchema.parse(result.value); expect(trace.start).toBe("action"); expect(trace.nodes.map(({ id }) => id)).toContain("native:function:0x1000"); expect(trace.nodes.map(({ id }) => id)).toContain("native:function:0x2000"); expect(trace.edges).toContainEqual( expect.objectContaining({ relation: "objc_dispatch", resolution: "inferred", }), ); expect(trace.edges).toContainEqual( expect.objectContaining({ relation: "provider_call", to: "native:function:0x2000", }), ); expect(trace.coverage).toContainEqual( expect.objectContaining({ facet: "cross_function_value_flow", status: "unsupported", }), ); }); }); describe("bounded native UI tracing", () => { it("continues handler traversal from its own depth when another UI branch is deeper", async () => { const deepGraph = { ...graph, nodes: [ ...graph.nodes, { id: "view", kind: "view" as const, name: "Container", location: null, attributes: {}, evidence, }, { id: "nested-view", kind: "view" as const, name: "Nested container", location: null, attributes: {}, evidence, }, ], edges: [ ...graph.edges, { id: "controller-view", from: "controller", to: "view", relation: "contains" as const, resolution: "observed" as const, evidence, limitations: [], }, { id: "view-nested-view", from: "view", to: "nested-view", relation: "contains" as const, resolution: "observed" as const, evidence, limitations: [], }, ], }; const result = await new EnhancedTools(testAnalysis({}, deepGraph)).execute( "trace_native_ui_action", { action: "buildTapped:", max_depth: 3 }, ); if (!result.ok) throw result.error; expect( nativeInvestigationTraceSchema .parse(result.value) .nodes.map(({ id }) => id), ).toContain("native:function:0x2000"); }); it("returns a typed input error for malformed direct metadata requests", async () => { const result = await new EnhancedTools(testAnalysis()).execute( "inspect_native_dispatch_metadata", { max_records: 0 }, ); expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" }, }); }); it("binds dispatch metadata to the active target digest", async () => { const result = await new EnhancedTools(testAnalysis()).execute( "inspect_native_dispatch_metadata", { max_records: 10 }, ); expect(result).toMatchObject({ ok: true, value: { target_sha256: target }, }); }); }); describe("native UI query outcomes", () => { it("resolves a control object ID through its authored action edge", async () => { const tools = new EnhancedTools(testAnalysis()); const result = await tools.execute("trace_native_ui_action", { action: "button", }); if (!result.ok) throw result.error; expect(result.value).toMatchObject({ start: "action" }); }); it("rejects UI and native observations with different target identities", async () => { const tools = new EnhancedTools( testAnalysis({ list_names: () => execution("list_names", [], "c".repeat(64)), }), ); const result = await tools.execute("trace_native_ui_action", { action: "buildTapped:", }); expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError", operation: "decode_interface_builder", }, }); }); it("returns candidates and a reason for an ambiguous selector", async () => { const duplicateGraph = { ...graph, nodes: [ ...graph.nodes, { ...graph.nodes[1], id: "second-action", }, ], }; const tools = new EnhancedTools( testAnalysis({ decode_interface_builder: () => execution("decode_interface_builder", { target_sha256: target, documents: [], graph: duplicateGraph, limitations: [], }), }), ); const result = await tools.execute("trace_native_ui_action", { action: "buildTapped:", }); if (!result.ok) throw result.error; expect(result.value).toMatchObject({ reason: "action_selector_matches_multiple_ui_connections", nodes: [{ id: "action" }, { id: "second-action" }], }); }); it("retains decoder truncation coverage when the action is absent", async () => { const boundedGraph = { ...graph, coverage: [ { facet: "objects:Main.nib", status: "partial" as const, reason: "object_limit_reached", examined: 2, omitted: 1, }, ], truncated: true, }; const result = await new EnhancedTools( testAnalysis({}, boundedGraph), ).execute("trace_native_ui_action", { action: "missing-selector" }); expect(result).toMatchObject({ ok: true, value: { truncated: true } }); if (!result.ok) return; expect(result.value).toHaveProperty("coverage"); expect(result.value).toMatchObject({ coverage: expect.arrayContaining([ expect.objectContaining({ facet: "objects:Main.nib", status: "partial", reason: "object_limit_reached", }), ]), }); }); it("returns cancellation instead of a successful trace during callee expansion", async () => { const result = await new EnhancedTools( testAnalysis({ procedure_callees: () => err(new AnalysisCancelledError("procedure_callees")), }), ).execute("trace_native_ui_action", { action: "buildTapped:" }); expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError", operation: "procedure_callees" }, }); }); it("reports only callees left unprocessed by the edge limit", async () => { const result = await new EnhancedTools( testAnalysis({ procedure_callees: (parameters) => execution( "procedure_callees", parameters.procedure === "0x1000" ? ["0x2000", "0x2001", "0x2002"] : [], ), }), ).execute("trace_native_ui_action", { action: "buildTapped:", max_depth: 4, max_edges: 5, }); expect(result).toMatchObject({ ok: true }); if (!result.ok) return; expect(result.value).toMatchObject({ coverage: expect.arrayContaining([ expect.objectContaining({ facet: "static_native_calls", status: "partial", omitted: 1, }), ]), }); }); }); describe("native UI trace boundaries", () => { it("retains unresolved dispatch evidence during callee expansion", async () => { const result = await new EnhancedTools( testAnalysis({ list_names: () => execution("list_names", []) }), ).execute("trace_native_ui_action", { action: "buildTapped:" }); expect(result).toMatchObject({ ok: true }); if (!result.ok) return; expect(result.value).toMatchObject({ unresolved: [ expect.objectContaining({ relation: "objc_dispatch", resolution: "unresolved", }), ], }); }); it("returns cancellation when archive decoding is cancelled", async () => { const result = await new EnhancedTools( testAnalysis({ decode_interface_builder: () => err(new AnalysisCancelledError("decode_interface_builder")), }), ).execute("trace_native_ui_action", { action: "buildTapped:" }); expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError", operation: "decode_interface_builder", }, }); }); it("matches handlers after the dispatch inventory's former default limit", async () => { const names = Array.from({ length: 5_000 }, (_, index) => ({ address: `0x${(index + 1).toString(16)}`, name: `unrelated_symbol_${index}`, })); names.push({ address: "0x2000", name: "-[BuildViewController buildTapped:]", }); const result = await new EnhancedTools( testAnalysis({ list_names: () => execution("list_names", names) }), ).execute("trace_native_ui_action", { action: "buildTapped:" }); expect(result).toMatchObject({ ok: true }); if (!result.ok) return; expect(result.value).toMatchObject({ nodes: expect.arrayContaining([ expect.objectContaining({ id: "native:function:0x2000" }), ]), }); }); it("counts edges removed with nodes in bounded graph coverage", async () => { const result = await new EnhancedTools(testAnalysis()).execute( "trace_native_ui_action", { action: "missing-selector", max_nodes: 1 }, ); expect(result).toMatchObject({ ok: true }); if (!result.ok) return; expect(result.value).toMatchObject({ coverage: expect.arrayContaining([ expect.objectContaining({ facet: "interface_builder_graph", status: "partial", omitted: 8, }), ]), }); }); });