diff --git a/docs/native-investigation.md b/docs/native-investigation.md index f963fd4d8..be1380ef0 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -227,6 +227,10 @@ nodes and edges. Node payloads are bounded to 8 MiB and serialized results to 32 MiB. Pagination returns edges whose source is on the node page and preserves stable IDs for endpoints outside that page. +Within one value trace, each exact procedure selector is attempted at most once, +including failed analyses. Failures remain unknown observations and do not +establish canonical function identity. A new trace can retry those selectors. + The raw Java `is_dead` flag can remain set after Ghidra decodes a live block; `block_membership` reports actual syntax-tree membership separately. @@ -319,7 +323,16 @@ complete observation. digest are preserved; key interpretation follows the pinned plist decoder. - `decode_interface_builder` / `rea decode-interface-builder ` reads compiled storyboard and nib resources into objects, outlets, actions, - connections and controller/class names. `verify:interface-builder` compiles + connections and controller/class names. A full app graph is often several + megabytes (XLD.app: 6.7 MB, about 2M tokens, which also exceeds the default + MCP response budget), so MCP callers should pass `detail: "summary"`. The + 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}`. + `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 a source-owned AppKit XIB with `ibtool`. Storyboards require an installed iOS platform; unsupported archive forms remain explicit. XML plist archives accept UTF-8 and BOM-marked UTF-16 in either byte order. diff --git a/src/application/native/EnhancedNativeInvestigation.test.ts b/src/application/native/EnhancedNativeInvestigation.test.ts index 1e1b517b6..0a28f681c 100644 --- a/src/application/native/EnhancedNativeInvestigation.test.ts +++ b/src/application/native/EnhancedNativeInvestigation.test.ts @@ -12,6 +12,7 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, + AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import { EnhancedTools } from "../EnhancedTools.js"; import { @@ -615,6 +616,25 @@ describe("native UI query outcomes", () => { }); }); + it("returns native seed resolution failures instead of reporting not found", async () => { + const result = await new EnhancedTools( + testAnalysis({ + procedure_address: () => + err( + new AnalysisOutputError( + "procedure_address", + "invalid address response", + ), + ), + }), + ).execute("trace_native_ui_action", { action: "missing-selector" }); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError", operation: "procedure_address" }, + }); + }); + it("returns cancellation instead of a successful trace during callee expansion", async () => { const result = await new EnhancedTools( testAnalysis({ diff --git a/src/application/native/NativeUiActionTrace.ts b/src/application/native/NativeUiActionTrace.ts index 8a6affdf7..cac49875d 100644 --- a/src/application/native/NativeUiActionTrace.ts +++ b/src/application/native/NativeUiActionTrace.ts @@ -125,6 +125,11 @@ export const traceNativeUiAction = async ( ); if (!native.ok && native.error._tag === "AnalysisCancelledError") return err(native.error); + if ( + !native.ok && + native.error._tag !== "AnalysisCapabilityUnavailableError" + ) + return err(native.error); if (native.ok) { const address = z.string().safeParse(native.value.result); if (!address.success || native.value.subject?.sha256 !== targetSha256) diff --git a/src/application/native/NativeValueTrace.test.ts b/src/application/native/NativeValueTrace.test.ts index cdc663d54..56ed3761d 100644 --- a/src/application/native/NativeValueTrace.test.ts +++ b/src/application/native/NativeValueTrace.test.ts @@ -8,7 +8,10 @@ import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js"; import { functionDossierSchema } from "../../domain/hopperValues.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; import { nativeValueTraceSchema } from "../../domain/native/nativeValueTrace.js"; -import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; import { ok, err } from "../../domain/result.js"; const subject = { @@ -181,52 +184,71 @@ describe("native value parameter-use node membership", () => { }); }); - it("preserves operation IDs that collide with generated parameter IDs", async () => { - const base = functionDossierSchema.parse(ghidraFunctionDossier()); - const address = base.procedure.address; - const execution = createAnalysisExecution( - { - ...base, - native_value_flow: { - available: true, - provenance: "ghidra-high-pcode", - operations: [ - node("parameter:0", address, "COPY"), - node("use", address, "COPY"), - ], - def_use: [], - effects: [], - parameters: [{ ordinal: 0, name: "value", data_type: "/int" }], - parameter_uses: [{ ordinal: 0, use: "use", input_index: 1 }], - truncated: false, - omitted_operations_lower_bound: 0, - known_omitted_inputs: 0, - known_omitted_edges: 0, - limitations: [], - }, - }, - provider, - { subject }, - ); - const result = await traceNativeValues( - { execute: async () => ok(execution) }, - { procedure: address, max_nodes: 2 }, - ); - - if (!result.ok) throw result.error; - const parsedResult = nativeValueTraceSchema.parse(result.value); - expect(parsedResult).toMatchObject({ - total_nodes: 2, - total_edges: 1, - edges: [ + it.each([3, 4])( + "keeps parameter identity separate from colliding operation IDs with max_nodes=%i", + async (max_nodes) => { + const base = functionDossierSchema.parse(ghidraFunctionDossier()); + const address = base.procedure.address; + const execution = createAnalysisExecution( { - source: `${address}/parameter:0`, - target: `${address}/use`, - kind: "parameter-use", + ...base, + native_value_flow: { + available: true, + provenance: "ghidra-high-pcode", + operations: [ + node("parameter:0", address, "COPY"), + node("parameter:0#1", address, "COPY"), + node("use", address, "COPY"), + ], + def_use: [], + effects: [], + parameters: [{ ordinal: 0, name: "value", data_type: "/int" }], + parameter_uses: [{ ordinal: 0, use: "use", input_index: 1 }], + truncated: false, + omitted_operations_lower_bound: 0, + known_omitted_inputs: 0, + known_omitted_edges: 0, + limitations: [], + }, }, - ], - }); - }); + provider, + { subject }, + ); + const result = await traceNativeValues( + { execute: async () => ok(execution) }, + { procedure: address, max_nodes }, + ); + + if (!result.ok) throw result.error; + const parsedResult = nativeValueTraceSchema.parse(result.value); + expect(parsedResult).toMatchObject({ + total_nodes: max_nodes, + total_edges: max_nodes === 3 ? 0 : 1, + edges: + max_nodes === 3 + ? [] + : [ + { + source: `${address}/parameter:0#2`, + target: `${address}/use`, + kind: "parameter-use", + }, + ], + }); + expect( + parsedResult.nodes + .filter(({ kind }) => kind === "operation") + .map(({ id }) => id), + ).toEqual([ + `${address}/parameter:0`, + `${address}/parameter:0#1`, + `${address}/use`, + ]); + expect(new Set(parsedResult.nodes.map(({ id }) => id)).size).toBe( + max_nodes, + ); + }, + ); }); const nativeFlowDossier = ( @@ -316,6 +338,97 @@ const callBindingAnalysis = ( }, }); +describe("native value failed-callee traversal", () => { + it("attempts a repeated failed selector once and still analyzes a later callee", async () => { + const base = functionDossierSchema.parse(ghidraFunctionDossier()); + const caller = nativeFlowDossier(base, { + address: "0x1000", + name: "caller", + operations: [ + node("bad-1", "0x1100", "CALL"), + node("bad-2", "0x1104", "CALL"), + node("good", "0x1108", "CALL"), + ], + parameters: [], + }); + const good = nativeFlowDossier(base, { + address: "0x3000", + name: "good", + operations: [], + parameters: [], + }); + const attempts: string[] = []; + const port: AnalysisOperationPort = { + execute: async (operation, parameters) => { + if (operation === "resolve_native_call_targets") { + const destination = + parameters.address === "0x1108" ? "0x3000" : "0x2000"; + return ok( + createAnalysisExecution( + { + call_site: String(parameters.address), + procedure: "0x1000", + status: "direct", + mechanism: "direct", + targets: [ + { + address: destination, + procedure: destination, + status: "direct", + basis: "provider-reference", + references: [], + }, + ], + limitations: [], + }, + provider, + { subject }, + ), + ); + } + expect(operation).toBe("analyze_function"); + const selector = String(parameters.procedure); + attempts.push(selector); + if (selector === "0x2000") + return err( + new AnalysisOutputError("analyze_function", "Callee unavailable"), + ); + return ok( + createAnalysisExecution( + selector === "0x1000" ? caller : good, + provider, + { subject }, + ), + ); + }, + }; + // A new invocation retries the failed selector: failures are not cached + // across traces, and successful procedure identity remains separate. + for (let run = 0; run < 2; run++) { + attempts.length = 0; + const result = await traceNativeValues(port, { + procedure: "0x1000", + max_functions: 3, + }); + if (!result.ok) throw result.error; + const trace = nativeValueTraceSchema.parse(result.value); + expect(attempts).toEqual(["0x1000", "0x2000", "0x3000"]); + expect(trace.decompilations).toBe(3); + expect(trace.procedures.map(({ address }) => address)).toEqual([ + "0x1000", + "0x3000", + ]); + expect(trace.unknowns).toContainEqual({ + procedure: "0x2000", + address: null, + reason: expect.stringContaining("Callee unavailable"), + }); + expect(trace.edges.filter(({ kind }) => kind === "call")).toHaveLength(3); + expect(trace.truncated).toBe(false); + } + }); +}); + describe("native value call-binding fan-out", () => { it("preserves binding order and edge-budget behavior for repeated callees", async () => { const callCount = 128; @@ -558,3 +671,156 @@ describe("native value trace selector origin", () => { ]); }); }); + +describe("native value node byte budgets", () => { + it("stops admitting parameters after an operation exceeds the byte budget", async () => { + const base = functionDossierSchema.parse(ghidraFunctionDossier()); + const value = nativeFlowDossier(base, { + address: "0x1000", + name: "caller", + operations: [node("use", "0x1000", "a".repeat(9 * 1024 * 1024))], + parameters: [{ ordinal: 0, name: "value", data_type: "/int" }], + }); + const result = await traceNativeValues( + { + execute: async () => + ok(createAnalysisExecution(value, provider, { subject })), + }, + { procedure: "0x1000" }, + ); + if (!result.ok) throw result.error; + expect(nativeValueTraceSchema.parse(result.value)).toMatchObject({ + truncated: true, + total_nodes: 0, + total_edges: 0, + nodes: [], + edges: [], + }); + }); + + it.each([ + { label: "plain text", name: "a".repeat(9 * 1024 * 1024) }, + { label: "JSON escapes", name: "\0".repeat(2 * 1024 * 1024) }, + ])("rejects an oversized parameter encoded with $label", async ({ name }) => { + const base = functionDossierSchema.parse(ghidraFunctionDossier()); + const value = nativeFlowDossier(base, { + address: "0x1000", + name: "caller", + operations: [ + node("parameter:0", "0x1000", "COPY"), + node("use", "0x1000", "MULTIEQUAL"), + ], + parameters: [{ ordinal: 0, name, data_type: "/int" }], + }); + const parsed = functionDossierSchema.parse(value); + if (!parsed.native_value_flow?.available) + throw new Error("Invalid fixture"); + parsed.native_value_flow.parameter_uses = [ + { ordinal: 0, use: "use", input_index: 0 }, + ]; + const result = await traceNativeValues( + { + execute: async () => + ok(createAnalysisExecution(parsed, provider, { subject })), + }, + { procedure: "0x1000" }, + ); + if (!result.ok) throw result.error; + const trace = nativeValueTraceSchema.parse(result.value); + expect(trace).toMatchObject({ + truncated: true, + total_nodes: 2, + total_edges: 0, + nodes: [ + expect.objectContaining({ kind: "operation" }), + expect.objectContaining({ kind: "operation" }), + ], + }); + }); + + it("shares the byte budget across operation and parameter nodes", async () => { + const base = functionDossierSchema.parse(ghidraFunctionDossier()); + const value = nativeFlowDossier(base, { + address: "0x1000", + name: "caller", + operations: [node("use", "0x1000", "MULTIEQUAL")], + parameters: [0, 1].map((ordinal) => ({ + ordinal, + name: "a".repeat(5 * 1024 * 1024), + data_type: "/int", + })), + }); + const parsed = functionDossierSchema.parse(value); + if (!parsed.native_value_flow?.available) + throw new Error("Invalid fixture"); + parsed.native_value_flow.parameter_uses = [0, 1].map((ordinal) => ({ + ordinal, + use: "use", + input_index: ordinal, + })); + const result = await traceNativeValues( + { + execute: async () => + ok(createAnalysisExecution(parsed, provider, { subject })), + }, + { procedure: "0x1000" }, + ); + if (!result.ok) throw result.error; + const trace = nativeValueTraceSchema.parse(result.value); + expect(trace).toMatchObject({ + truncated: true, + total_nodes: 2, + total_edges: 1, + }); + expect(trace.nodes.map(({ id }) => id)).toEqual([ + "0x1000/use", + "0x1000/parameter:0", + ]); + expect(trace.edges[0]?.source).toBe("0x1000/parameter:0"); + const retainedBytes = trace.nodes.reduce( + (total, value) => total + Buffer.byteLength(JSON.stringify(value)), + 0, + ); + expect(retainedBytes).toBeLessThanOrEqual(8 * 1024 * 1024); + }); +}); + +describe("native value byte-bound call bindings", () => { + it("omits argument bindings to rejected callee parameter nodes", async () => { + const base = functionDossierSchema.parse(ghidraFunctionDossier()); + const caller = nativeFlowDossier(base, { + address: "0x1000", + name: "caller", + operations: [node("call", "0x1000", "CALL")], + parameters: [], + }); + const callee = nativeFlowDossier(base, { + address: "0x2000", + name: "callee", + operations: [node("return", "0x2000", "RETURN")], + parameters: [ + { ordinal: 0, name: "a".repeat(9 * 1024 * 1024), data_type: "/int" }, + ], + }); + const result = await traceNativeValues( + callBindingAnalysis("0x1000", "0x2000", caller, callee), + { procedure: "0x1000" }, + ); + if (!result.ok) throw result.error; + const trace = nativeValueTraceSchema.parse(result.value); + expect(trace.truncated).toBe(true); + expect(trace.nodes.map(({ kind }) => kind)).toEqual([ + "operation", + "operation", + ]); + expect(trace.edges.map(({ kind }) => kind)).toEqual([ + "call", + "return-binding", + ]); + expect(trace.unknowns).toContainEqual( + expect.objectContaining({ + reason: "Parameter bindings for 0x2000 are unavailable or omitted", + }), + ); + }); +}); diff --git a/src/application/native/NativeValueTrace.ts b/src/application/native/NativeValueTrace.ts index 71c602396..fc304201d 100644 --- a/src/application/native/NativeValueTrace.ts +++ b/src/application/native/NativeValueTrace.ts @@ -12,6 +12,9 @@ import { import { err, ok } from "../../domain/result.js"; import type { AnalysisOperationPort } from "../AnalysisProvider.js"; import type { EnhancedResult } from "../EnhancedToolTypes.js"; +import { jsonParts } from "../../domain/jsonSerialization.js"; + +const NODE_PAYLOAD_BYTES = 8 * 1024 * 1024; /** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */ export const traceNativeValues = async ( @@ -21,7 +24,6 @@ export const traceNativeValues = async ( ): EnhancedResult => { const input = nativeValueTraceInputSchema.parse(parameters); const nodes: typeof nativeValueTraceSchema._output.nodes = []; - const nodeIds = new Set(); const edges: typeof nativeValueTraceSchema._output.edges = []; const procedures: typeof nativeValueTraceSchema._output.procedures = []; const unknowns: typeof nativeValueTraceSchema._output.unknowns = []; @@ -34,17 +36,46 @@ export const traceNativeValues = async ( evidence_id: string; }[] = []; const pending = [{ procedure: input.procedure, depth: 0 }]; + const attempted = new Set(); const visited = new Set(); let targetSha256: string | undefined; let truncated = false; let decompilations = 0; let callSites = 0; let bytes = 0; + let nodeBudgetReached = false; + const retainNode = (value: (typeof nodes)[number]): boolean => { + if (nodeBudgetReached) return false; + if (nodes.length >= input.max_nodes) { + truncated = true; + nodeBudgetReached = true; + return false; + } + let size = 0; + // Reuse bounded JSON encoding to count escapes and UTF-8 expansion + // before allocating a complete serialized node that might not fit. + for (const part of jsonParts(value)) { + size += Buffer.byteLength(part); + if (size > NODE_PAYLOAD_BYTES - bytes) { + truncated = true; + nodeBudgetReached = true; + return false; + } + } + bytes += size; + nodes.push(value); + return true; + }; while (pending.length > 0) { if (signal?.aborted) return err(new AnalysisCancelledError("trace_native_values")); const next = pending.shift(); - if (next === undefined || visited.has(next.procedure)) continue; + if ( + next === undefined || + attempted.has(next.procedure) || + visited.has(next.procedure) + ) + continue; if (decompilations >= input.max_functions) { truncated = true; unknowns.push({ @@ -54,6 +85,9 @@ export const traceNativeValues = async ( }); break; } + // An exact selector is attempted once per trace, even on failure. Only + // successful dossiers establish canonical procedure identity in visited. + attempted.add(next.procedure); const execution = await analysis.execute( "analyze_function", { procedure: next.procedure }, @@ -135,6 +169,10 @@ export const traceNativeValues = async ( }); } const ids = new Set(); + // Keep generated parameter identity independent of which operations fit + // the budget, while preserving every provider-supplied operation ID. + const operationIds = new Set(flow.operations.map(({ id }) => id)); + const parameterIds = new Map(); const qualified = (id: string) => `${address}/${id}`; for (const operation of flow.operations) { if ( @@ -150,44 +188,37 @@ export const traceNativeValues = async ( kind: "operation" as const, parameter: null, }; - const size = Buffer.byteLength(JSON.stringify(node)); - if (nodes.length >= input.max_nodes || bytes + size > 8 * 1024 * 1024) { - truncated = true; - break; - } - bytes += size; + if (!retainNode(node)) break; ids.add(operation.id); - nodes.push(node); - nodeIds.add(node.id); } for (const parameter of flow.parameters) { - if (nodes.length >= input.max_nodes) { - truncated = true; + const parameterId = `parameter:${parameter.ordinal}`; + let localId = parameterId; + for (let suffix = 1; operationIds.has(localId); suffix++) + localId = `${parameterId}#${suffix}`; + const id = qualified(localId); + if ( + !retainNode({ + id, + procedure: address, + evidence_id: evidenceId, + kind: "parameter", + operation: null, + parameter, + }) + ) break; - } - const id = `${address}/parameter:${parameter.ordinal}`; - nodes.push({ - id, - procedure: address, - evidence_id: evidenceId, - kind: "parameter", - operation: null, - parameter, - }); - nodeIds.add(id); + parameterIds.set(parameter.ordinal, id); } for (const use of flow.parameter_uses) { - if ( - !ids.has(use.use) || - !nodeIds.has(`${address}/parameter:${use.ordinal}`) - ) - continue; + const source = parameterIds.get(use.ordinal); + if (!ids.has(use.use) || source === undefined) continue; if (edges.length >= input.max_edges) { truncated = true; break; } edges.push({ - source: `${address}/parameter:${use.ordinal}`, + source, target: qualified(use.use), input_index: use.input_index, kind: "parameter-use", @@ -295,9 +326,10 @@ export const traceNativeValues = async ( } if ( truncated && - (nodes.length >= input.max_nodes || + (nodeBudgetReached || + nodes.length >= input.max_nodes || edges.length >= input.max_edges || - bytes >= 8 * 1024 * 1024) + bytes >= NODE_PAYLOAD_BYTES) ) break; } diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 7d023752d..189757295 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -9,14 +9,14 @@ import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; const RETAINED_EXAMPLE_ID = `ev_${"a".repeat(64)}`; -/** Selected views of completed layout, JavaScript, native, inventory, WASM, Mach-O and web network Evidence. */ +/** Selected views of completed layout, JavaScript, native, inventory, WASM, Mach-O, Interface Builder and web network Evidence. */ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ { name: "inspect_analysis_view", ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, inspect_evm_interface, inspect_managed_artifact, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index 287b41214..3d63488a4 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -89,11 +89,17 @@ export const ARTIFACT_TOOL_CONTRACTS = [ ), artifact( "decode_interface_builder", - "Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns.", + "Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns. A full app graph is often several megabytes; detail summary retains it and returns documents with node, edge and coverage counts, then page rows, optionally one document's, with inspect_analysis_view kind interface-builder.", z.strictObject({ max_documents: z.number().int().min(1).max(64).default(64), max_objects: z.number().int().min(1).max(20_000).default(20_000), max_connections: z.number().int().min(1).max(40_000).default(40_000), + detail: analysisDetailSchema + .unwrap() + .optional() + .describe( + "Omitted or complete returns the complete decoded graph Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for interface-builder pages.", + ), }), ), artifact( diff --git a/src/contracts/investigationExamples.ts b/src/contracts/investigationExamples.ts index 8218ee609..96258225c 100644 --- a/src/contracts/investigationExamples.ts +++ b/src/contracts/investigationExamples.ts @@ -28,6 +28,7 @@ export const FUNCTION_COMPARISON_EVIDENCE = createEvidence( result: jsonValueSchema.parse(comparison), confidence: "derived", authority: "analyst-inference", + limitations: comparison.limitations, evidenceLinks: [ FUNCTION_COMPARISON_EXAMPLE.left.evidence_id, FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, diff --git a/src/contracts/native/nativeToolContracts.ts b/src/contracts/native/nativeToolContracts.ts index d45b62cc6..05838bfb3 100644 --- a/src/contracts/native/nativeToolContracts.ts +++ b/src/contracts/native/nativeToolContracts.ts @@ -66,15 +66,13 @@ const native = ( }; /** Ordered single-line symbols; `swift-demangle` ends each result with LF. */ -export const swiftSymbolsSchema = z - .array( - z - .string() - .min(1) - .regex(/^[^\n]*$/u, "Each Swift symbol must be one line.") - .regex(/^[^\x00]*$/u, "Swift symbols cannot contain NUL."), - ) - .min(1); +export const swiftSymbolsSchema = z.array( + z + .string() + .min(1) + .regex(/^[^\n]*$/u, "Each Swift symbol must be one line.") + .regex(/^[^\x00]*$/u, "Swift symbols cannot contain NUL."), +); /** Provider-neutral semantic operations backed initially by macOS utilities. */ export const NATIVE_TOOL_CONTRACTS = [ diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 1a36c0896..bbadef367 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; import { artifactInventorySummaryViewResultSchema, + interfaceBuilderSummaryViewResultSchema, machoSummaryViewResultSchema, managedArtifactSummaryViewResultSchema, } from "../domain/analysisView/analysisView.js"; @@ -360,17 +361,19 @@ export const enhancedOutputSchemas: Readonly> = { z.object({ document: z.string(), image_base: z.string().min(1).optional(), - segments: z.array( - z.object({ - name: z.string(), - start: z.string(), - end: z.string(), - length: z.number().min(0), - image_base: z.string().exactOptional(), - address_space: z.string().exactOptional(), - }), - ), - segment_count: z.number().int().min(0), + segments: z + .array( + z.object({ + name: z.string(), + start: z.string(), + end: z.string(), + length: z.number().min(0), + image_base: z.string().exactOptional(), + address_space: z.string().exactOptional(), + }), + ) + .optional(), + segment_count: z.number().int().min(0).optional(), procedure_count: z.number().int().min(0), string_count: z.number().int().min(0), }), @@ -404,7 +407,12 @@ export const artifactOutputSchemas: Readonly> = { ]), ), extract_artifact: evidenceResultOf(artifactExtractionResultSchema), - decode_interface_builder: evidenceResultOf(interfaceBuilderAnalysisSchema), + decode_interface_builder: evidenceResultOf( + z.union([ + interfaceBuilderAnalysisSchema, + interfaceBuilderSummaryViewResultSchema, + ]), + ), inspect_keyed_archive: evidenceResultOf(keyedArchiveResultSchema), inspect_asset_catalog: evidenceResultOf(appleAssetCatalogResultSchema), trace_dylib_resolution: evidenceResultOf(dylibResolutionResultSchema), diff --git a/src/domain/analysisView/androidPackageView.ts b/src/domain/analysisView/androidPackageView.ts index caf948c10..519153db1 100644 --- a/src/domain/analysisView/androidPackageView.ts +++ b/src/domain/analysisView/androidPackageView.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { androidResultSchemas } from "../android/androidAnalysis.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { analysisViewInputError, type AnalysisViewParent, @@ -53,19 +53,8 @@ export const projectAndroidPackageView = ( }, ]), ); - const parsed = packageSchema.safeParse(parent.normalizedResult); - if (!parsed.success) - return err( - analysisInputErrorFromIssues( - "inspect_analysis_view", - parsed.error.issues.map((issue) => ({ - ...issue, - path: ["source", "normalized_result", ...issue.path], - })), - { source: { normalized_result: parent.normalizedResult } }, - { cause: parsed.error }, - ), - ); + const parsed = parseAnalysisViewResult(packageSchema, parent); + if (!parsed.ok) return parsed; // Engine artifact_sha256 identifies the JAR, never the APK. A missing // authenticated APK subject cannot be repaired using the engine identity. if (parent.subject == null) @@ -79,7 +68,7 @@ export const projectAndroidPackageView = ( }, ]), ); - const report = parsed.data; + const report = parsed.value; const shared = { parent_evidence_id: parent.evidenceId, parent_operation: "inspect_android_package" as const, diff --git a/src/domain/analysisView/dartAotView.ts b/src/domain/analysisView/dartAotView.ts index 4d66aa190..76242973c 100644 --- a/src/domain/analysisView/dartAotView.ts +++ b/src/domain/analysisView/dartAotView.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { flutterResultSchemas } from "../flutter/flutterBuildAnalysis.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { analysisViewInputError, type AnalysisViewParent, @@ -59,20 +59,9 @@ export const projectDartAotView = ( }, ]), ); - const parsed = aotSchema.safeParse(parent.normalizedResult); - if (!parsed.success) - return err( - analysisInputErrorFromIssues( - "inspect_analysis_view", - parsed.error.issues.map((issue) => ({ - ...issue, - path: ["source", "normalized_result", ...issue.path], - })), - { source: { normalized_result: parent.normalizedResult } }, - { cause: parsed.error }, - ), - ); - const report = parsed.data; + const parsed = parseAnalysisViewResult(aotSchema, parent); + if (!parsed.ok) return parsed; + const report = parsed.value; const { package_uris, dart_uris, class_like_tokens, ...pool } = report.string_pool; const string_pool = { diff --git a/src/domain/analysisView/evmInterfaceView.ts b/src/domain/analysisView/evmInterfaceView.ts index d9f3994d4..f931f21c7 100644 --- a/src/domain/analysisView/evmInterfaceView.ts +++ b/src/domain/analysisView/evmInterfaceView.ts @@ -2,8 +2,8 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { uniqueSorted } from "../canonicalOrdering.js"; import { evmInterfaceSchema } from "../evm/evmInterface.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { analysisViewInputError, type AnalysisViewParent, @@ -60,20 +60,9 @@ export const projectEvmInterfaceView = ( }, ]), ); - const parsed = evmInterfaceSchema.safeParse(parent.normalizedResult); - if (!parsed.success) - return err( - analysisInputErrorFromIssues( - "inspect_analysis_view", - parsed.error.issues.map((issue) => ({ - ...issue, - path: ["source", "normalized_result", ...issue.path], - })), - { source: { normalized_result: parent.normalizedResult } }, - { cause: parsed.error }, - ), - ); - const report = parsed.data; + const parsed = parseAnalysisViewResult(evmInterfaceSchema, parent); + if (!parsed.ok) return parsed; + const report = parsed.value; if ( parent.subject == null || parent.subject.format !== "file" || diff --git a/src/domain/analysisView/interfaceBuilderView.test.ts b/src/domain/analysisView/interfaceBuilderView.test.ts new file mode 100644 index 000000000..a591d3194 --- /dev/null +++ b/src/domain/analysisView/interfaceBuilderView.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; + +import { + projectAnalysisView, + type AnalysisViewParent, +} from "./analysisView.js"; + +const target = "a".repeat(64); +const evidence = (artifact_path: string) => [ + { + kind: "interface_builder_resource" as const, + description: "compiled document", + location: { address: null, file_offset: null }, + artifact_path, + artifact_sha256: "b".repeat(64), + }, +]; +const node = (id: string, kind: string, document: string) => ({ + id, + kind, + name: id, + location: null, + attributes: {}, + evidence: evidence(document), +}); +const main = "Contents/Resources/Main.nib"; +const second = "Contents/Resources/Second.nib"; +const document = (relative_path: string) => ({ + relative_path, + archive_sha256: "b".repeat(64), + document_kind: "nib" as const, + object_count: 2, + connection_count: 1, + hierarchy_complete: true, +}); +const report = (truncated = false) => ({ + target_sha256: target, + documents: [document(main), document(second)], + graph: { + target_sha256: target, + provider: { id: "rea-ib", version: "1", tool_version: "1" }, + nodes: [ + node("main:root", "scene", main), + node("main:button", "control", main), + node("second:root", "scene", second), + ], + edges: [ + { + id: "main:edge", + from: "main:root", + to: "main:button", + relation: "contains", + resolution: "observed", + evidence: evidence(main), + limitations: [], + }, + ], + coverage: [ + { + facet: `objects:${main}`, + status: "complete", + reason: null, + examined: 2, + omitted: 0, + }, + ], + truncated, + }, + limitations: ["Recognized archive fields only."], +}); +const parent = ( + normalizedResult: unknown = report(), + sha256 = target, +): AnalysisViewParent => ({ + evidenceId: `ev_${"c".repeat(64)}`, + operation: "decode_interface_builder", + normalizedResult, + subject: { + name: "Fixture", + digest: { sha256 }, + format: "mach-o", + architecture: "arm64", + local_path: "/Applications/Fixture.app/Contents/MacOS/Fixture", + }, + limitations: [], +}); + +describe("Interface Builder analysis views", () => { + it("summarizes documents and graph counts without rows", () => { + const projected = projectAnalysisView(parent(), { kind: "summary" }); + if (!projected.ok) throw projected.error; + expect(projected.value).toMatchObject({ + kind: "summary", + parent_operation: "decode_interface_builder", + artifact: { sha256: target }, + summary: { + documents: report().documents, + available_counts: { nodes: 3, edges: 1, coverage: 1 }, + node_kinds: { control: 1, scene: 2 }, + edge_relations: { contains: 1 }, + coverage_statuses: { complete: 1 }, + truncated: false, + }, + coverage: { examined: 7, total: 7 }, + }); + expect(JSON.stringify(projected.value)).not.toContain("main:button"); + }); + + it("pages one document's nodes and keeps decoder truncation visible", () => { + const projected = projectAnalysisView(parent(), { + kind: "interface-builder", + collection: "nodes", + document: main, + offset: 1, + limit: 5, + }); + if (!projected.ok) throw projected.error; + expect(projected.value).toMatchObject({ + kind: "interface-builder", + items: [node("main:button", "control", main)], + source_truncated: false, + coverage: { status: "page", examined: 1, total: 2, exhausted: true }, + }); + const truncated = projectAnalysisView(parent(report(true)), { + kind: "interface-builder", + collection: "coverage", + offset: 0, + limit: 64, + }); + if (!truncated.ok) throw truncated.error; + expect(truncated.value).toMatchObject({ + items: report().graph.coverage, + source_truncated: true, + unknowns: expect.arrayContaining([ + expect.stringContaining("The decoded graph is incomplete"), + ]), + }); + }); + + it("refuses unknown documents, other views and mismatched targets", () => { + const unknown = projectAnalysisView(parent(), { + kind: "interface-builder", + collection: "edges", + document: "Contents/Resources/Missing.nib", + offset: 0, + limit: 64, + }); + if (unknown.ok) throw new Error("expected an unknown-document refusal"); + expect(unknown.error).toMatchObject({ + issues: [{ path: ["view", "document"] }], + }); + const wrongKind = projectAnalysisView(parent(), { + kind: "macho", + facet: "imports", + offset: 0, + limit: 64, + }); + if (wrongKind.ok) throw new Error("expected a view-kind refusal"); + expect(wrongKind.error).toMatchObject({ issues: [{ path: ["view"] }] }); + const mismatched = projectAnalysisView(parent(report(), "d".repeat(64)), { + kind: "summary", + }); + if (mismatched.ok) throw new Error("expected a subject refusal"); + expect(mismatched.error).toMatchObject({ + issues: [{ path: ["source", "subject"] }], + }); + }); +}); diff --git a/src/domain/analysisView/interfaceBuilderView.ts b/src/domain/analysisView/interfaceBuilderView.ts new file mode 100644 index 000000000..8710795a1 --- /dev/null +++ b/src/domain/analysisView/interfaceBuilderView.ts @@ -0,0 +1,248 @@ +import { z } from "zod"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { uniqueSorted } from "../canonicalOrdering.js"; +import { + interfaceBuilderAnalysisSchema, + interfaceBuilderDocumentSchema, +} from "../apple/interfaceBuilderGraph.js"; +import { + nativeInvestigationCoverageSchema, + nativeInvestigationEdgeSchema, + nativeInvestigationNodeSchema, +} from "../native/nativeInvestigationGraph.js"; +import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { + analysisViewInputError, + type AnalysisViewParent, + type AnalysisViewRequest, + type UnsignedAnalysisView, +} from "./analysisView.js"; +import { + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; + +const page = { + kind: z.literal("interface-builder"), + offset: z.number().int().nonnegative().default(0), + limit: z.number().int().positive().default(64), +} as const; + +/** Original decoded rows; graph rows can be narrowed to one decoded document. */ +export const interfaceBuilderViewRequestSchema = z.discriminatedUnion( + "collection", + [ + z.strictObject({ + ...page, + collection: z.enum(["nodes", "edges"]), + document: z + .string() + .min(1) + .optional() + .describe( + "Exact documents[].relative_path; selects rows whose evidence names that archive.", + ), + }), + z.strictObject({ + ...page, + collection: z.enum(["documents", "coverage"]), + }), + ], +); + +export const interfaceBuilderViewItemsSchema = z.union([ + z.array(interfaceBuilderDocumentSchema), + z.array(nativeInvestigationNodeSchema), + z.array(nativeInvestigationEdgeSchema), + z.array(nativeInvestigationCoverageSchema), +]); + +/** The decoder's own truncation, independent of page exhaustion. */ +export const interfaceBuilderViewContextSchema = z.strictObject({ + source_truncated: z.boolean(), +}); + +const countsSchema = z.record(z.string(), z.number().int().positive()); + +/** Every decoded document with graph counts; rows and evidence stay retained. */ +export const interfaceBuilderSummarySchema = z.strictObject({ + target_sha256: interfaceBuilderAnalysisSchema.shape.target_sha256, + provider: interfaceBuilderAnalysisSchema.shape.graph.shape.provider, + documents: interfaceBuilderAnalysisSchema.shape.documents, + available_counts: z.strictObject({ + nodes: z.number().int().nonnegative(), + edges: z.number().int().nonnegative(), + coverage: z.number().int().nonnegative(), + }), + node_kinds: countsSchema, + edge_relations: countsSchema, + coverage_statuses: countsSchema, + truncated: z.boolean(), + limitation_count: z.number().int().nonnegative(), +}); + +const tally = (values: readonly string[]): Record => { + const counts = new Map(); + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); + return Object.fromEntries( + [...counts].sort(([left], [right]) => (left < right ? -1 : 1)), + ); +}; + +type InterfaceBuilderReport = z.output; + +/** Admit a well-formed report bound to its recorded target, if one exists. */ +const parseInterfaceBuilderReport = ( + parent: AnalysisViewParent, +): Result => { + const parsed = parseAnalysisViewResult( + interfaceBuilderAnalysisSchema, + parent, + ); + if (!parsed.ok) return parsed; + const report = parsed.value; + if ( + report.graph.target_sha256 !== report.target_sha256 || + (parent.subject != null && + parent.subject.digest.sha256 !== report.target_sha256) + ) + return err( + analysisViewInputError([ + { + path: ["source", "subject"], + reason: "invalid_value", + message: + "Interface Builder parent Evidence subject and graph must identify the report's target SHA-256.", + }, + ]), + ); + return ok(report); +}; + +/** Every decoded document with graph tallies; rows stay in the retained parent. */ +const summarizeInterfaceBuilder = ( + report: InterfaceBuilderReport, + limitationCount: number, +) => { + const { graph } = report; + const total = + report.documents.length + + graph.nodes.length + + graph.edges.length + + graph.coverage.length; + return { + summary: { + target_sha256: report.target_sha256, + provider: graph.provider, + documents: report.documents, + available_counts: { + nodes: graph.nodes.length, + edges: graph.edges.length, + coverage: graph.coverage.length, + }, + node_kinds: tally(graph.nodes.map((node) => node.kind)), + edge_relations: tally(graph.edges.map((edge) => edge.relation)), + coverage_statuses: tally(graph.coverage.map((row) => row.status)), + truncated: graph.truncated, + limitation_count: limitationCount, + }, + coverage: completeWithinViewCoverage(total, total), + }; +}; + +/** Project retained decode_interface_builder Evidence without reopening archives. */ +export const projectInterfaceBuilderView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "interface-builder") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "Interface Builder decoding supports summary and interface-builder views.", + }, + ]), + ); + const parsed = parseInterfaceBuilderReport(parent); + if (!parsed.ok) return parsed; + const report = parsed.value; + const { graph } = report; + const limitations = uniqueSorted([ + ...parent.limitations, + ...report.limitations, + ]); + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "decode_interface_builder" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { + path: parent.subject?.local_path ?? "", + sha256: report.target_sha256, + }, + limitations, + unknowns: [ + "Rows describe recognized archive objects and connections only; unrecognized classes, fields and runtime-created UI remain unknown.", + ...(graph.truncated + ? [ + "The decoded graph is incomplete (see coverage and limitations for the cause); page exhaustion does not establish absence of undecoded objects or connections.", + ] + : []), + ], + }; + if (view.kind === "summary") + return ok({ + kind: "summary", + view, + ...shared, + ...summarizeInterfaceBuilder(report, limitations.length), + }); + const document = + view.collection === "nodes" || view.collection === "edges" + ? view.document + : undefined; + if ( + document !== undefined && + !report.documents.some((row) => row.relative_path === document) + ) + return err( + analysisViewInputError([ + { + path: ["view", "document"], + reason: "invalid_value", + message: + "Select an exact documents[].relative_path from this decoded report.", + }, + ]), + ); + const inDocument = < + Row extends { readonly evidence: readonly { artifact_path: unknown }[] }, + >( + candidates: readonly Row[], + ): readonly Row[] => + document === undefined + ? candidates + : candidates.filter((row) => + row.evidence.some((evidence) => evidence.artifact_path === document), + ); + const rows = + view.collection === "documents" + ? report.documents + : view.collection === "coverage" + ? graph.coverage + : view.collection === "nodes" + ? inDocument(graph.nodes) + : inDocument(graph.edges); + const items = rows.slice(view.offset, view.offset + view.limit); + return ok({ + kind: "interface-builder", + view, + items, + ...shared, + source_truncated: graph.truncated, + coverage: pageViewCoverage(view.offset, items.length, rows.length), + }); +}; diff --git a/src/domain/analysisView/machoView.ts b/src/domain/analysisView/machoView.ts index 1a64d23aa..fe39d6326 100644 --- a/src/domain/analysisView/machoView.ts +++ b/src/domain/analysisView/machoView.ts @@ -2,9 +2,9 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { uniqueSorted } from "../canonicalOrdering.js"; import { isMachOFormat } from "../binaryTargetTypes.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { inspectMachoSchema } from "../native/nativeInspection.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { analysisViewInputError, type AnalysisViewParent, @@ -93,19 +93,8 @@ type MachoReport = z.output; const parseMachoReport = ( parent: AnalysisViewParent, ): Result => { - const parsed = inspectMachoSchema.safeParse(parent.normalizedResult); - if (!parsed.success) - return err( - analysisInputErrorFromIssues( - "inspect_analysis_view", - parsed.error.issues.map((issue) => ({ - ...issue, - path: ["source", "normalized_result", ...issue.path], - })), - { source: { normalized_result: parent.normalizedResult } }, - { cause: parsed.error }, - ), - ); + const parsed = parseAnalysisViewResult(inspectMachoSchema, parent); + if (!parsed.ok) return parsed; if (parent.subject != null && !isMachOFormat(parent.subject.format)) return err( analysisViewInputError([ @@ -117,7 +106,7 @@ const parseMachoReport = ( }, ]), ); - return ok(parsed.data); + return ok(parsed.value); }; /** Counts replace rows; every collection caveat stays in the limitations. */ diff --git a/src/domain/analysisView/managedArtifactView.ts b/src/domain/analysisView/managedArtifactView.ts index 49d16fa80..3dd0033d7 100644 --- a/src/domain/analysisView/managedArtifactView.ts +++ b/src/domain/analysisView/managedArtifactView.ts @@ -1,12 +1,12 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { uniqueSorted } from "../canonicalOrdering.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { managedArtifactInspectionSchema, managedMemberInspectionSchema, } from "../managed/managedArtifact.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { analysisViewInputError, type AnalysisViewParent, @@ -67,22 +67,12 @@ export const projectManagedArtifactView = ( }, ]), ); - const parsed = managedArtifactInspectionSchema.safeParse( - parent.normalizedResult, + const parsed = parseAnalysisViewResult( + managedArtifactInspectionSchema, + parent, ); - if (!parsed.success) - return err( - analysisInputErrorFromIssues( - "inspect_analysis_view", - parsed.error.issues.map((issue) => ({ - ...issue, - path: ["source", "normalized_result", ...issue.path], - })), - { source: { normalized_result: parent.normalizedResult } }, - { cause: parsed.error }, - ), - ); - const report = parsed.data; + if (!parsed.ok) return parsed; + const report = parsed.value; const identity_scope = { token_identity: "build-local" as const, requires_artifact_sha256: report.artifact.sha256, diff --git a/src/domain/analysisView/wasmArtifactView.ts b/src/domain/analysisView/wasmArtifactView.ts index dc3036b26..52a97632a 100644 --- a/src/domain/analysisView/wasmArtifactView.ts +++ b/src/domain/analysisView/wasmArtifactView.ts @@ -2,11 +2,11 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { uniqueSorted } from "../canonicalOrdering.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { wasmArtifactSchema, wasmArtifactMatchesSubject, } from "../wasm/wasmArtifact.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { analysisViewInputError, type AnalysisViewParent, @@ -81,20 +81,9 @@ export const projectWasmArtifactView = ( }, ]), ); - const parsed = wasmArtifactSchema.safeParse(parent.normalizedResult); - if (!parsed.success) - return err( - analysisInputErrorFromIssues( - "inspect_analysis_view", - parsed.error.issues.map((issue) => ({ - ...issue, - path: ["source", "normalized_result", ...issue.path], - })), - { source: { normalized_result: parent.normalizedResult } }, - { cause: parsed.error }, - ), - ); - const report = parsed.data; + const parsed = parseAnalysisViewResult(wasmArtifactSchema, parent); + if (!parsed.ok) return parsed; + const report = parsed.value; if ( !wasmArtifactMatchesSubject( report.artifact, diff --git a/src/domain/callPath.test.ts b/src/domain/callPath.test.ts index 80a260ac3..048cb63b4 100644 --- a/src/domain/callPath.test.ts +++ b/src/domain/callPath.test.ts @@ -337,6 +337,42 @@ describe("call path topology and capacity", () => { }); }); +describe("directional call relationship coverage", () => { + it.each([ + { + directions: ["callers"] as ["callers"], + status: "not_found", + exhaustive: true, + }, + { + directions: ["callees"] as ["callees"], + status: "unknown", + exhaustive: false, + }, + { + directions: ["callers", "callees"] as ["callers", "callees"], + status: "unknown", + exhaustive: false, + }, + ])( + "uses $directions coverage for the forward search", + ({ directions, status, exhaustive }) => { + const limitation = "The inventory omitted a reference."; + const relationshipCoverage: RelationshipCoverage = { + status: "partial", + limitations: [limitation], + limited_directions: directions, + }; + const result = run([ + observe("0x1000", ["0x2000"], "rea-workflow", { relationshipCoverage }), + observe("0x2000", [], "rea-workflow", { relationshipCoverage }), + ]); + expect(result).toMatchObject({ status, search_scope: { exhaustive } }); + expect(result.limitations).toContain(limitation); + }, + ); +}); + describe("explicit call relationship coverage", () => { it("accumulates 150,000 partial reasons without argument-stack exhaustion", () => { const result = run([ diff --git a/src/domain/callPath.ts b/src/domain/callPath.ts index 3ffbc2356..65524fbc2 100644 --- a/src/domain/callPath.ts +++ b/src/domain/callPath.ts @@ -10,7 +10,7 @@ import { parseFunctionEvidence, type FunctionSnapshot, } from "./functionDossierEvidence.js"; -import { isCallerOnlyUnknownCoverage } from "./functionCallCoverage.js"; +import { isCallerOnlyIncompleteCoverage } from "./functionCallCoverage.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; /** Minimal directed caller-to-callee adjacency for call-path search. */ @@ -268,7 +268,7 @@ const inspectSearch = ({ coverage === null ? snapshot.limitations.length > 0 : coverage.status !== "complete" && - !isCallerOnlyUnknownCoverage(coverage) + !isCallerOnlyIncompleteCoverage(coverage) ) incompleteExpandedCoverage = true; } else if (node === goal) { diff --git a/src/domain/changedBehavior.ts b/src/domain/changedBehavior.ts index 56e9b6ad9..3d4062e83 100644 --- a/src/domain/changedBehavior.ts +++ b/src/domain/changedBehavior.ts @@ -6,7 +6,10 @@ import { compositeKey, compareUnicodeCodePoints, } from "./unicodeCodePointOrder.js"; -import { comparisonSourceEvidenceIds } from "./comparisonSourceEvidence.js"; +import { + assertComparisonFindingLinks, + comparisonSourceEvidenceIds, +} from "./comparisonSourceEvidence.js"; import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js"; import { functionComparisonResultSchema } from "./functionComparisonSchemas.js"; import { @@ -260,7 +263,7 @@ const parseResult = ( const result = functionComparisonResultSchema.parse( evidence.normalized_result, ); - assertNestedLinks( + assertComparisonFindingLinks( evidence, result.dimensions.flatMap(({ evidence_links: links }) => links), ); @@ -269,24 +272,13 @@ const parseResult = ( const result = artifactComparisonResultSchema.parse( evidence.normalized_result, ); - assertNestedLinks( + assertComparisonFindingLinks( evidence, result.changes.flatMap(({ evidence_links: links }) => links), ); return { operation, evidence, result }; }; -const assertNestedLinks = ( - evidence: Evidence, - nestedLinks: readonly string[], -): void => { - const topLevel = new Set(evidence.evidence_links); - if (nestedLinks.some((evidenceId) => !topLevel.has(evidenceId))) - throw new TypeError( - "Comparison finding cites Evidence outside its top-level closure", - ); -}; - const findingsFor = (comparison: ParsedComparison): Finding[] => { if (comparison.operation === "compare_process_captures") return processFindings(comparison); diff --git a/src/domain/comparisonSourceEvidence.ts b/src/domain/comparisonSourceEvidence.ts index 420428b48..096e76548 100644 --- a/src/domain/comparisonSourceEvidence.ts +++ b/src/domain/comparisonSourceEvidence.ts @@ -55,3 +55,15 @@ export const comparisonSourceEvidenceIds = (evidence: Evidence): string[] => { const sides = comparisonSourceEvidenceSides(evidence); return uniqueSorted([...sides.left, ...sides.right]); }; + +/** Preserve the source closure when consuming nested comparison findings. */ +export const assertComparisonFindingLinks = ( + evidence: Pick, + nestedLinks: readonly string[], +): void => { + const closure = new Set(evidence.evidence_links); + if (nestedLinks.some((id) => !closure.has(id))) + throw new TypeError( + "Comparison finding cites Evidence outside its top-level closure", + ); +}; diff --git a/src/domain/functionCallCoverage.test.ts b/src/domain/functionCallCoverage.test.ts index 6fd3fb045..543f6ca92 100644 --- a/src/domain/functionCallCoverage.test.ts +++ b/src/domain/functionCallCoverage.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { combineFunctionRelationshipCoverage, - isCallerOnlyUnknownCoverage, + isCallerOnlyIncompleteCoverage, } from "./functionCallCoverage.js"; import type { RelationshipCoverage } from "./relationshipCoverage.js"; @@ -22,14 +22,14 @@ const cappedCallees: RelationshipCoverage = { }; const complete: RelationshipCoverage = { status: "complete" }; -describe("isCallerOnlyUnknownCoverage", () => { +describe("isCallerOnlyIncompleteCoverage", () => { it("detects unknown callers beside complete callees", () => { const combined = combineFunctionRelationshipCoverage( unknownCallers, complete, ); expect(combined.status).toBe("unknown"); - expect(isCallerOnlyUnknownCoverage(combined)).toBe(true); + expect(isCallerOnlyIncompleteCoverage(combined)).toBe(true); }); it("rejects unknown callees beside complete callers", () => { @@ -37,7 +37,7 @@ describe("isCallerOnlyUnknownCoverage", () => { complete, unknownCallees, ); - expect(isCallerOnlyUnknownCoverage(combined)).toBe(false); + expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false); }); it("rejects unknown coverage on both sides", () => { @@ -45,7 +45,7 @@ describe("isCallerOnlyUnknownCoverage", () => { unknownCallers, unknownCallees, ); - expect(isCallerOnlyUnknownCoverage(combined)).toBe(false); + expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false); }); it("rejects unknown callers beside IDA-capped callees", () => { @@ -54,11 +54,37 @@ describe("isCallerOnlyUnknownCoverage", () => { cappedCallees, ); expect(combined.status).toBe("unknown"); - expect(isCallerOnlyUnknownCoverage(combined)).toBe(false); + expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false); }); it("rejects complete coverage", () => { const combined = combineFunctionRelationshipCoverage(complete, complete); - expect(isCallerOnlyUnknownCoverage(combined)).toBe(false); + expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false); + }); + + it("retains caller-only partial coverage without relying on limitation wording", () => { + const combined = combineFunctionRelationshipCoverage( + { status: "partial", limitations: ["Missing incoming references."] }, + complete, + ); + expect(combined).toMatchObject({ limited_directions: ["callers"] }); + expect(isCallerOnlyIncompleteCoverage(combined)).toBe(true); + }); + + it("uses explicit directions even when explanation text contains direction labels", () => { + expect( + isCallerOnlyIncompleteCoverage({ + status: "unknown", + reason: "Callers: unavailable. Callees: diagnostic text.", + limited_directions: ["callers"], + }), + ).toBe(true); + expect( + isCallerOnlyIncompleteCoverage({ + status: "unknown", + reason: "Callers: unavailable.", + limited_directions: ["callees"], + }), + ).toBe(false); }); }); diff --git a/src/domain/functionCallCoverage.ts b/src/domain/functionCallCoverage.ts index b64e78cd6..ce3798c4f 100644 --- a/src/domain/functionCallCoverage.ts +++ b/src/domain/functionCallCoverage.ts @@ -22,6 +22,16 @@ export const combineFunctionRelationshipCoverage = ( ), ); const details = diagnostics.length > 0 ? { diagnostics } : {}; + const callersLimited = callers?.status !== "complete"; + const calleesLimited = callees?.status !== "complete"; + const limited_directions: Extract< + RelationshipCoverage, + { status: "partial" } + >["limited_directions"] = callersLimited + ? calleesLimited + ? ["callers", "callees"] + : ["callers"] + : ["callees"]; const reasons = reads.flatMap(({ direction, coverage }) => { const label = direction === "callers" ? callerCoveragePrefix : calleeCoveragePrefix; @@ -43,30 +53,37 @@ export const combineFunctionRelationshipCoverage = ( return { status: "unknown", reason: uniqueSorted(reasons).join(" "), + limited_directions, ...details, }; if (reasons.length > 0) return { status: "partial", limitations: uniqueSorted(reasons), + limited_directions, ...details, }; return { status: "complete", ...details }; }; /** - * Whether combined relationship coverage is unknown only on the caller side. - * The combine helper above prefixes each direction's reasons, so a reason - * that carries the caller prefix and no callee prefix records observed - * callees with unobservable callers. Forward (callee-edge) searches stay - * exhaustive under that caveat; the caller reason remains a limitation. + * Forward searches depend on callee coverage. Preserve the historical + * unknown-reason encoding when reading observations without explicit directions. */ -export const isCallerOnlyUnknownCoverage = ( +export const isCallerOnlyIncompleteCoverage = ( coverage: RelationshipCoverage, -): boolean => - coverage.status === "unknown" && - coverage.reason.startsWith(callerCoveragePrefix) && - !coverage.reason.includes(calleeCoveragePrefix); +): boolean => { + if (coverage.status === "complete") return false; + if (coverage.limited_directions !== undefined) + return !coverage.limited_directions.some( + (direction) => direction === "callees", + ); + return ( + coverage.status === "unknown" && + coverage.reason.startsWith(callerCoveragePrefix) && + !coverage.reason.includes(calleeCoveragePrefix) + ); +}; /** Explain why two resolved call sets cannot establish call-set equality. */ export const functionCallCoverageLimitations = ( diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 16f8282c9..e49b0a892 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -625,6 +625,40 @@ describe("function comparison CFG address normalization", () => { }); describe("function comparison unresolved text", () => { + it("qualifies exact text changes caused by relocated provider labels", () => { + const left = observe( + "2", + functionDossierSchema.parse({ + ...dossier("return FUN_00001010(DAT_00001020) % 3;", "0x1000"), + assembly: ["0x1000 call FUN_00001010", "0x1004 ret"], + }), + ); + const right = observe( + "3", + functionDossierSchema.parse({ + ...dossier("return FUN_00002010(DAT_00002020) % 3;", "0x2000"), + assembly: ["0x2000 call FUN_00002010", "0x2004 ret"], + }), + ); + const comparison = compareFunctions(left, right); + for (const name of ["pseudocode", "assembly"]) { + const dimension = comparison.dimensions.find( + ({ dimension }) => dimension === name, + ); + expect(dimension).toMatchObject({ + status: "changed", + limitations: expect.arrayContaining([ + expect.stringContaining( + "layout-only differences do not establish a behavior change", + ), + ]), + }); + } + expect( + comparison.dimensions.find(({ dimension }) => dimension === "cfg"), + ).toMatchObject({ status: "unchanged" }); + }); + it("counts Unicode text consistently for unresolved provider comparisons", () => { const left = observe("b", dossier("return '😀';", "0x1000"), "provider-a"); const right = observe("c", dossier("return '😀';", "0x2000"), "provider-b"); @@ -886,6 +920,31 @@ describe("function comparison Swift string literals", () => { }); describe("function comparison partial Swift literal coverage", () => { + it("keeps literal comparisons unknown at unresolved jump candidates", () => { + const unresolved = functionDossierSchema.parse({ + ...withLiterals("0x2000", []), + swift_string_literal_omissions: [ + { + source_address: "0x2004", + representation: "small", + address: null, + byte_length: 4, + reason: "unresolved_jump", + registers: ["x0", "x1"], + words: ["0x732f4220", "0xe400000000000000"], + }, + ], + }); + expect( + stringsDimension( + "8", + withLiterals("0x1000", ["SleepDisabled"]), + "9", + unresolved, + ), + ).toMatchObject({ status: "unknown" }); + }); + it("does not equate a partial literal record with a complete one", () => { const partial = functionDossierSchema.parse({ ...withLiterals("0x2000", ["SleepDisabled"]), @@ -930,7 +989,9 @@ describe("function comparison partial Swift literal coverage", () => { stringsDimension("d", withLiterals("0x1000", []), "e", partial), ).toMatchObject({ status: "changed" }); }); +}); +describe("function comparison partial Swift literal omission interpretation", () => { it("does not interpret an unexamined path as a missing literal at a retained site", () => { const partial = functionDossierSchema.parse({ ...withLiterals("0x2000", ["A"]), diff --git a/src/domain/functionComparisonDimensions.ts b/src/domain/functionComparisonDimensions.ts index f91057a6b..4a36e1c27 100644 --- a/src/domain/functionComparisonDimensions.ts +++ b/src/domain/functionComparisonDimensions.ts @@ -121,12 +121,14 @@ const compareText = ( right: string, context: ComparisonContext, ): FunctionDimension => { - const limitations = - dimension === "assembly" + const limitations = [ + "Exact provider text is compared without address or generated-name normalization; layout-only differences do not establish a behavior change.", + ...(dimension === "assembly" ? [ "Assembly is opaque provider text; relocation normalization is unavailable.", ] - : []; + : []), + ]; if (context.providersDiffer) return unresolvedDimension({ dimension, @@ -149,6 +151,7 @@ const compareText = ( leftCount: [...left].length, rightCount: [...right].length, limitations: [ + ...limitations, "Bounded line diff exceeded its time or edit-distance limit.", ], }); diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 258b1a82f..6596d0d7b 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -319,15 +319,15 @@ const swiftStringLiteralOmissionFields = { source_address: swiftStringLiteralFields.source_address, byte_length: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), reason: z - .literal("capacity") + .enum(["capacity", "unresolved_jump"]) .describe( - "The record did not fit the provider's memory budget for this dossier; decoding stopped here, so later use sites were not examined.", + "Why this candidate is omitted: capacity stopped decoding, or an unresolved jump left its use unknown.", ), registers: swiftStringLiteralFields.registers, words: swiftStringLiteralFields.words, }; -/** A Swift literal whose layout matched but whose record did not fit; decoding stopped there. */ +/** A Swift literal candidate omitted because decoding capacity or control flow left it unknown. */ export const swiftStringLiteralOmissionSchema = z.discriminatedUnion( "representation", [ @@ -410,7 +410,7 @@ export const functionDossierSchema = z .array(swiftStringLiteralOmissionSchema) .exactOptional() .describe( - "The literal at which decoding stopped for capacity. Present exactly when swift_string_literals is present; empty when decoding examined every use site.", + "Literal candidates omitted for capacity or unresolved control flow. Present exactly when swift_string_literals is present; empty when all examined use sites were classified.", ), basic_blocks: z.array( z diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts index ded36daff..d95f64857 100644 --- a/src/domain/propertyListKeys.test.ts +++ b/src/domain/propertyListKeys.test.ts @@ -1,4 +1,5 @@ -import { expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; +import { parse } from "plist"; import { omitPrototypeKeys, parseXmlPropertyList } from "./propertyListKeys.js"; @@ -234,3 +235,55 @@ it("locates sparse omitted keys across newline-dense source", () => { omittedPrototypeKeys: 2, }); }); + +describe("OpenStep plist nesting preflight", () => { + const arrays = (depth: number, leaf = "x") => + "(".repeat(depth) + leaf + ")".repeat(depth); + + it.each([129, 3000])( + "rejects %s nested OpenStep values with the shared depth limit", + (depth) => { + expect(() => parseXmlPropertyList(arrays(depth))).toThrow( + "Plist exceeds 128 nesting levels", + ); + }, + ); + + it.each(["{}", "()"])( + "preserves the first %s root despite deeply nested trailing text", + (root) => { + expect(parseXmlPropertyList(root + arrays(3000))).toEqual({ + value: root === "{}" ? {} : [], + omittedPrototypeKeys: 0, + }); + }, + ); + + it("accepts the deepest supported OpenStep value and empty container", () => { + expect(() => parseXmlPropertyList(arrays(128))).not.toThrow(); + expect(() => parseXmlPropertyList(arrays(129, ""))).not.toThrow(); + }); + + it.each([ + "(x, y,)", + "{ key = x next = (y,); }", + "{ (compound, key) = x; { nested = key; } = y; }", + "{ key = (); next = {}; }", + ])("preserves pinned separator and compound-key semantics for %s", (text) => { + expect(parseXmlPropertyList(text)).toEqual({ + value: parse(text), + omittedPrototypeKeys: 0, + }); + }); + + it("ignores brackets in quoted strings, escaped quotes, comments, and data", () => { + expect( + parseXmlPropertyList( + '{ quote = "\\\"( )"; /* ((( */ data = <2829>; // (((\n values = (x); }', + ), + ).toEqual({ + value: { quote: '"( )', data: new Uint8Array([40, 41]), values: ["x"] }, + omittedPrototypeKeys: 0, + }); + }); +}); diff --git a/src/domain/propertyListKeys.ts b/src/domain/propertyListKeys.ts index 35aa0889d..2dcab952c 100644 --- a/src/domain/propertyListKeys.ts +++ b/src/domain/propertyListKeys.ts @@ -94,7 +94,10 @@ export const parseXmlPropertyList = ( // Preserve the pinned decoder's existing OpenStep autodetection, also used // by app Info.plist resolution. Only XML needs the key-element substitution. const first = text.trimStart()[0]; - if (first === "{" || first === "(") return omitPrototypeKeys(parse(text)); + if (first === "{" || first === "(") { + assertOpenStepDepthWithinLimit(text); + return omitPrototypeKeys(parse(text)); + } // Normalize only the literal line endings the pinned decoder normalizes. // Preserve character references in the source: serializing a DOM would turn // e.g. an encoded carriage return into a literal newline on the second parse. @@ -111,6 +114,133 @@ export const parseXmlPropertyList = ( return withoutKey(parse(substituted), placeholder); }; +interface OpenStepContainer { + end: string; + next: "key" | "equals" | "value" | "separator" | "after-value"; +} + +/** Consume only the punctuation expected between the pinned decoder's values. */ +const consumeOpenStepSeparator = ( + parent: OpenStepContainer | undefined, + character: string, +): "consumed" | "invalid" | "none" => { + if (parent?.next === "after-value") { + parent.next = "key"; + return character === ";" ? "consumed" : "none"; + } + if (parent?.next !== "equals" && parent?.next !== "separator") return "none"; + if (character !== (parent.next === "equals" ? "=" : ",")) return "invalid"; + parent.next = "value"; + return "consumed"; +}; + +/** An array may close after a comma; a dictionary still needs its assigned value. */ +const admitsOpenStepCloser = ( + parent: OpenStepContainer | undefined, + character: string, +): boolean => + parent?.end === character && + parent.next !== "equals" && + !(parent.end === "}" && parent.next === "value"); + +/** Bound OpenStep value nesting before the pinned decoder recursively reads it. */ +const assertOpenStepDepthWithinLimit = (text: string): void => { + const containers: OpenStepContainer[] = []; + const completeValue = (): void => { + const parent = containers.at(-1); + if (parent !== undefined) + parent.next = + parent.next === "key" + ? "equals" + : parent.end === "}" + ? "after-value" + : "separator"; + }; + const assertDepth = (): void => { + if (containers.length > MAX_PLIST_DEPTH) + throw new RangeError( + `Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`, + ); + }; + let cursor = 0; + while (cursor < text.length) { + const afterTrivia = skipOpenStepTrivia(text, cursor); + if (afterTrivia !== cursor) { + cursor = afterTrivia; + continue; + } + const character = text[cursor] ?? ""; + const parent = containers.at(-1); + // Stop at the first impossible token: only the pinned decoder owns syntax + // diagnostics. A later deep suffix cannot change that failure into a limit. + if (character === "}" || character === ")") { + if (!admitsOpenStepCloser(parent, character)) return; + containers.pop(); + // parseOpenStep returns the first root value without reading trailing text. + if (containers.length === 0) return; + completeValue(); + cursor += 1; + continue; + } + const separator = consumeOpenStepSeparator(parent, character); + if (separator === "invalid") return; + if (separator === "consumed") { + cursor += 1; + continue; + } + if (character === "{" || character === "(") { + assertDepth(); + containers.push({ + end: character === "{" ? "}" : ")", + next: character === "{" ? "key" : "value", + }); + cursor += 1; + continue; + } + const end = skipOpenStepScalar(text, cursor); + if (end === cursor) return; + if (parent?.next !== "key") assertDepth(); + cursor = end; + completeValue(); + } +}; + +/** Skip grammar trivia without interpreting delimiter examples inside comments. */ +const skipOpenStepTrivia = (text: string, cursor: number): number => { + if (/\s/u.test(text[cursor] ?? "")) return cursor + 1; + if (!text.startsWith("//", cursor) && !text.startsWith("/*", cursor)) + return cursor; + const line = text[cursor + 1] === "/"; + const end = text.indexOf(line ? "\n" : "*/", cursor + 2); + return end === -1 ? text.length : end + (line ? 1 : 2); +}; + +/** Read the extent of one opaque scalar; the pinned decoder still owns its value. */ +const skipOpenStepScalar = (text: string, start: number): number => { + let cursor = start; + const character = text[cursor]; + if (character === '"') { + cursor += 1; + while (cursor < text.length) { + const next = text[cursor]; + cursor += next === "\\" ? 2 : 1; + if (next === '"') break; + } + } else if (character === "<") { + const end = text.indexOf(">", cursor + 1); + cursor = end === -1 ? text.length : end + 1; + } else { + // Match the pinned OpenStep decoder's unquoted-string alphabet. Other + // syntax remains for the decoder to reject; preflight only owns depth. + while ( + cursor < text.length && + /[a-zA-Z0-9._/$:-]/u.test(text[cursor] ?? "") + ) + cursor += 1; + } + return cursor; +}; + /** Elements whose children the pinned plist value decoder visits recursively. */ const PLIST_CONTAINER_ELEMENTS = new Set(["plist", "dict", "array"]); diff --git a/src/domain/reconstructionVerification.functionSources.test.ts b/src/domain/reconstructionVerification.functionSources.test.ts index 8f25ccb36..7ca6961be 100644 --- a/src/domain/reconstructionVerification.functionSources.test.ts +++ b/src/domain/reconstructionVerification.functionSources.test.ts @@ -662,7 +662,7 @@ describe("function reconstruction admission controls", () => { ]); }); - it("rejects selected-dimension citations outside comparison closure", () => { + it("rejects any comparison finding link outside the top-level closure", () => { const evidence = comparison(singular, { result: jsonValueSchema.parse({ ...baselineResult, @@ -677,7 +677,7 @@ describe("function reconstruction admission controls", () => { }), }); expect(admittedVerification(evidence, [left, right, extra])).toThrow( - /dimension cites Evidence outside comparison closure/u, + /finding cites Evidence outside its top-level closure/u, ); }); }); diff --git a/src/domain/reconstructionVerification.test.ts b/src/domain/reconstructionVerification.test.ts index 664438d85..159a81729 100644 --- a/src/domain/reconstructionVerification.test.ts +++ b/src/domain/reconstructionVerification.test.ts @@ -21,7 +21,13 @@ const source = ( digit: string, authority: "controlled-replay" | "shipped-artifact", confidence: "observed" | "derived" = "observed", - captureResult: JsonValue = EMPTY_PROCESS_CAPTURE_EXAMPLE, + { + captureResult = EMPTY_PROCESS_CAPTURE_EXAMPLE, + replayEnvironment = environment, + }: { + readonly captureResult?: JsonValue; + readonly replayEnvironment?: typeof environment | null; + } = {}, ): Evidence => createEvidence( { @@ -43,7 +49,7 @@ const source = ( result: authority === "controlled-replay" ? captureResult : { digit }, confidence, authority, - environment: authority === "controlled-replay" ? environment : null, + environment: authority === "controlled-replay" ? replayEnvironment : null, }, ); @@ -113,6 +119,31 @@ const processComparison = ( }, ); +const multiSourceProcessComparison = ( + left: readonly Evidence[], + right: readonly Evidence[], +): Evidence => + createEvidence( + undefined, + { + id: "rea-process", + name: "REA deterministic process harness", + version: "3", + }, + { + predicateType: "rea.process-comparison", + operation: "compare_process_captures", + parameters: { + left_evidence_ids: left.map(({ evidence_id: id }) => id), + right_evidence_ids: right.map(({ evidence_id: id }) => id), + }, + result: processResult(), + confidence: "derived", + authority: "analyst-inference", + evidenceLinks: [...left, ...right].map(({ evidence_id: id }) => id), + }, + ); + const behavioralSpec = (comparison: Evidence) => ({ name: "CLI compatibility", claims: [ @@ -288,12 +319,9 @@ describe("reconstruction verification", () => { describe("reconstruction process capture input", () => { it("rejects saved captures without explicit executable identity", () => { - const left = source( - "1", - "controlled-replay", - "observed", - legacyV3Capture(), - ); + const left = source("1", "controlled-replay", "observed", { + captureResult: legacyV3Capture(), + }); const right = source("2", "controlled-replay"); const comparison = processComparison(left, right, processResult()); expect(() => @@ -306,6 +334,49 @@ describe("reconstruction process capture input", () => { }); describe("reconstruction verification outcomes", () => { + it("requires every selected behavioral source to share a replay environment", () => { + const replay = (digit: string, id: string | null) => + source(digit, "controlled-replay", "observed", { + replayEnvironment: id === null ? null : { ...environment, id }, + }); + const verify = (left: readonly Evidence[], right: readonly Evidence[]) => { + const comparison = multiSourceProcessComparison(left, right); + return verifyReconstruction( + behavioralSpec(comparison), + createEvidenceBundle([...left, ...right, comparison]), + ); + }; + const limitation = + "Controlled replay environments are unavailable or incompatible."; + const differentOnLeft = verify( + [replay("1", "env-a"), replay("2", "env-b")], + [replay("3", "env-a"), replay("4", "env-a")], + ); + expect(differentOnLeft.status).toBe("unknown"); + expect(differentOnLeft.claims.items[0]?.limitations).toContain(limitation); + + const differentOnRight = verify( + [replay("5", "env-a"), replay("6", "env-a")], + [replay("7", "env-a"), replay("8", "env-b")], + ); + expect(differentOnRight.status).toBe("unknown"); + expect(differentOnRight.claims.items[0]?.limitations).toContain(limitation); + + const unavailable = verify( + [replay("9", "env-a"), replay("a", "env-a")], + [replay("b", "env-a"), replay("c", null)], + ); + expect(unavailable.status).toBe("unknown"); + expect(unavailable.claims.items[0]?.limitations).toContain(limitation); + + expect( + verify( + [replay("d", "env-a"), replay("e", "env-a")], + [replay("f", "env-a"), replay("0", "env-a")], + ).status, + ).toBe("pass"); + }); + it("fails an authoritative observed disagreement", () => { const left = source("1", "controlled-replay"); const right = source("2", "controlled-replay"); diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index 6f693a158..ee7b73b9b 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -7,7 +7,10 @@ import { artifactComparisonResultSchema } from "./artifactComparison.js"; import { artifactInventoryResultSchema } from "./artifactGraph.js"; import { uniqueSorted } from "./canonicalOrdering.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; -import { comparisonSourceEvidenceSides } from "./comparisonSourceEvidence.js"; +import { + comparisonSourceEvidenceSides, + assertComparisonFindingLinks, +} from "./comparisonSourceEvidence.js"; import { parseEvidence, type Evidence } from "./evidence.js"; import { parseEvidenceBundle } from "./evidenceBundle.js"; import { functionComparisonResultSchema } from "./functionComparisonSchemas.js"; @@ -176,6 +179,7 @@ const evaluateClaim = ( unknown_ids: displayedUnknowns.map(({ unknown_id: id }) => id), limitations: uniqueSorted([ ...comparison.limitations, + ...observation.limitations, ...limitations, ...displayedUnknowns.map( ({ question }) => `Residual unknown: ${question}`, @@ -245,6 +249,7 @@ const comparisonObservation = ( evidence: Evidence, ): { readonly status: ObservedStatus; + readonly limitations: readonly string[]; readonly hasIndependentFunctionChange?: boolean; } => { if (claim.kind === "behavioral") { @@ -255,16 +260,26 @@ const comparisonObservation = ( return { status: claim.dimension === "overall" ? result.status : result[claim.dimension], + limitations: [], }; } if (claim.kind === "structural-function") { const result = functionComparisonResultSchema.parse( evidence.normalized_result, ); + assertComparisonFindingLinks( + evidence, + result.dimensions.flatMap(({ evidence_links }) => evidence_links), + ); if (claim.dimension === "overall") return { status: result.status, - // Historical overall changes may come solely from incomplete call sets. + limitations: result.dimensions.flatMap( + ({ limitations }) => limitations, + ), + // Historical overall changes can be caused only by incomplete call + // sets. Ignore that aggregate change when independently checking the + // source call-coverage limitations. hasIndependentFunctionChange: result.function_match.status === "mismatched" || (result.function_match.status === "matched" && @@ -279,28 +294,16 @@ const comparisonObservation = ( ); if (dimension === undefined) throw new TypeError("Function comparison dimension missing"); - if ( - dimension.evidence_links.some( - (id) => !evidence.evidence_links.includes(id), - ) - ) - throw new TypeError( - "Function dimension cites Evidence outside comparison closure", - ); - return { status: dimension.status }; + return { status: dimension.status, limitations: dimension.limitations }; } const result = artifactComparisonResultSchema.parse( evidence.normalized_result, ); - if ( - result.changes.some((item) => - item.evidence_links.some((id) => !evidence.evidence_links.includes(id)), - ) - ) - throw new TypeError( - "Artifact change cites Evidence outside comparison closure", - ); - return { status: result.status }; + assertComparisonFindingLinks( + evidence, + result.changes.flatMap(({ evidence_links }) => evidence_links), + ); + return { status: result.status, limitations: [] }; }; const validateProcessStatus = ( @@ -346,13 +349,15 @@ const authorityLimitations = ( ) limitations.push("Structural source artifact identity is unavailable."); if (claim.kind === "behavioral") { - const byId = new Map(sources.map((source) => [source.evidence_id, source])); - const left = byId.get(sides.left[0] ?? ""); - const right = byId.get(sides.right[0] ?? ""); + const referenceEnvironment = sources[0]?.environment; if ( - left?.environment === null || - right?.environment === null || - JSON.stringify(left?.environment) !== JSON.stringify(right?.environment) + referenceEnvironment === null || + referenceEnvironment === undefined || + sources.some( + ({ environment }) => + environment === null || + JSON.stringify(environment) !== JSON.stringify(referenceEnvironment), + ) ) limitations.push( "Controlled replay environments are unavailable or incompatible.", diff --git a/src/domain/staticRuntimeCorrelation.test.ts b/src/domain/staticRuntimeCorrelation.test.ts index e2d4fff14..83051af7e 100644 --- a/src/domain/staticRuntimeCorrelation.test.ts +++ b/src/domain/staticRuntimeCorrelation.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import { INVESTIGATION_EXAMPLES } from "../contracts/investigationExamples.js"; import { createEvidence, type Evidence } from "./evidence.js"; -import type { JsonValue } from "./jsonValue.js"; +import { jsonValueSchema, type JsonValue } from "./jsonValue.js"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../contracts/functionComparisonExample.js"; +import { functionDossierSchema } from "./hopperValues.js"; +import { compareFunctions } from "./functionComparison.js"; import { correlateStaticAndRuntime, staticRuntimeCorrelationInputSchema, @@ -113,6 +116,70 @@ const mapping = ( ], }); +it("keeps text, assembly and unavailable CFG caveats on their selected correlations", () => { + const source = FUNCTION_COMPARISON_EXAMPLE.left; + const left = createEvidence( + { path: "/tmp/unavailable-cfg", sha256: "a".repeat(64), format: "mach-o" }, + source.provider, + { + operation: source.operation, + parameters: source.parameters, + result: jsonValueSchema.parse({ + ...functionDossierSchema.parse(source.normalized_result), + basic_blocks: [{ start: "0x1000", end: "0x1000", successors: [] }], + }), + confidence: source.confidence, + authority: source.authority, + }, + ); + const right = FUNCTION_COMPARISON_EXAMPLE.right; + const computed = compareFunctions(left, right); + const comparison = createEvidence(undefined, staticComparison.provider, { + predicateType: "rea.function-comparison", + operation: "compare_functions", + parameters: { + left_evidence_id: left.evidence_id, + right_evidence_id: right.evidence_id, + }, + result: jsonValueSchema.parse(computed), + confidence: "derived", + authority: "analyst-inference", + limitations: computed.limitations, + evidenceLinks: [left.evidence_id, right.evidence_id], + }); + const input = mapping(processComparison("unchanged"), "static_only"); + const result = correlateStaticAndRuntime({ + ...input, + static_comparisons: [comparison], + mappings: ["pseudocode", "assembly", "cfg"].map((dimension) => ({ + ...input.mappings[0], + static: { + comparison_evidence_id: comparison.evidence_id, + selector: { kind: "function", dimension }, + }, + })), + }); + expect(result.status).toBe("unknown"); + const caveats = new Map( + result.correlations.items.map((item) => [ + item.static.selector.kind === "function" + ? item.static.selector.dimension + : "artifact", + item.limitations.join(" "), + ]), + ); + expect(caveats.get("pseudocode")).toMatch(/generated-name normalization/u); + expect(caveats.get("pseudocode")).not.toMatch( + /Assembly is opaque|CFG addresses/u, + ); + expect(caveats.get("assembly")).toMatch(/Assembly is opaque/u); + expect(caveats.get("assembly")).not.toMatch(/CFG addresses/u); + expect(caveats.get("cfg")).toMatch(/CFG addresses/u); + expect(caveats.get("cfg")).not.toMatch( + /generated-name normalization|Assembly is opaque/u, + ); +}); + describe("static/runtime correlation", () => { it("accepts real runtime dimensions and rejects removed protocol dimensions", () => { const runtime = processComparison("unchanged"); @@ -154,6 +221,9 @@ describe("static/runtime correlation", () => { expect(result.limitations.join(" ")).toMatch( /does not establish causality/u, ); + expect(result.correlations.items[0]?.limitations.join(" ")).toMatch( + /generated-name normalization/u, + ); }); it("classifies an explicit expectation mismatch as contradiction", () => { diff --git a/src/domain/staticRuntimeCorrelation.ts b/src/domain/staticRuntimeCorrelation.ts index b00f5a3d5..0362569a7 100644 --- a/src/domain/staticRuntimeCorrelation.ts +++ b/src/domain/staticRuntimeCorrelation.ts @@ -6,7 +6,10 @@ import { z } from "zod"; import { artifactComparisonResultSchema } from "./artifactComparison.js"; import { uniqueSorted } from "./canonicalOrdering.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; -import { comparisonSourceEvidenceIds } from "./comparisonSourceEvidence.js"; +import { + comparisonSourceEvidenceIds, + assertComparisonFindingLinks, +} from "./comparisonSourceEvidence.js"; import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js"; import { functionComparisonResultSchema } from "./functionComparisonSchemas.js"; import { @@ -236,7 +239,10 @@ const correlate = ( mapping.runtime.comparison_evidence_id, "runtime comparison", ); - const selectedStatic = selectStatic(staticEvidence, mapping.static.selector); + const { limitations: selectedLimitations, ...selectedStatic } = selectStatic( + staticEvidence, + mapping.static.selector, + ); const process = processCaptureComparisonSchema.parse( runtimeEvidence.normalized_result, ); @@ -270,6 +276,7 @@ const correlate = ( ]), limitations: uniqueSorted([ ...staticEvidence.limitations, + ...selectedLimitations, ...runtimeEvidence.limitations, ...process.limitations, "This correlation tests an explicit hypothesis and does not establish causality.", @@ -281,7 +288,9 @@ const correlate = ( const selectStatic = ( evidence: Evidence, selector: z.infer, -): Pick => { +): Pick & { + readonly limitations: readonly string[]; +} => { if (selector.kind === "function") { if (evidence.operation !== "compare_functions") throw new TypeError( @@ -299,6 +308,7 @@ const selectStatic = ( return { status: match.status, conclusion_kind: match.conclusion_kind, + limitations: match.limitations, }; } if (evidence.operation !== "compare_artifacts") @@ -319,6 +329,7 @@ const selectStatic = ( const status = match.classification; return { status, + limitations: [], conclusion_kind: status === "contradiction" ? "contradiction" @@ -342,7 +353,7 @@ const parseStatic = (input: unknown): Evidence => { const result = artifactComparisonResultSchema.parse( evidence.normalized_result, ); - assertNestedLinks( + assertComparisonFindingLinks( evidence, result.changes.flatMap(({ evidence_links: links }) => links), ); @@ -350,7 +361,7 @@ const parseStatic = (input: unknown): Evidence => { const result = functionComparisonResultSchema.parse( evidence.normalized_result, ); - assertNestedLinks( + assertComparisonFindingLinks( evidence, result.dimensions.flatMap(({ evidence_links: links }) => links), ); @@ -358,17 +369,6 @@ const parseStatic = (input: unknown): Evidence => { return evidence; }; -const assertNestedLinks = ( - evidence: Evidence, - nestedLinks: readonly string[], -): void => { - const closure = new Set(evidence.evidence_links); - if (nestedLinks.some((evidenceId) => !closure.has(evidenceId))) - throw new TypeError( - "Comparison finding cites Evidence outside its top-level closure", - ); -}; - const parseRuntime = (input: unknown): Evidence => { const evidence = parseEvidence(input); if (evidence.operation !== "compare_process_captures") diff --git a/src/go/GoBinaryContainer.ts b/src/go/GoBinaryContainer.ts index 96b4cb146..11a3c3ada 100644 --- a/src/go/GoBinaryContainer.ts +++ b/src/go/GoBinaryContainer.ts @@ -12,7 +12,7 @@ export class GoBinaryFormatFailure extends Error { /** Resource boundary exceeded while decoding metadata or native container structure. */ export class GoBinaryResourceFailure extends Error { constructor( - readonly boundary: "build-info" | "structure", + readonly boundary: "build-info" | "structure" | "search", readonly maximum_bytes: number, message: string, ) { @@ -44,7 +44,7 @@ export interface GoImageSection { readonly compressed: boolean; } -/** Native container facts and its producer-defined build-info search area. */ +/** Native container facts and its ordered producer-defined build-info search ranges. */ export interface GoBinaryContainer { readonly format: "elf" | "pe" | "macho"; readonly architecture: string; @@ -53,7 +53,7 @@ export interface GoBinaryContainer { readonly mappings: readonly GoFileMapping[]; readonly zero_fills: readonly GoZeroFillMapping[]; readonly sections: readonly GoImageSection[]; - readonly search: GoFileMapping | null; + readonly search: readonly GoFileMapping[]; } /** Reader that checks every structural range before decoding numeric fields. */ diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts index e94a80ab6..c9e2366c8 100644 --- a/src/go/GoBinaryImage.ts +++ b/src/go/GoBinaryImage.ts @@ -25,6 +25,7 @@ const MAGIC = Buffer.from("ff20476f206275696c64696e663a", "hex"); const MODULE_START = Buffer.from("3077af0c9274080241e1c107e6d618e6", "hex"); const MODULE_END = Buffer.from("f932433186182072008242104116d8f2", "hex"); const MAX_BUILD_INFO_BYTES = 1024 * 1024; +const MAX_HEADER_SEARCH_BYTES = 256 * 1024 * 1024; /** Exact file span of one embedded string's contents, excluding its length/header. */ export interface GoStringLocation { @@ -57,10 +58,31 @@ export interface GoBinaryImage { export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { const container = readGoBinaryContainer(bytes); const { format, architecture, bits, byte_order } = container; - const search = container.search; - const header = findHeader(bytes, search); - if (header === null || search === null) + let selected: { search: GoFileMapping; header: number } | null = null; + const searched = new Set(); + let remainingSearchBytes = MAX_HEADER_SEARCH_BYTES; + for (const search of container.search) { + // Identical file spans and alignment inspect the same candidate bytes. + // Partially overlapping spans still consume a cumulative work budget. + const identity = `${search.offset}:${search.size}:${search.address % 16n}`; + if (searched.has(identity)) continue; + searched.add(identity); + if (search.size > remainingSearchBytes) + throw new GoBinaryResourceFailure( + "search", + MAX_HEADER_SEARCH_BYTES, + "Go build-info discovery exceeds the cumulative 256 MiB header-search budget", + ); + remainingSearchBytes -= search.size; + const header = findHeader(bytes, search); + if (header !== null) { + selected = { search, header }; + break; + } + } + if (selected === null) return { format, architecture, bits, byte_order, build_info: null }; + const { search, header } = selected; const verifyMapping = (size: number): void => { const address = search.address + BigInt(header - search.offset); if ( @@ -222,11 +244,8 @@ export const readGoBinaryContainer = (bytes: Buffer): GoBinaryContainer => { ); }; -const findHeader = ( - bytes: Buffer, - search: GoFileMapping | null, -): number | null => { - if (search === null || search.size === 0) return null; +const findHeader = (bytes: Buffer, search: GoFileMapping): number | null => { + if (search.size === 0) return null; const end = search.offset + search.size; const selected = bytes.subarray(search.offset, end); let offset = Number((16n - (search.address % 16n)) % 16n); diff --git a/src/go/GoElfImage.ts b/src/go/GoElfImage.ts index de4388114..1ba0c3058 100644 --- a/src/go/GoElfImage.ts +++ b/src/go/GoElfImage.ts @@ -103,7 +103,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { const mappings: GoFileMapping[] = []; const zeroFills: GoZeroFillMapping[] = []; const sections: GoImageSection[] = []; - let fallback: GoFileMapping | null = null; + const fallback: GoFileMapping[] = []; for (let index = 0; index < phCount; index++) { const offset = phStart + index * phWidth; if (reader.u32(offset) !== 1) continue; @@ -126,7 +126,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { if (mapping.size !== 0) mappings.push(mapping); if (memorySize > size) zeroFills.push({ address: address + size, size: memorySize - size }); - if (fallback === null && (flags & 3) === 2) fallback = mapping; + if (mapping.size !== 0 && (flags & 3) === 2) fallback.push(mapping); } let search: GoFileMapping | null = null; if (shCount !== 0) { @@ -226,7 +226,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { mappings, zero_fills: zeroFills, sections, - search: search ?? fallback, + search: search === null ? fallback : [search], }; }; diff --git a/src/go/GoMachoImage.ts b/src/go/GoMachoImage.ts index 5184a5829..cba13bf2d 100644 --- a/src/go/GoMachoImage.ts +++ b/src/go/GoMachoImage.ts @@ -193,7 +193,7 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { mappings, zero_fills: zeroFills, sections, - search: named[0] ?? fallback, + search: named.length > 0 ? named : fallback === null ? [] : [fallback], }; }; diff --git a/src/go/GoPeImage.ts b/src/go/GoPeImage.ts index 0787bc93d..304a70397 100644 --- a/src/go/GoPeImage.ts +++ b/src/go/GoPeImage.ts @@ -157,7 +157,7 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => { mappings, zero_fills: zeroFills, sections, - search, + search: search === null ? [] : [search], }; }; diff --git a/src/native/AppleDispatchCoverage.ts b/src/native/AppleDispatchCoverage.ts index a6c68ca51..44c6b9f36 100644 --- a/src/native/AppleDispatchCoverage.ts +++ b/src/native/AppleDispatchCoverage.ts @@ -27,6 +27,8 @@ export const pushDispatchCoverage = (input: { readonly examined: number; readonly categoriesExamined: number; readonly truncated: boolean; + readonly objcListScanTruncated: boolean; + readonly objcClassListScanTruncated: boolean; readonly fixups: PointerFixups; readonly vtables: FacetDecodeFacts; readonly swift: FacetDecodeFacts; @@ -38,6 +40,8 @@ export const pushDispatchCoverage = (input: { examined, categoriesExamined, truncated, + objcListScanTruncated, + objcClassListScanTruncated, fixups, vtables, swift, @@ -78,7 +82,7 @@ export const pushDispatchCoverage = (input: { const classReasons = [ ...failures, ...classRecords, - ...(truncated ? ["max_records_reached"] : []), + ...(truncated || objcListScanTruncated ? ["max_records_reached"] : []), ]; const categoryMethods = result.objc_dispatch_implementations.filter( ({ category }) => category !== undefined, @@ -99,20 +103,18 @@ export const pushDispatchCoverage = (input: { ({ decode }) => decode?.status === "decoded", ).length, }); + const relativePointerReasons = [ + ...(truncated || objcClassListScanTruncated ? ["max_records_reached"] : []), + ...(result.relative_pointers.some( + (item) => item.decode.status !== "decoded", + ) + ? ["relative_pointer_targets_unresolved"] + : []), + ]; result.coverage.push({ facet: "binary_relative_pointers", - status: - truncated || - result.relative_pointers.some((item) => item.decode.status !== "decoded") - ? "partial" - : "complete", - reason: truncated - ? "max_records_reached" - : result.relative_pointers.some( - (item) => item.decode.status !== "decoded", - ) - ? "relative_pointer_targets_unresolved" - : null, + status: relativePointerReasons.length > 0 ? "partial" : "complete", + reason: relativePointerReasons.join("; ") || null, examined: result.relative_pointers.length, decoded: result.relative_pointers.filter( (item) => item.decode.status === "decoded", @@ -126,6 +128,7 @@ export const pushDispatchCoverage = (input: { facet: "objc_properties_categories", status: truncated || + objcClassListScanTruncated || categoryFailures.length > 0 || result.objc_categories.some( ({ decode }) => decode.status !== "decoded", @@ -137,7 +140,9 @@ export const pushDispatchCoverage = (input: { [ ...categoryFailures, ...categoryMethodReasons, - ...(truncated ? ["max_records_reached"] : []), + ...(truncated || objcClassListScanTruncated + ? ["max_records_reached"] + : []), ].join("; ") || null, examined: categoriesExamined, decoded: result.objc_categories.filter( diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index 244a35aeb..f715c3c4e 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -115,6 +115,50 @@ const fixture = ( return bytes; }; const provenance = { path: "/fixture/app", sha256: "a".repeat(64) }; + +const partialSiblingsFixture = ( + options: { + readonly emptyMiddleClass?: boolean; + } = {}, +) => { + const bytes = Buffer.alloc(4096); + const imageBase = 0x100000000n; + bytes.writeUInt32LE(0xfeedfacf, 0); + bytes.writeUInt32LE(0x0100000c, 4); + bytes.writeUInt32LE(2, 12); + bytes.writeUInt32LE(1, 16); + bytes.writeUInt32LE(232, 20); + bytes.writeUInt32LE(0x19, 32); + bytes.writeUInt32LE(232, 36); + bytes.write("__DATA", 40); + bytes.writeBigUInt64LE(imageBase, 56); + bytes.writeBigUInt64LE(4096n, 64); + bytes.writeBigUInt64LE(4096n, 80); + bytes.writeUInt32LE(5, 92); + bytes.writeUInt32LE(2, 96); + bytes.write("__objc_classlist", 104); + bytes.writeBigUInt64LE(imageBase + 0x200n, 136); + bytes.writeBigUInt64LE(24n, 144); + bytes.write("__objc_protolist", 184); + bytes.writeBigUInt64LE(imageBase + 0x300n, 216); + bytes.writeBigUInt64LE(8n, 224); + for (const [slot, record, ro, name] of [ + [0x200, 0x400, 0x500, 0x600], + [0x208, 0x440, 0x540, 0x620], + [0x210, 0x480, 0x580, 0x640], + ] as const) { + bytes.writeBigUInt64LE(imageBase + BigInt(record), slot); + bytes.writeBigUInt64LE(imageBase + BigInt(ro), record + 32); + bytes.writeUInt32LE(2, ro); + bytes.writeUInt32LE(16, ro + 8); + bytes.writeBigUInt64LE(imageBase + BigInt(name), ro + 24); + } + bytes.write("KeptClass\0", 0x600); + if (!options.emptyMiddleClass) bytes.write("OtherClass\0", 0x620); + bytes.write("LaterClass\0", 0x640); + return bytes; +}; + describe("Apple dispatch pointer fixup metadata", () => { it("keeps decoded bindings and their coverage when an independent stream fails", () => { const bytes = fixture(); @@ -367,6 +411,23 @@ describe("Apple dispatch coverage ownership", () => { }); describe("malformed Apple dispatch metadata", () => { + it("retains valid class siblings when one class has an empty name", () => { + const result = decodeAppleDispatchMetadata( + partialSiblingsFixture({ emptyMiddleClass: true }), + 100, + provenance, + ); + expect(result.objc_classes.map(({ name }) => name)).toEqual([ + "KeptClass", + "LaterClass", + ]); + expect( + result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + ), + ).toMatchObject({ status: "partial", examined: 3 }); + }); + it("rejects malformed command and section boundaries", () => { const bytes = fixture(); bytes.writeUInt32LE(7, 36); @@ -530,3 +591,218 @@ describe("byte-swapped universal Apple dispatch metadata", () => { ); }); }); + +const unmappedProtocolSectionBase = 0x100000000n; +const unmappedProtocolImage = (bad: "protocol" | "class" | "neither") => { + const bytes = Buffer.alloc(4096); + bytes.writeUInt32LE(0xfeedfacf, 0); + bytes.writeUInt32LE(0x0100000c, 4); + bytes.writeUInt32LE(2, 12); + bytes.writeUInt32LE(1, 16); + bytes.writeUInt32LE(232, 20); + bytes.writeUInt32LE(0x19, 32); + bytes.writeUInt32LE(232, 36); + bytes.write("__DATA", 40); + bytes.writeBigUInt64LE(unmappedProtocolSectionBase, 56); + bytes.writeBigUInt64LE(4096n, 64); + bytes.writeBigUInt64LE(4096n, 80); + bytes.writeUInt32LE(5, 92); + bytes.writeUInt32LE(2, 96); + bytes.write("__objc_classlist", 104); + bytes.writeBigUInt64LE( + unmappedProtocolSectionBase + (bad === "class" ? 0x9000n : 0x200n), + 136, + ); + bytes.writeBigUInt64LE(8n, 144); + bytes.write("__objc_protolist", 184); + bytes.writeBigUInt64LE( + unmappedProtocolSectionBase + (bad === "protocol" ? 0x9000n : 0x300n), + 216, + ); + bytes.writeBigUInt64LE(8n, 224); + bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x400n, 0x200); + bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x500n, 0x420); + bytes.writeUInt32LE(2, 0x500); + bytes.writeUInt32LE(16, 0x508); + bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x600n, 0x518); + bytes.write("RetainedClass\0", 0x600); + return bytes; +}; +const unmappedProtocolClassCoverage = (result: { + coverage: { facet: string; status: string; reason: string | null }[]; +}) => + result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + ); + +describe("Apple dispatch unmapped protocol-list slot", () => { + it("returns the class when every list slot is mapped", () => { + const result = decodeAppleDispatchMetadata( + unmappedProtocolImage("neither"), + 100, + provenance, + ); + expect(result.objc_classes).toMatchObject([{ name: "RetainedClass" }]); + expect(unmappedProtocolClassCoverage(result)).toMatchObject({ + status: "complete", + }); + }); + + it("records partial coverage when the class-list slot is unmapped", () => { + const result = decodeAppleDispatchMetadata( + unmappedProtocolImage("class"), + 100, + provenance, + ); + expect(unmappedProtocolClassCoverage(result)).toMatchObject({ + status: "partial", + reason: expect.stringContaining("0x100009000"), + }); + }); + + it("retains the class and partial coverage when the protocol-list slot is unmapped", () => { + const result = decodeAppleDispatchMetadata( + unmappedProtocolImage("protocol"), + 100, + provenance, + ); + expect(result.objc_classes).toMatchObject([{ name: "RetainedClass" }]); + expect(unmappedProtocolClassCoverage(result)).toMatchObject({ + status: "partial", + reason: expect.stringContaining("0x100009000"), + }); + }); +}); + +describe("Apple dispatch list scan budget", () => { + it("bounds failed protocol-list slots by the record budget", () => { + // The section size is file-declared, so a crafted image can claim an + // arbitrarily large unmapped protocol list. Failed slots must spend the + // record budget like decoded ones, otherwise the loop runs unbounded. + const bytes = unmappedProtocolImage("protocol"); + bytes.writeBigUInt64LE(80000n, 224); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + const coverage = unmappedProtocolClassCoverage(result); + expect(coverage).toMatchObject({ status: "partial" }); + expect(coverage?.reason).toContain("max_records_reached"); + const failures = + coverage?.reason?.match(/Unmapped or ambiguous metadata pointer/g) ?? []; + expect(failures.length).toBeLessThanOrEqual(100); + }); + + it("keeps independent facets complete when only zero-pointer list scanning is capped", () => { + const bytes = unmappedProtocolImage("neither"); + bytes.writeBigUInt64LE(0n, 144); + bytes.writeBigUInt64LE(16n, 224); + const result = decodeAppleDispatchMetadata(bytes, 1, provenance); + expect(unmappedProtocolClassCoverage(result)).toMatchObject({ + status: "partial", + reason: expect.stringContaining("max_records_reached"), + }); + for (const facet of [ + "objc_properties_categories", + "binary_relative_pointers", + ]) + expect( + result.coverage.find((item) => item.facet === facet), + ).toMatchObject({ + status: "complete", + reason: null, + }); + }); + + it("marks class-dependent coverage partial when zero slots hide a later class", () => { + const bytes = unmappedProtocolImage("neither"); + bytes.writeBigUInt64LE(0n, 224); + bytes.writeBigUInt64LE(16n, 144); + bytes.writeBigUInt64LE(0n, 0x200); + bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x400n, 0x208); + const result = decodeAppleDispatchMetadata(bytes, 1, provenance); + expect(result.objc_classes).toEqual([]); + for (const facet of [ + "objc_class_method_ivar_metadata", + "objc_properties_categories", + "binary_relative_pointers", + ]) + expect( + result.coverage.find((item) => item.facet === facet), + ).toMatchObject({ + status: "partial", + reason: expect.stringContaining("max_records_reached"), + }); + for (const facet of [ + "swift_class_vtable_descriptors", + "swift_conformances_static_witness_slots", + ]) + expect( + result.coverage.find((item) => item.facet === facet), + ).toMatchObject({ status: "complete" }); + }); + + it("bounds failed class-list slots by the record budget", () => { + // Same file-declared-size hazard as the protocol list: a crafted image + // can declare an arbitrarily large unmapped class list. Failed class + // slots must spend the record budget like decoded ones, otherwise the + // loop runs unbounded (each failed read produced an unbounded failure + // string with no budget spend). + const bytes = unmappedProtocolImage("class"); + bytes.writeBigUInt64LE(80000n, 144); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + const coverage = unmappedProtocolClassCoverage(result); + expect(coverage).toMatchObject({ status: "partial" }); + expect(coverage?.reason).toContain("max_records_reached"); + const failures = + coverage?.reason?.match(/Unmapped or ambiguous metadata pointer/g) ?? []; + expect(failures.length).toBeLessThanOrEqual(100); + }); + + it("reports the real failure for a mapped-but-malformed class at the budget boundary", () => { + // The class-list slot itself is mapped, so readClass admits the record + // budget and only then throws on the unmapped class_ro pointer. The + // failure must be recorded as-is: it already spent one budget unit, so + // admitting again would break before the reason is appended and report + // only max_records_reached. + const bytes = unmappedProtocolImage("neither"); + bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x700n, 0x200); + bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x9000n, 0x720); + const result = decodeAppleDispatchMetadata(bytes, 1, provenance); + const coverage = unmappedProtocolClassCoverage(result); + expect(coverage).toMatchObject({ status: "partial" }); + expect(coverage?.reason).toContain( + "Unmapped or ambiguous metadata pointer 0x100009018", + ); + expect(coverage?.reason).not.toContain("max_records_reached"); + }); + + it("does not skip a valid class after a mapped-but-malformed class", () => { + // With two slots and a budget of two, the corrupt first slot must spend + // exactly one unit: admitting twice would exhaust the budget and skip + // the valid class behind it. + const bytes = unmappedProtocolImage("neither"); + bytes.writeBigUInt64LE(16n, 144); + bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x700n, 0x200); + bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x9000n, 0x720); + bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x400n, 0x208); + const result = decodeAppleDispatchMetadata(bytes, 2, provenance); + expect(result.objc_classes).toMatchObject([{ name: "RetainedClass" }]); + expect(unmappedProtocolClassCoverage(result)?.reason).toContain( + "Unmapped or ambiguous metadata pointer 0x100009018", + ); + }); + + it("reports the real failure for a mapped-but-truncated protocol slot", () => { + // The protocol slot is mapped, so protocolReader.record admits the + // budget itself before throwing on the truncated declaration. The loop + // must not admit a second time; the recorded reason stays the + // declaration failure, not max_records_reached. + const bytes = unmappedProtocolImage("neither"); + bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x700n, 0x300); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + const coverage = unmappedProtocolClassCoverage(result); + expect(coverage).toMatchObject({ status: "partial" }); + expect(coverage?.reason).toContain( + "Truncated Objective-C protocol declaration", + ); + expect(coverage?.reason).not.toContain("max_records_reached"); + }); +}); diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index 26ff8caaf..dfc5aec6a 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -105,15 +105,22 @@ export const decodeAppleDispatchMetadata = ( evidence, offset, }; - const { failures, categoryIssues, examined, categoriesExamined } = - decodeObjcDispatchFacets({ - bytes, - sections, - segments, - readers, - budget, - result, - }); + const { + failures, + categoryIssues, + examined, + categoriesExamined, + objcListScanTruncated, + objcClassListScanTruncated, + } = decodeObjcDispatchFacets({ + bytes, + sections, + segments, + readers, + budget, + maxListSlots: maxRecords, + result, + }); const swiftRelative = createSwiftRelativeReader({ bytes, readers, @@ -163,6 +170,8 @@ export const decodeAppleDispatchMetadata = ( categoryIssues, examined, categoriesExamined, + objcListScanTruncated, + objcClassListScanTruncated, truncated: budget.truncated, fixups, vtables, diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts index b52545840..955fb956e 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -1,3 +1,4 @@ +import { err, ok, type Result } from "../domain/result.js"; import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js"; import type { Segment, Section } from "./AppleMachoSelection.js"; import { boundClassName, decodeObjcCategories } from "./AppleObjcCategories.js"; @@ -67,14 +68,25 @@ export const decodeObjcDispatchFacets = (input: { segments: readonly Segment[]; readers: ObjcDispatchReaders; budget: DispatchRecordBudget; + maxListSlots: number; result: ObjcSwiftMetadata; }): { failures: string[]; categoryIssues: DecodeIssue[]; examined: number; categoriesExamined: number; + objcListScanTruncated: boolean; + objcClassListScanTruncated: boolean; } => { - const { bytes, sections, segments, readers: read, budget, result } = input; + const { + bytes, + sections, + segments, + readers: read, + budget, + maxListSlots, + result, + } = input; const failures: string[] = []; const categoryIssues: DecodeIssue[] = []; let examined = 0; @@ -190,6 +202,33 @@ export const decodeObjcDispatchFacets = (input: { result, failures, }); + // Each list scan has its own slot bound: zero and duplicate pointers do + // not produce records, but still consume pointer-reading work. The bound + // is per list so one list's slots cannot starve the other list's scan. + // Record decoders retain their existing admission ownership. + let objcListScanTruncated = false; + let objcClassListScanTruncated = false; + const createListSlotReader = () => { + let listSlotsExamined = 0; + return (field: bigint): Result => { + if (listSlotsExamined >= maxListSlots) { + objcListScanTruncated = true; + return err("exhausted"); + } + listSlotsExamined++; + try { + return ok(read.pointer(field)); + } catch (cause: unknown) { + const admitted = budget.admit(); + failures.push( + `${hex(field)}: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + return err(admitted ? "unmapped" : "exhausted"); + } + }; + }; + const readProtocolListSlot = createListSlotReader(); + const readClassListSlot = createListSlotReader(); for (const section of sections.filter( ({ name }) => name === "__objc_protolist", )) { @@ -200,7 +239,21 @@ export const decodeObjcDispatchFacets = (input: { budget.markTruncated(); break; } - protocolReader.record(read.pointer(section.address + BigInt(index * 8))); + const field = section.address + BigInt(index * 8); + const pointer = readProtocolListSlot(field); + if (!pointer.ok) { + if (pointer.error === "exhausted") break; + continue; + } + try { + protocolReader.record(pointer.value); + } catch (cause) { + // record() admits the slot itself before decoding, so admitting + // again here would count one protocol twice. + failures.push( + `${hex(field)}: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } } } const visited = new Set(); @@ -209,6 +262,10 @@ export const decodeObjcDispatchFacets = (input: { visited.add(hex(address)); const ro = read.pointer(address + 32n) & ~7n; const name = read.string(read.pointer(ro + 24n)); + if (name.length === 0) + throw new TypeError( + `Objective-C class at ${hex(address)} has an empty name`, + ); const root = (read.u32(ro) & 2) !== 0; const superclass = resolveSuperclass(read, address, name, failures); if (superclass.name === null && superclass.address === null && !root) @@ -320,11 +377,24 @@ export const decodeObjcDispatchFacets = (input: { budget.markTruncated(); break; } - examined++; const field = section.address + BigInt(index * 8); + const pointer = readClassListSlot(field); + if (pointer.ok || pointer.error === "unmapped") examined++; + if (!pointer.ok) { + if (pointer.error === "exhausted") { + objcClassListScanTruncated = true; + break; + } + continue; + } try { - readClass(read.pointer(field)); + readClass(pointer.value); } catch (cause) { + // The slot was already admitted inside readClass before anything + // could throw (its admit runs before the first pointer read), so + // admitting again here would count one class twice: at the budget + // boundary it would break before appending the failure reason, and + // with larger limits malformed classes would starve valid ones. failures.push( `${hex(field)}: ${cause instanceof Error ? cause.message : String(cause)}`, ); @@ -341,5 +411,12 @@ export const decodeObjcDispatchFacets = (input: { protocols: (list) => protocolReader.list(list), localClassName: (address) => localClassName(read, address), }); - return { failures, categoryIssues, examined, categoriesExamined }; + return { + failures, + categoryIssues, + examined, + categoriesExamined, + objcListScanTruncated, + objcClassListScanTruncated, + }; }; diff --git a/src/native/AppleSwiftVtables.test.ts b/src/native/AppleSwiftVtables.test.ts index fb1615820..fe79b955e 100644 --- a/src/native/AppleSwiftVtables.test.ts +++ b/src/native/AppleSwiftVtables.test.ts @@ -60,6 +60,8 @@ describe("Swift vtable coverage facts: unresolved Swift dispatch slots", () => { examined: 0, categoriesExamined: 0, truncated: false, + objcListScanTruncated: false, + objcClassListScanTruncated: false, fixups: { kind: "none", formats: [], @@ -165,6 +167,8 @@ describe("Swift vtable coverage facts: unsupported pointer fixup coverage", () = examined: 0, categoriesExamined: 0, truncated: false, + objcListScanTruncated: false, + objcClassListScanTruncated: false, fixups: { kind: "chained", formats: ["format-99"], diff --git a/src/native/parsers/demangle.test.ts b/src/native/parsers/demangle.test.ts new file mode 100644 index 000000000..5b15229bd --- /dev/null +++ b/src/native/parsers/demangle.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { parseDemangledSymbols } from "./demangle.js"; +import { swiftSymbolsSchema } from "../../contracts/native/nativeToolContracts.js"; + +describe("parseDemangledSymbols", () => { + it("returns an empty array when given an empty input list", () => { + expect(swiftSymbolsSchema.safeParse([]).success).toBe(true); + expect(parseDemangledSymbols([], "")).toEqual([]); + expect(parseDemangledSymbols([], "\n")).toEqual([]); + }); + + it("rejects demangler output when the input batch is empty", () => { + expect(() => parseDemangledSymbols([], "unexpected\n")).toThrow( + "swift-demangle emitted output for an empty input", + ); + }); + + it("parses demangled symbols successfully", () => { + const inputs = ["$s4main4testyyF"]; + const output = "main.test() -> ()\n"; + expect(parseDemangledSymbols(inputs, output)).toEqual([ + { + input: "$s4main4testyyF", + output: "main.test() -> ()", + status: "demangled", + }, + ]); + }); + + it("identifies unchanged non-Swift symbols", () => { + const inputs = ["_printf"]; + const output = "_printf\n"; + expect(parseDemangledSymbols(inputs, output)).toEqual([ + { + input: "_printf", + output: "_printf", + status: "unchanged", + }, + ]); + }); + + it("identifies invalid Swift symbols that fail demangling", () => { + const inputs = ["$sInvalidMangle", "_$sAlsoInvalid"]; + const output = "$sInvalidMangle\n_$sAlsoInvalid\n"; + expect(parseDemangledSymbols(inputs, output)).toEqual([ + { + input: "$sInvalidMangle", + output: "$sInvalidMangle", + status: "invalid", + }, + { + input: "_$sAlsoInvalid", + output: "_$sAlsoInvalid", + status: "invalid", + }, + ]); + }); + + it("parses mixed batches preserving ordering", () => { + const inputs = ["$s4main4testyyF", "_printf", "$sInvalidMangle"]; + const output = "main.test() -> ()\n_printf\n$sInvalidMangle\n"; + expect(parseDemangledSymbols(inputs, output)).toEqual([ + { + input: "$s4main4testyyF", + output: "main.test() -> ()", + status: "demangled", + }, + { + input: "_printf", + output: "_printf", + status: "unchanged", + }, + { + input: "$sInvalidMangle", + output: "$sInvalidMangle", + status: "invalid", + }, + ]); + }); + + it("throws TypeError when output count does not match input count", () => { + expect(() => + parseDemangledSymbols(["sym1", "sym2"], "demangled1\n"), + ).toThrow(TypeError); + expect(() => + parseDemangledSymbols(["sym1"], "demangled1\ndemangled2\n"), + ).toThrow("swift-demangle output count does not match input"); + }); +}); diff --git a/src/native/parsers/demangle.ts b/src/native/parsers/demangle.ts index ba960be56..afcb30b62 100644 --- a/src/native/parsers/demangle.ts +++ b/src/native/parsers/demangle.ts @@ -6,6 +6,8 @@ const resultSchema = z.object({ status: z.enum(["demangled", "unchanged", "invalid"]), }); +export type DemangledSymbol = z.infer; + /** * Preserve input ordering while parsing `swift-demangle --compact` lines. * The demangler ends each record with LF, so a carriage return belongs to the @@ -14,7 +16,11 @@ const resultSchema = z.object({ export const parseDemangledSymbols = ( inputs: readonly string[], output: string, -) => { +): DemangledSymbol[] => { + if (inputs.length === 0) { + if (output === "" || output === "\n") return []; + throw new TypeError("swift-demangle emitted output for an empty input"); + } const lines = output.replace(/\n$/u, "").split("\n"); if (lines.length !== inputs.length) throw new TypeError("swift-demangle output count does not match input"); diff --git a/src/server/registerArtifactTools.ts b/src/server/registerArtifactTools.ts index 1217664e6..d2df1da9e 100644 --- a/src/server/registerArtifactTools.ts +++ b/src/server/registerArtifactTools.ts @@ -1,39 +1,36 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; -import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { ARTIFACT_TOOL_CONTRACTS, artifactInspectionInputSchema, } from "../contracts/artifactToolContracts.js"; -import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; -import { - requireAnalysisViewRetention, - summarizeRetainedAnalysis, -} from "../application/analysisView/AnalysisViewService.js"; import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js"; -import { ok } from "../domain/result.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import type { Logger } from "pino"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; import { artifactInspectionResultSchema } from "../domain/artifactInspection.js"; +import { + retainedSummaryDelivery, + withoutDeliveryDetail, + type EvidenceToolFamilyOptions, +} from "./retainedSummaryDelivery.js"; + +/** Operations whose MCP delivery may be a retained summary view. */ +const SUMMARY_DELIVERY: ReadonlySet = new Set([ + "inspect_artifact", + "decode_interface_builder", +]); +/** inspect_artifact keeps an explicit detail in its Evidence parameters. */ +const decodeWithoutDelivery = withoutDeliveryDetail( + new Set(["decode_interface_builder"]), +); /** Register deterministic artifact inventory and safe extraction operations. */ export const registerArtifactTools = ( server: EvidenceMcpServer, - options: { - readonly logger: Logger; - readonly activeTarget: (() => BinaryTarget | undefined) | undefined; - readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; - readonly evidenceById?: EvidenceLookup | undefined; - readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis; - }, + options: EvidenceToolFamilyOptions, ): void => { - const retention = { - recordEvidence: options.recordEvidence, - evidenceById: options.evidenceById, - }; registerEvidenceTools(server, ARTIFACT_TOOL_CONTRACTS, { ...options, + ...retainedSummaryDelivery(SUMMARY_DELIVERY, options), parametersForExecution: (operation, parameters) => { if (operation === "inspect_artifact") { const { detail: _detail, ...execution } = @@ -42,16 +39,9 @@ export const registerArtifactTools = ( } return operation === "extract_artifact" ? { ...parameters, output_root: createArtifactExtractionDestination() } - : parameters; + : decodeWithoutDelivery(operation, parameters); }, - preflight: (operation, parameters) => - operation === "inspect_artifact" && parameters.detail === "summary" - ? requireAnalysisViewRetention(operation, retention) - : ok(null), - projectEvidence: (operation, parameters, evidence) => - operation === "inspect_artifact" && parameters.detail === "summary" - ? summarizeRetainedAnalysis(evidence, retention) - : ok(evidence), + parametersForEvidence: decodeWithoutDelivery, sourceEvidence: (operation, result) => operation === "inspect_artifact" ? artifactInspectionResultSchema diff --git a/src/server/registerManagedTools.ts b/src/server/registerManagedTools.ts index 5ff513d67..2f00d16ce 100644 --- a/src/server/registerManagedTools.ts +++ b/src/server/registerManagedTools.ts @@ -1,6 +1,4 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; -import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; -import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import { requireAnalysisViewRetention, summarizeRetainedAnalysis, @@ -8,24 +6,17 @@ import { import type { AnalysisOperationPort } from "../application/AnalysisProvider.js"; import { MANAGED_TOOL_CONTRACTS } from "../contracts/managed/managedToolContracts.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { err, ok } from "../domain/result.js"; -import type { Logger } from "pino"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; +import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js"; import { runManagedProviderExecution } from "../composition/directAnalysis.js"; import { isManagedToolName } from "../contracts/managed/managedToolContracts.js"; /** Register execution-free managed PE/CLI inspection. */ export const registerManagedTools = ( server: EvidenceMcpServer, - options: { - readonly logger: Logger; - readonly activeTarget: (() => BinaryTarget | undefined) | undefined; - readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; - readonly evidenceById?: EvidenceLookup | undefined; - readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis; - }, + options: EvidenceToolFamilyOptions, ): void => { let selectedManagedPath: string | undefined; const analysisForAdmission = ( diff --git a/src/server/registerNativeTools.ts b/src/server/registerNativeTools.ts index 036389d16..62668daa1 100644 --- a/src/server/registerNativeTools.ts +++ b/src/server/registerNativeTools.ts @@ -1,54 +1,25 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; -import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js"; -import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; -import { - requireAnalysisViewRetention, - summarizeRetainedAnalysis, -} from "../application/analysisView/AnalysisViewService.js"; -import type { JsonValue } from "../domain/jsonValue.js"; -import { ok } from "../domain/result.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import type { Logger } from "pino"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; +import { + retainedSummaryDelivery, + withoutDeliveryDetail, + type EvidenceToolFamilyOptions, +} from "./retainedSummaryDelivery.js"; -/** Delivery detail selects a response shape; it never reaches the tools or Evidence identity. */ -const withoutDelivery = ( - operation: string, - parameters: Readonly>, -): Readonly> => { - if (operation !== "inspect_macho") return parameters; - const { detail: _detail, ...selected } = parameters; - return selected; -}; +const SUMMARY_DELIVERY: ReadonlySet = new Set(["inspect_macho"]); /** Register macOS native contracts; Mach-O inspection can deliver a retained summary. */ export const registerNativeTools = ( server: EvidenceMcpServer, - options: { - readonly logger: Logger; - readonly activeTarget: (() => BinaryTarget | undefined) | undefined; - readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; - readonly evidenceById?: EvidenceLookup | undefined; - readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis; - }, + options: EvidenceToolFamilyOptions, ): void => { - const retention = { - recordEvidence: options.recordEvidence, - evidenceById: options.evidenceById, - }; + const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY); registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, { ...options, + ...retainedSummaryDelivery(SUMMARY_DELIVERY, options), parametersForExecution: withoutDelivery, parametersForEvidence: withoutDelivery, - preflight: (operation, parameters) => - operation === "inspect_macho" && parameters.detail === "summary" - ? requireAnalysisViewRetention(operation, retention) - : ok(null), - projectEvidence: (operation, parameters, evidence) => - operation === "inspect_macho" && parameters.detail === "summary" - ? summarizeRetainedAnalysis(evidence, retention) - : ok(evidence), }); }; diff --git a/src/server/retainedSummaryDelivery.ts b/src/server/retainedSummaryDelivery.ts new file mode 100644 index 000000000..0bc703a7c --- /dev/null +++ b/src/server/retainedSummaryDelivery.ts @@ -0,0 +1,62 @@ +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; +import { + requireAnalysisViewRetention, + summarizeRetainedAnalysis, +} from "../application/analysisView/AnalysisViewService.js"; +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { Evidence } from "../domain/evidence.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { ok } from "../domain/result.js"; +import type { Logger } from "pino"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; + +/** Registration inputs shared by provider-backed Evidence tool families. */ +export interface EvidenceToolFamilyOptions { + readonly logger: Logger; + readonly activeTarget: (() => BinaryTarget | undefined) | undefined; + readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; + readonly evidenceById?: EvidenceLookup | undefined; + readonly withAdmittedAnalysis: WithAdmittedAnalysis; +} + +type Parameters = Readonly>; + +/** + * Summary delivery for the named operations: refuse it before analysis when + * this server retains no Evidence, then return the retained summary view. + */ +export const retainedSummaryDelivery = ( + operations: ReadonlySet, + options: Pick, +) => { + const retention = { + recordEvidence: options.recordEvidence, + evidenceById: options.evidenceById, + }; + const selected = (operation: string, parameters: Parameters): boolean => + operations.has(operation) && parameters.detail === "summary"; + return { + preflight: (operation: string, parameters: Parameters) => + selected(operation, parameters) + ? requireAnalysisViewRetention(operation, retention) + : ok(null), + projectEvidence: ( + operation: string, + parameters: Parameters, + evidence: Evidence, + ) => + selected(operation, parameters) + ? summarizeRetainedAnalysis(evidence, retention) + : ok(evidence), + }; +}; + +/** Delivery detail selects a response shape; it never reaches tools or Evidence identity. */ +export const withoutDeliveryDetail = + (operations: ReadonlySet) => + (operation: string, parameters: Parameters): Parameters => { + if (!operations.has(operation)) return parameters; + const { detail: _detail, ...selected } = parameters; + return selected; + }; diff --git a/tests/boundary/go/goBinaryImage.test.ts b/tests/boundary/go/goBinaryImage.test.ts index c8069be98..9bc91c185 100644 --- a/tests/boundary/go/goBinaryImage.test.ts +++ b/tests/boundary/go/goBinaryImage.test.ts @@ -5,13 +5,113 @@ import { fatImage, machoImage, } from "../../../src/artifacts/apple/MachoImage.fixture.js"; -import { readGoBinaryImage } from "../../../src/go/GoBinaryImage.js"; +import { + readGoBinaryImage, + GoBinaryResourceFailure, +} from "../../../src/go/GoBinaryImage.js"; import { readGoMachoImage } from "../../../src/go/GoMachoImage.js"; import { createGoBinaryFixture, + createGoSplitElfFixture, + createGoOverlappingElfFixture, GO_MODULE_TEXT, } from "../../fixtures/go/image.js"; +it("bounds cumulative header searches across overlapping ELF file spans", () => { + const bytes = createGoOverlappingElfFixture(); + expect(() => readGoBinaryImage(bytes)).toThrow(GoBinaryResourceFailure); + expect(() => readGoBinaryImage(bytes)).toThrow(/256 MiB.*search/); +}); + +it("does not charge duplicate ELF search spans repeatedly", () => { + expect( + readGoBinaryImage(createGoOverlappingElfFixture(true)).build_info, + ).toBeNull(); +}); + +it("accepts a search exactly at the cumulative byte limit", () => { + const bytes = createGoOverlappingElfFixture(); + bytes.writeUInt16LE(256, 56); + expect(readGoBinaryImage(bytes).build_info).toBeNull(); +}); + +it("keeps different virtual alignments distinct when file spans match", () => { + const fixture = createGoBinaryFixture({ sectionless: true }); + fixture.bytes.copy(fixture.bytes, 184, 128, 184); + fixture.bytes.writeUInt16LE(2, 56); + fixture.bytes.writeBigUInt64LE(0x200001n, 128 + 16); + expect(readGoBinaryImage(fixture.bytes).build_info?.header_offset).toBe( + fixture.headerOffset, + ); +}); + +it.each([ + [32, "little", "inline"], + [32, "little", "pointer"], + [32, "big", "inline"], + [32, "big", "pointer"], + [64, "little", "inline"], + [64, "little", "pointer"], + [64, "big", "inline"], + [64, "big", "pointer"], +] as const)( + "retains later-load ELF metadata without sections (%d %s %s)", + (bits, byteOrder, encoding) => { + const original = createGoSplitElfFixture({ bits, byteOrder, encoding }); + const sectionless = createGoSplitElfFixture({ + bits, + byteOrder, + encoding, + sectionless: true, + }); + const expected = readGoBinaryImage(original.bytes).build_info; + expect(expected).not.toBeNull(); + expect(readGoBinaryImage(sectionless.bytes).build_info).toEqual(expected); + }, +); + +it("continues past an empty file-backed ELF data load", () => { + const fixture = createGoSplitElfFixture({ sectionless: true }); + const expected = readGoBinaryImage(fixture.bytes).build_info; + fixture.bytes.writeBigUInt64LE(0n, 128 + 32); + expect(readGoBinaryImage(fixture.bytes).build_info).toEqual(expected); +}); + +it.each([4, 5, 7])( + "does not search a later non-data ELF load with flags %d", + (flags) => { + const fixture = createGoSplitElfFixture({ sectionless: true }); + fixture.bytes.writeUInt32LE(flags, 184 + 4); + expect(readGoBinaryImage(fixture.bytes).build_info).toBeNull(); + }, +); + +it("prefers the named ELF section to an earlier writable-load marker", () => { + const fixture = createGoSplitElfFixture(); + const expected = readGoBinaryImage(fixture.bytes).build_info; + fixture.bytes.copy( + fixture.bytes, + 512, + fixture.headerOffset, + fixture.headerOffset + 32, + ); + expect(readGoBinaryImage(fixture.bytes).build_info).toEqual(expected); +}); + +it("does not skip malformed earlier metadata to report a later valid record", () => { + const fixture = createGoSplitElfFixture({ sectionless: true }); + fixture.bytes.copy( + fixture.bytes, + 512, + fixture.headerOffset, + fixture.headerOffset + 32, + ); + fixture.bytes[512 + 15] = 0xff; + expect(() => readGoBinaryImage(fixture.bytes)).toThrow( + /unsupported format bit/, + ); +}); + it.each([ ["elf", 32, "little"], ["elf", 32, "big"], diff --git a/tests/boundary/mcp/interfaceBuilderSummaryMcp.test.ts b/tests/boundary/mcp/interfaceBuilderSummaryMcp.test.ts new file mode 100644 index 000000000..b62fcdf53 --- /dev/null +++ b/tests/boundary/mcp/interfaceBuilderSummaryMcp.test.ts @@ -0,0 +1,152 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; +import { openProductionTarget } from "../../support/mcp/productionSession.js"; + +const evidenceOf = (schema: Schema) => + z.object({ + evidence_id: z.string(), + parameters: z.record(z.string(), z.unknown()), + normalized_result: schema, + }); +const summarySchema = z.object({ + kind: z.literal("summary"), + parent_evidence_id: z.string(), + summary: z.object({ + documents: z.array(z.unknown()), + available_counts: z.object({ + nodes: z.number(), + edges: z.number(), + coverage: z.number(), + }), + }), +}); +const pageSchema = z.object({ + kind: z.literal("interface-builder"), + items: z.array(z.unknown()), + coverage: z.object({ next_offset: z.number().nullable() }), +}); + +/** A two-document app; each nib is a real rooted UIKit outlet archive. */ +const fixtureApp = async (): Promise => { + const app = join( + await createTestTempDirectory("rea-ib-summary-mcp-"), + "Fixture.app", + ); + const contents = join(app, "Contents"); + const resources = join(contents, "Resources"); + await mkdir(resources, { recursive: true }); + await mkdir(join(contents, "MacOS")); + await writeFile( + join(contents, "MacOS", "Fixture"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableFixture", + ); + const nib = await readFile( + join(process.cwd(), "tests/fixtures/interface-builder/UIKitOutlet.xml"), + ); + await writeFile(join(resources, "Main.nib"), nib); + await writeFile(join(resources, "Second.nib"), nib); + return app; +}; + +mcpTest( + "retains complete Interface Builder Evidence and pages one document's rows", + async ({ mcp, onTestFinished }) => { + const app = await fixtureApp(); + const client = await openProductionTarget(mcp, onTestFinished, app); + + // Summary first: its own retained parent must serve every later page. + const summarized = await client.callTool({ + name: "decode_interface_builder", + arguments: { detail: "summary" }, + }); + expect(summarized.isError, JSON.stringify(summarized.content)).not.toBe( + true, + ); + const summary = evidenceOf(summarySchema).parse( + summarized.structuredContent, + ).normalized_result; + const source = { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }; + const readAll = async (view: Record) => { + const rows: unknown[] = []; + let offset: number | null = 0; + while (offset !== null) { + const paged = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source, + view: { kind: "interface-builder", ...view, offset, limit: 2 }, + }, + }); + expect(paged.isError, JSON.stringify(paged.content)).not.toBe(true); + const page = evidenceOf(pageSchema).parse( + paged.structuredContent, + ).normalized_result; + rows.push(...page.items); + offset = page.coverage.next_offset; + } + return rows; + }; + const main = "Contents/Resources/Main.nib"; + const edges = await readAll({ collection: "edges" }); + const mainNodes = await readAll({ collection: "nodes", document: main }); + + // Only then compare with a complete call made for reference. + const complete = await client.callTool({ + name: "decode_interface_builder", + arguments: {}, + }); + expect(complete.isError, JSON.stringify(complete.content)).not.toBe(true); + const full = evidenceOf(interfaceBuilderAnalysisSchema).parse( + complete.structuredContent, + ); + expect(full.parameters).not.toHaveProperty("detail"); + const report = full.normalized_result; + expect(report.documents).toHaveLength(2); + // Delivery detail never reaches the decoder or the source Evidence ID. + expect(summary.parent_evidence_id).toBe(full.evidence_id); + expect(summary.summary.documents).toEqual(report.documents); + expect(summary.summary.available_counts).toEqual({ + nodes: report.graph.nodes.length, + edges: report.graph.edges.length, + coverage: report.graph.coverage.length, + }); + expect(edges).toEqual(report.graph.edges); + const expectedMainNodes = report.graph.nodes.filter(({ evidence }) => + evidence.some(({ artifact_path }) => artifact_path === main), + ); + expect(expectedMainNodes.length).toBeGreaterThan(0); + expect(expectedMainNodes.length).toBeLessThan(report.graph.nodes.length); + expect(mainNodes).toEqual(expectedMainNodes); + + const unknownDocument = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source, + view: { + kind: "interface-builder", + collection: "nodes", + document: "Contents/Resources/Missing.nib", + }, + }, + }); + expect(parseMcpToolError(unknownDocument)).toMatchObject({ + error: { code: "invalid_request" }, + }); + }, +); diff --git a/tests/boundary/mcp/keyedArchiveNumberMetadataMcp.test.ts b/tests/boundary/mcp/keyedArchiveNumberMetadataMcp.test.ts index 4a40a3064..485c058dd 100644 --- a/tests/boundary/mcp/keyedArchiveNumberMetadataMcp.test.ts +++ b/tests/boundary/mcp/keyedArchiveNumberMetadataMcp.test.ts @@ -1,40 +1,24 @@ -import { silentLogger } from "../../../src/logger.js"; import { expect } from "vitest"; import { z } from "zod"; -import { createBinarySession } from "../../../src/composition/binary.js"; -import { parseConfig } from "../../../src/config/parseConfig.js"; import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; -import { createServer } from "../../../src/server/createServer.js"; import { archiveNumberMetadataCases, expectKeyedArchiveNumberMetadata, keyedArchiveNumberMetadataFixture, } from "../../fixtures/keyedArchiveNumberMetadata.js"; import { mcpTest } from "../../support/mcp/mcpFixture.js"; +import { openProductionTarget } from "../../support/mcp/productionSession.js"; mcpTest.for(archiveNumberMetadataCases)( "MCP preserves numeric meaning for $name", async (item, { mcp, onTestFinished }) => { const fixture = await keyedArchiveNumberMetadataFixture(item); - const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" }); - if (!configured.ok) throw configured.error; - const session = createBinarySession( - configured.value, - silentLogger, - process.env, + const client = await openProductionTarget( + mcp, + onTestFinished, + fixture.path, ); - onTestFinished(async () => { - await session.close(); - }); - const client = await mcp.connect( - createServer({ kind: "session", session }), - ); - const opened = await client.callTool({ - name: "open_binary", - arguments: { path: fixture.path }, - }); - expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); const result = await client.callTool({ name: "inspect_keyed_archive", arguments: {}, diff --git a/tests/boundary/mcp/keyedArchivePrototypeKeyMcp.test.ts b/tests/boundary/mcp/keyedArchivePrototypeKeyMcp.test.ts index b7803f0bd..17d536003 100644 --- a/tests/boundary/mcp/keyedArchivePrototypeKeyMcp.test.ts +++ b/tests/boundary/mcp/keyedArchivePrototypeKeyMcp.test.ts @@ -1,39 +1,23 @@ import { expect } from "vitest"; import { z } from "zod"; -import { createBinarySession } from "../../../src/composition/binary.js"; -import { parseConfig } from "../../../src/config/parseConfig.js"; import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; -import { silentLogger } from "../../../src/logger.js"; -import { createServer } from "../../../src/server/createServer.js"; import { expectKeyedArchivePrototypeKeys, keyedArchivePrototypeKeyFixture, } from "../../fixtures/keyedArchivePrototypeKeys.js"; import { mcpTest } from "../../support/mcp/mcpFixture.js"; +import { openProductionTarget } from "../../support/mcp/productionSession.js"; mcpTest( "MCP retains ordinary evidence with formatted XML prototype keys", async ({ mcp, onTestFinished }) => { const fixture = await keyedArchivePrototypeKeyFixture(); - const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" }); - if (!configured.ok) throw configured.error; - const session = createBinarySession( - configured.value, - silentLogger, - process.env, + const client = await openProductionTarget( + mcp, + onTestFinished, + fixture.path, ); - onTestFinished(async () => { - await session.close(); - }); - const client = await mcp.connect( - createServer({ kind: "session", session }), - ); - const opened = await client.callTool({ - name: "open_binary", - arguments: { path: fixture.path }, - }); - expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); const result = await client.callTool({ name: "inspect_keyed_archive", arguments: {}, diff --git a/tests/boundary/mcp/keyedArchiveXmlExtensionMcp.test.ts b/tests/boundary/mcp/keyedArchiveXmlExtensionMcp.test.ts new file mode 100644 index 000000000..3b5e8bb5e --- /dev/null +++ b/tests/boundary/mcp/keyedArchiveXmlExtensionMcp.test.ts @@ -0,0 +1,86 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { build } from "plist"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { createBinarySession } from "../../../src/composition/binary.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; +import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +mcpTest( + "MCP inspects an XML keyed archive with a non-plist suffix", + async ({ mcp, onTestFinished }) => { + const root = await createTestTempDirectory("rea-keyed-xml-extension-mcp-"); + const path = join(root, "Model.archive"); + await writeFile( + path, + build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "leaf"], + $top: { root: { UID: 1 } }, + }).replace("?>", `?>${" ".repeat(8192)}`), + ); + const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" }); + if (!configured.ok) throw configured.error; + const session = createBinarySession( + configured.value, + silentLogger, + process.env, + ); + onTestFinished(async () => { + await session.close(); + }); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path }, + }); + expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); + const inventoryReply = await client.callTool({ + name: "inspect_artifact", + arguments: {}, + }); + expect( + inventoryReply.isError, + JSON.stringify(inventoryReply.content), + ).not.toBe(true); + const inspectionEvidence = parseEvidence(inventoryReply.structuredContent); + const inspection = artifactInspectionResultSchema.parse( + inspectionEvidence.normalized_result, + ); + const inventory = artifactInventoryResultSchema.parse( + inspection.substeps[0]?.evidence.normalized_result, + ); + expect(inventory.manifest.root_format).toBe("plist"); + expect(inventory.occurrences[0]?.artifact_format).toBe("plist"); + + const result = await client.callTool({ + name: "inspect_keyed_archive", + arguments: {}, + }); + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + expect( + z + .object({ normalized_result: keyedArchiveResultSchema }) + .parse(result.structuredContent).normalized_result, + ).toMatchObject({ + archive_path: "Model.archive", + archive_format: "xml-plist", + objects: expect.arrayContaining([ + expect.objectContaining({ value: "leaf" }), + ]), + }); + }, +); diff --git a/tests/boundary/mcp/nativeDemangleMcp.test.ts b/tests/boundary/mcp/nativeDemangleMcp.test.ts index 71f7732a5..8e66a27fa 100644 --- a/tests/boundary/mcp/nativeDemangleMcp.test.ts +++ b/tests/boundary/mcp/nativeDemangleMcp.test.ts @@ -1,15 +1,12 @@ -import { silentLogger } from "../../../src/logger.js"; import { copyFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect } from "vitest"; import { z } from "zod"; -import { createBinarySession } from "../../../src/composition/binary.js"; -import { parseConfig } from "../../../src/config/parseConfig.js"; -import { createServer } from "../../../src/server/createServer.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { mcpTest } from "../../support/mcp/mcpFixture.js"; +import { openProductionTarget } from "../../support/mcp/productionSession.js"; const demangledSchema = z.object({ normalized_result: z.object({ @@ -30,24 +27,7 @@ describe.skipIf(process.platform !== "darwin")( "tool", ); await copyFile("/usr/bin/true", target); - const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" }); - if (!configured.ok) throw configured.error; - const session = createBinarySession( - configured.value, - silentLogger, - process.env, - ); - onTestFinished(async () => { - await session.close(); - }); - const client = await mcp.connect( - createServer({ kind: "session", session }), - ); - const opened = await client.callTool({ - name: "open_binary", - arguments: { path: target }, - }); - expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); + const client = await openProductionTarget(mcp, onTestFinished, target); const called = await client.callTool({ name: "demangle_swift", diff --git a/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts b/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts index e492263fa..1a2f29902 100644 --- a/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts +++ b/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts @@ -1,12 +1,9 @@ -import { silentLogger } from "../../../src/logger.js"; import { copyFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect } from "vitest"; import { z } from "zod"; -import { createBinarySession } from "../../../src/composition/binary.js"; -import { parseConfig } from "../../../src/config/parseConfig.js"; import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js"; import { createServer } from "../../../src/server/createServer.js"; import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; @@ -16,6 +13,7 @@ import { ok } from "../../../src/domain/result.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { mcpTest } from "../../support/mcp/mcpFixture.js"; +import { openProductionTarget } from "../../support/mcp/productionSession.js"; const evidenceOf = (schema: Schema) => z.object({ @@ -54,24 +52,7 @@ describe.skipIf(process.platform !== "darwin")( "ls", ); await copyFile("/bin/ls", target); - const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" }); - if (!configured.ok) throw configured.error; - const session = createBinarySession( - configured.value, - silentLogger, - process.env, - ); - onTestFinished(async () => { - await session.close(); - }); - const client = await mcp.connect( - createServer({ kind: "session", session }), - ); - const opened = await client.callTool({ - name: "open_binary", - arguments: { path: target }, - }); - expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); + const client = await openProductionTarget(mcp, onTestFinished, target); const complete = await client.callTool({ name: "inspect_macho", diff --git a/tests/boundary/providers/native/artifactProvider.directoryProbes.test.ts b/tests/boundary/providers/native/artifactProvider.directoryProbes.test.ts new file mode 100644 index 000000000..fc4fc4519 --- /dev/null +++ b/tests/boundary/providers/native/artifactProvider.directoryProbes.test.ts @@ -0,0 +1,138 @@ +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { createPackageWithOptions } from "@electron/asar"; +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { createDirectAnalysis } from "../../../../src/composition/directAnalysis.js"; +import { artifactInspectionResultSchema } from "../../../../src/domain/artifactInspection.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; + +const { runProviderAnalysis } = createDirectAnalysis({}); + +// mach_header_64 for an arm64 MH_EXECUTE image with no load commands. +const MACH_O_64 = Buffer.alloc(32); +MACH_O_64.writeUInt32LE(0xfeedfacf, 0); +MACH_O_64.writeUInt32LE(0x0100000c, 4); +MACH_O_64.writeUInt32LE(2, 12); +const INFO_PLIST = ` +CFBundleExecutableFixture +`; + +const writeTree = async ( + root: string, + files: Readonly>, +): Promise => { + for (const [path, contents] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), contents); + } +}; + +const probeOperations = async (path: string): Promise => { + const evidence = parseEvidence( + await runProviderAnalysis(path, "inspect_artifact", {}), + ); + const inspection = artifactInspectionResultSchema.parse( + evidence.normalized_result, + ); + expect(inspection.subject.root_format).toBe("directory"); + expect( + inspection.unexplored_branches.map(({ next_probe }) => next_probe), + ).toEqual(inspection.next_probes); + return inspection.next_probes.map(({ operation }) => operation); +}; + +describe("directory inspection probes", () => { + it("proposes native and plist probes for an application bundle without JavaScript", async () => { + const root = await createTestTempDirectory("rea-artifact-bundle-"); + const bundle = join(root, "Fixture.app"); + await writeTree(bundle, { + "Contents/MacOS/Fixture": MACH_O_64, + "Contents/Info.plist": INFO_PLIST, + }); + + expect(await probeOperations(bundle)).toEqual([ + "open_binary", + "inspect_plist", + ]); + }); + + it("keeps JavaScript analysis first for an Electron-style bundle", async () => { + const root = await createTestTempDirectory("rea-artifact-electron-"); + const source = join(root, "source"); + const bundle = join(root, "Fixture.app"); + await writeTree(source, { "main.js": "console.log('ok');\n" }); + await writeTree(bundle, { + "Contents/MacOS/Fixture": MACH_O_64, + "Contents/Info.plist": INFO_PLIST, + }); + await createPackageWithOptions( + source, + join(bundle, "Contents/Resources/app.asar"), + {}, + ); + + expect(await probeOperations(bundle)).toEqual([ + "analyze_javascript_application", + "open_binary", + "inspect_plist", + ]); + }); + + it.each(["cjs", "cts", "mjs", "mts", "js", "jsx", "ts", "tsx"])( + "proposes JavaScript analysis for .%s sources the analyzer reads", + async (extension) => { + const root = await createTestTempDirectory("rea-artifact-typescript-"); + const bundle = join(root, "Fixture.app"); + await writeTree(bundle, { + "Contents/MacOS/Fixture": MACH_O_64, + "Contents/Info.plist": INFO_PLIST, + [`Contents/Resources/app/main.${extension}`]: + "export const ok = true;\n", + }); + + expect(await probeOperations(bundle)).toEqual([ + "analyze_javascript_application", + "open_binary", + "inspect_plist", + ]); + }, + ); + + it("preserves plist inspection for a readable Info.plist symlink", async () => { + const root = await createTestTempDirectory("rea-artifact-plist-link-"); + const bundle = join(root, "Fixture.app"); + await writeTree(bundle, { "Contents/MacOS/Fixture": MACH_O_64 }); + await writeFile(join(root, "bundle-metadata"), INFO_PLIST); + await symlink( + join(root, "bundle-metadata"), + join(bundle, "Contents/Info.plist"), + ); + expect(await probeOperations(bundle)).toEqual([ + "open_binary", + "inspect_plist", + ]); + }); + + it("does not propose JavaScript analysis for a symlink the analyzers never follow", async () => { + const root = await createTestTempDirectory("rea-artifact-symlink-"); + const bundle = join(root, "Fixture.app"); + await writeTree(bundle, { + "Contents/MacOS/Fixture": MACH_O_64, + "Contents/Info.plist": INFO_PLIST, + }); + await writeTree(root, { "outside.js": "console.log('outside');\n" }); + await mkdir(join(bundle, "Contents/Resources")); + await symlink( + join(root, "outside.js"), + join(bundle, "Contents/Resources/main.js"), + ); + + expect(await probeOperations(bundle)).toEqual([ + "open_binary", + "inspect_plist", + ]); + }); +}); diff --git a/tests/boundary/providers/native/artifactProvider.failureDetail.test.ts b/tests/boundary/providers/native/artifactProvider.failureDetail.test.ts index e500d2c0c..5b042e6e2 100644 --- a/tests/boundary/providers/native/artifactProvider.failureDetail.test.ts +++ b/tests/boundary/providers/native/artifactProvider.failureDetail.test.ts @@ -41,10 +41,32 @@ it("keeps logical names that differ only in case and records the limitation", as } }); +it("keeps literal backslash ZIP names distinct from slash paths", async () => { + const root = await createTestTempDirectory("rea-artifact-detail-"); + const path = join(root, "fixture.zip"); + await writeOrderedZip(path, ["a\\b.js", "a/b.js"]); + const target = await parseBinaryTarget(path); + if (!target.ok) throw new Error("expected a ZIP target"); + const result = await new ArtifactProvider(process.env) + .createClient(target.value) + .execute("inventory_artifact", {}); + if (!result.ok) throw result.error; + const inventory = artifactInventoryResultSchema.parse(result.value.result); + const literal = inventory.occurrences.find( + ({ logical_path }) => logical_path === "a\\b.js", + ); + const slash = inventory.occurrences.find( + ({ logical_path }) => logical_path === "a/b.js", + ); + expect(literal).toBeDefined(); + expect(slash).toBeDefined(); + expect(literal?.occurrence_id).not.toBe(slash?.occurrence_id); +}); + it("reports an unsafe path as a hostile artifact path", async () => { const root = await createTestTempDirectory("rea-artifact-detail-"); const path = join(root, "fixture.zip"); - await writeOrderedZip(path, ["a\\b.js"]); + await writeOrderedZip(path, ["../outside.js"]); const target = await parseBinaryTarget(path); if (!target.ok) throw new Error("expected a ZIP target"); const result = await new ArtifactProvider(process.env) @@ -53,11 +75,11 @@ it("reports an unsafe path as a hostile artifact path", async () => { if (result.ok) throw new Error("expected the path constraint to fail"); expect(projectAnalysisError(result.error)).toMatchObject({ code: "artifact_operation_failed", - message: 'Artifact path is absolute or unsafe: "a\\\\b.js"', + message: 'Artifact path is not normalized: "../outside.js"', details: { operation: "inventory_artifact", reason: "path", - detail: 'Artifact path is absolute or unsafe: "a\\\\b.js"', + detail: 'Artifact path is not normalized: "../outside.js"', }, }); });