diff --git a/src/application/native/EnhancedNativeInvestigation.test.ts b/src/application/native/EnhancedNativeInvestigation.test.ts index 0a28f681c5..78d338b349 100644 --- a/src/application/native/EnhancedNativeInvestigation.test.ts +++ b/src/application/native/EnhancedNativeInvestigation.test.ts @@ -257,6 +257,56 @@ describe("native UI action trace", () => { }), ); }); + it("carries source-owned reference limitations into trace output without changing coverage", async () => { + const limitation = "SEH handler targets may be outside the function body."; + const run = async (limitations: readonly string[]) => { + const result = await new EnhancedTools( + testAnalysis({ + procedure_address: () => execution("procedure_address", "0x1000"), + procedure_references: () => + ok( + createAnalysisExecution( + { + reference_kinds_available: false, + references: [], + unresolved_calls: [], + }, + { id: "ghidra", name: "Ghidra", version: "fixture" }, + { limitations }, + ), + ), + procedure_callees: (parameters) => + execution( + "procedure_callees", + parameters.procedure === "0x1000" ? ["0x2000"] : [], + ), + }), + ).execute("trace_native_ui_action", { action: "0x1000" }); + if (!result.ok) throw result.error; + return nativeInvestigationTraceSchema.parse(result.value); + }; + + const withSourceLimitation = await run([limitation]); + const withoutSourceLimitation = await run([]); + + expect(withSourceLimitation.limitations).toContain( + `Ghidra (ghidra fixture) procedure_references: ${limitation}`, + ); + expect( + withSourceLimitation.limitations.filter((entry) => + entry.endsWith(`procedure_references: ${limitation}`), + ), + ).toHaveLength(1); + expect(withSourceLimitation.coverage).toEqual( + withoutSourceLimitation.coverage, + ); + expect(withSourceLimitation.coverage).toContainEqual( + expect.objectContaining({ + facet: "static_native_calls", + status: "partial", + }), + ); + }); it("accepts an explicit native seed without an authored UI connection", async () => { const result = await new EnhancedTools( testAnalysis({ diff --git a/src/application/native/NativeCallRoutes.test.ts b/src/application/native/NativeCallRoutes.test.ts index 135807c3e6..fa8f12a151 100644 --- a/src/application/native/NativeCallRoutes.test.ts +++ b/src/application/native/NativeCallRoutes.test.ts @@ -34,6 +34,12 @@ describe("untyped native call route fallback", () => { operation === "procedure_references" ? referenceProvider : fallbackProvider, + { + limitations: + operation === "procedure_references" + ? ["Reference-kind metadata is incomplete."] + : ["Callee enumeration is limited to named functions."], + }, ), ); }, @@ -54,6 +60,18 @@ describe("untyped native call route fallback", () => { resolution: "inferred", })), unknowns: [{ ...unknown, provider: referenceProvider }], + limitations: [ + { + provider: referenceProvider, + operation: "procedure_references", + limitation: "Reference-kind metadata is incomplete.", + }, + { + provider: fallbackProvider, + operation: "procedure_callees", + limitation: "Callee enumeration is limited to named functions.", + }, + ], }, }); }, @@ -105,6 +123,7 @@ describe("typed native call routes", () => { ], }, provider, + { limitations: ["SEH handler targets may be outside the body."] }, ), ), }, @@ -126,6 +145,13 @@ describe("typed native call routes", () => { }, ], unknowns: [{ address: "0x1008" }], + limitations: [ + { + provider, + operation: "procedure_references", + limitation: "SEH handler targets may be outside the body.", + }, + ], }, }); }); @@ -146,12 +172,26 @@ describe("typed native call routes", () => { }); expect(calls).toBe(1); const malformed = await readNativeCallRoutes( - { execute: async () => ok(createAnalysisExecution({}, provider)) }, + { + execute: async () => + ok( + createAnalysisExecution({}, provider, { + limitations: ["Malformed output still has source caveats."], + }), + ), + }, "0x1000", ); expect(malformed).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" }, + limitations: [ + { + provider, + operation: "procedure_references", + limitation: "Malformed output still has source caveats.", + }, + ], }); }); it("rejects omitted unresolved-call observations instead of claiming none", async () => { diff --git a/src/application/native/NativeCallRoutes.ts b/src/application/native/NativeCallRoutes.ts index 9848ab86d8..affbfaf290 100644 --- a/src/application/native/NativeCallRoutes.ts +++ b/src/application/native/NativeCallRoutes.ts @@ -10,6 +10,19 @@ import { } from "../../domain/hopperValues.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +export interface NativeCallRouteLimitation { + readonly provider: ProviderIdentity; + readonly operation: "procedure_references" | "procedure_callees"; + readonly limitation: string; +} + +const limitationsFrom = ( + operation: NativeCallRouteLimitation["operation"], + provider: ProviderIdentity, + limitations: readonly string[], +): NativeCallRouteLimitation[] => + limitations.map((limitation) => ({ provider, operation, limitation })); + const referencesSchema = z.object({ reference_kinds_available: z.boolean(), references: z.array( @@ -24,6 +37,33 @@ const referencesSchema = z.object({ z.object({ address: z.string(), reason: z.string() }), ), }); + +const typedCallRoutes = ( + references: z.infer["references"], +) => { + const calls = references.filter(({ kind }) => kind.available && kind.call); + const countBySite = new Map>(); + for (const call of calls) { + const targets = countBySite.get(call.source_address) ?? new Set(); + targets.add(call.target_address); + countBySite.set(call.source_address, targets); + } + return calls.map((call) => ({ + address: call.target_procedure?.address ?? call.target_address, + site: call.source_address, + relation: + call.kind.available && (call.kind.computed || call.kind.indirect) + ? ("indirect_call" as const) + : ("direct_call" as const), + resolution: + (countBySite.get(call.source_address)?.size ?? 0) > 1 + ? ("ambiguous" as const) + : call.kind.available && (call.kind.computed || call.kind.indirect) + ? ("resolved" as const) + : ("observed" as const), + })); +}; + /** Prefer typed static call references, preserving computed targets and targetless sites. */ export const readNativeCallRoutes = async ( analysis: AnalysisOperationPort, @@ -45,7 +85,13 @@ export const readNativeCallRoutes = async ( reason: string; provider: ProviderIdentity; }> = []; + let limitations: NativeCallRouteLimitation[] = []; if (execution.ok) { + limitations = limitationsFrom( + "procedure_references", + execution.value.provider, + execution.value.limitations, + ); const parsed = referencesSchema.safeParse(execution.value.result); if (!parsed.success) return { @@ -55,6 +101,7 @@ export const readNativeCallRoutes = async ( "Invalid typed call references", ), unknowns, + limitations, }; // Missing detailed reference flags do not invalidate observed targetless // calls. Keep their producing provider even if another provider supplies @@ -68,48 +115,38 @@ export const readNativeCallRoutes = async ( parsed.data.reference_kinds_available === true || (references.length > 0 && references.every(({ kind }) => kind.available)) ) { - const calls = references.filter( - ({ kind }) => kind.available && kind.call, - ); - const countBySite = new Map>(); - for (const call of calls) { - const targets = - countBySite.get(call.source_address) ?? new Set(); - targets.add(call.target_address); - countBySite.set(call.source_address, targets); - } return { ok: true as const, value: { provider: execution.value.provider, - routes: calls.map((call) => ({ - address: call.target_procedure?.address ?? call.target_address, - site: call.source_address, - relation: - call.kind.available && (call.kind.computed || call.kind.indirect) - ? ("indirect_call" as const) - : ("direct_call" as const), - resolution: - (countBySite.get(call.source_address)?.size ?? 0) > 1 - ? ("ambiguous" as const) - : call.kind.available && - (call.kind.computed || call.kind.indirect) - ? ("resolved" as const) - : ("observed" as const), - })), + routes: typedCallRoutes(references), unknowns, + limitations, }, }; } } else if (execution.error._tag !== "AnalysisCapabilityUnavailableError") - return { ok: false as const, error: execution.error, unknowns }; + return { + ok: false as const, + error: execution.error, + unknowns, + limitations, + }; const callees = await analysis.execute( "procedure_callees", { procedure }, options, ); if (!callees.ok) - return { ok: false as const, error: callees.error, unknowns }; + return { ok: false as const, error: callees.error, unknowns, limitations }; + limitations = [ + ...limitations, + ...limitationsFrom( + "procedure_callees", + callees.value.provider, + callees.value.limitations, + ), + ]; const parsed = parseRelatedAddresses(callees.value.result, "callees"); return parsed.ok ? { @@ -123,7 +160,8 @@ export const readNativeCallRoutes = async ( resolution: "inferred" as const, })), unknowns, + limitations, }, } - : { ok: false as const, error: parsed.error, unknowns }; + : { ok: false as const, error: parsed.error, unknowns, limitations }; }; diff --git a/src/application/native/NativeUiActionTrace.ts b/src/application/native/NativeUiActionTrace.ts index cac49875d0..06d1fe4d39 100644 --- a/src/application/native/NativeUiActionTrace.ts +++ b/src/application/native/NativeUiActionTrace.ts @@ -13,7 +13,10 @@ import { import { nativeDispatchMetadataResultSchema } from "../../domain/native/objcSwiftMetadata.js"; import { inspectNativeDispatch } from "./NativeDispatchMetadataInspection.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; -import { readNativeCallRoutes } from "./NativeCallRoutes.js"; +import { + readNativeCallRoutes, + type NativeCallRouteLimitation, +} from "./NativeCallRoutes.js"; import { err, ok } from "../../domain/result.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; @@ -244,6 +247,10 @@ export const traceNativeUiAction = async ( : graph.truncated && trace.reason === null ? "native_trace_output_bound_reached" : trace.reason, + limitations: [ + ...trace.limitations, + ...renderSourceLimitations(expanded.limitations), + ], }), ), ); @@ -398,6 +405,7 @@ const addDirectCallees = async (input: { let examined = 0; let omitted = 0; let truncated = false; + const sourceLimitations: NativeCallRouteLimitation[] = []; while (queueIndex < queue.length) { const current = queue[queueIndex++]; if (current === undefined || visited.has(current.address)) continue; @@ -419,6 +427,9 @@ const addDirectCallees = async (input: { "provider", ); examined += 1; + sourceLimitations.push( + ...(execution.ok ? execution.value.limitations : execution.limitations), + ); if (!execution.ok && execution.error._tag === "AnalysisCancelledError") return { error: execution.error }; const unknowns = execution.ok @@ -539,9 +550,33 @@ const addDirectCallees = async (input: { examined, omitted, truncated, + limitations: sourceLimitations, }; }; +const renderSourceLimitations = ( + limitations: readonly NativeCallRouteLimitation[], +): string[] => { + const seen = new Set(); + const rendered: string[] = []; + for (const { provider, operation, limitation } of limitations) { + const provenance = JSON.stringify([ + provider.id, + provider.name, + provider.version, + operation, + limitation, + ]); + if (seen.has(provenance)) continue; + seen.add(provenance); + const version = provider.version === null ? "" : ` ${provider.version}`; + rendered.push( + `${provider.name} (${provider.id}${version}) ${operation}: ${limitation}`, + ); + } + return rendered; +}; + const graphNodeDepths = ( start: string, nodes: readonly z.infer<