diff --git a/src/application/native/EnhancedNativeInvestigation.test.ts b/src/application/native/EnhancedNativeInvestigation.test.ts index 78d338b349..b598ea2ad8 100644 --- a/src/application/native/EnhancedNativeInvestigation.test.ts +++ b/src/application/native/EnhancedNativeInvestigation.test.ts @@ -358,6 +358,74 @@ describe("native UI action trace", () => { }); }); +describe("native dispatch candidate identity", () => { + it.each([ + ["0x1000", "0x3000"], + ["ram:0x1000", "rom:0x1000"], + ])( + "keeps same-selector implementation alternatives unresolved: %j and %j", + async (left, right) => { + for (const addresses of [ + [left, right], + [right, left], + ]) { + const result = await new EnhancedTools( + testAnalysis({ + list_names: () => + execution( + "list_names", + addresses.map((address) => ({ + ...methodInventoryEntry, + address, + })), + ), + }), + ).execute("trace_native_ui_action", { action: "buildTapped:" }); + if (!result.ok) throw result.error; + const trace = nativeInvestigationTraceSchema.parse(result.value); + const dispatch = trace.unresolved.find( + ({ relation }) => relation === "objc_dispatch", + ); + expect(dispatch).toMatchObject({ + to: null, + resolution: "unresolved", + reason: "selector_has_multiple_candidate_implementations", + }); + expect( + dispatch?.evidence.map(({ location }) => location.address), + ).toEqual(expect.arrayContaining(addresses)); + } + }, + ); + + it("uses one canonical function node for equivalent coordinate spellings", async () => { + const addresses = ["0x1000", "0X001000"]; + for (const ordered of [addresses, [...addresses].reverse()]) { + const result = await new EnhancedTools( + testAnalysis({ + list_names: () => + execution( + "list_names", + ordered.map((address) => ({ ...methodInventoryEntry, address })), + ), + }), + ).execute("trace_native_ui_action", { action: "buildTapped:" }); + if (!result.ok) throw result.error; + const trace = nativeInvestigationTraceSchema.parse(result.value); + const dispatch = trace.edges.find( + ({ relation }) => relation === "objc_dispatch", + ); + expect(dispatch).toMatchObject({ + to: "native:function:0x1000", + resolution: "inferred", + }); + expect( + dispatch?.evidence.map(({ location }) => location.address), + ).toEqual(expect.arrayContaining(addresses)); + } + }); +}); + describe("native UI action selector origin", () => { it("marks provider-reported function addresses as derived and the action seed as caller-supplied", async () => { const inner = testAnalysis({ diff --git a/src/domain/native/objcSwiftMetadata.test.ts b/src/domain/native/objcSwiftMetadata.test.ts index b7b4750970..5a1cc28570 100644 --- a/src/domain/native/objcSwiftMetadata.test.ts +++ b/src/domain/native/objcSwiftMetadata.test.ts @@ -2,6 +2,106 @@ import { describe, expect, it } from "vitest"; import { inspectNativeDispatchMetadata } from "./objcSwiftMetadata.js"; +describe("symbol-only Objective-C implementation identity", () => { + it.each([ + { source: "0X00002000", canonical: "0x2000", numeric: 8192 }, + { + source: "0x1fffffffffffff", + canonical: "0x1fffffffffffff", + numeric: Number.MAX_SAFE_INTEGER, + }, + { + source: "0x20000000000000", + canonical: "0x20000000000000", + numeric: null, + }, + { source: "ram:0x2000", canonical: "ram:0x2000", numeric: null }, + { source: "rom:0x2000", canonical: "rom:0x2000", numeric: null }, + ])( + "preserves $source without inventing a numeric method identity", + ({ source, canonical, numeric }) => { + const result = inspectNativeDispatchMetadata([ + { address: source, name: "-[StoreController buildTapped:]" }, + ]); + expect(result.objc_classes[0]?.methods[0]?.address).toBe(numeric); + expect(result.objc_dispatch_implementations[0]).toMatchObject({ + implementation_address: canonical, + location: { address: canonical }, + evidence: [{ location: { address: source } }], + }); + }, + ); + + it("retains unparseable symbol coordinates as reported evidence, without claiming a location", () => { + const source = "invalid::0x2000"; + const result = inspectNativeDispatchMetadata([ + { address: source, name: "-[StoreController buildTapped:]" }, + { address: source, name: "$s5Store7launchyyF" }, + ]); + expect(result.objc_dispatch_implementations[0]).toMatchObject({ + implementation_address: null, + location: { address: null }, + evidence: [{ location: { address: null }, reported_address: source }], + }); + expect(result.swift_symbols[0]).toMatchObject({ + address: null, + evidence: [{ location: { address: null }, reported_address: source }], + }); + }); + + it("merges equivalent method coordinates and retains every source spelling", () => { + const addresses = ["0x2000", "0X002000"]; + for (const ordered of [addresses, [...addresses].reverse()]) { + const result = inspectNativeDispatchMetadata( + ordered.map((address) => ({ + address, + name: "-[StoreController buildTapped:]", + })), + ); + expect(result.objc_dispatch_implementations).toHaveLength(1); + expect(result.objc_dispatch_implementations[0]).toMatchObject({ + implementation_address: "0x2000", + location: { address: "0x2000" }, + }); + expect( + result.objc_dispatch_implementations[0]?.evidence.map( + ({ location }) => location.address, + ), + ).toEqual(ordered); + expect(result.objc_classes[0]?.methods).toMatchObject([ + { selector: "buildTapped:", address: 8192 }, + ]); + } + }); + + it.each([ + ["0x2000", "0x3000"], + ["ram:0x2000", "rom:0x2000"], + ["", "0x2000"], + ["unresolved", "unresolved"], + ])( + "retains distinct or unknown implementation identities: %j and %j", + (left, right) => { + const result = inspectNativeDispatchMetadata( + [left, right].map((address) => ({ + address, + name: "-[StoreController buildTapped:]", + })), + ); + expect(result.objc_dispatch_implementations).toHaveLength(2); + expect(result.objc_classes[0]?.methods).toEqual([ + { + selector: "buildTapped:", + method_type: "instance", + address: null, + is_required: null, + is_optional: null, + }, + ]); + }, + ); +}); + describe("ObjC/Swift metadata", () => { it("bounds symbol projections and labels symbol-only metadata as partial", () => { const result = inspectNativeDispatchMetadata( diff --git a/src/domain/native/objcSwiftMetadata.ts b/src/domain/native/objcSwiftMetadata.ts index 2ff35353ce..f2ce8b7bb1 100644 --- a/src/domain/native/objcSwiftMetadata.ts +++ b/src/domain/native/objcSwiftMetadata.ts @@ -8,6 +8,10 @@ import { providerSchema } from "../evidence.js"; import { isSwiftMangledSymbolName } from "../swiftSymbolClassification.js"; import { nativeCallReferenceSchema } from "./nativeCallReference.js"; import { machoTargetSelectionSchema } from "../apple/machoTargetSelection.js"; +import { + nativeAddressCoordinateInputSchema, + nativeAddressInputSchema, +} from "./nativeAddress.js"; import { compareUnicodeCodePoints, @@ -36,7 +40,12 @@ export const objcPropertyAttributeSchema = z.strictObject({ export const objcMethodSchema = z.strictObject({ selector: z.string().min(1), method_type: objcMethodTypeSchema, - address: z.int().nullable(), + address: z + .int() + .nullable() + .describe( + "Unique unqualified implementation address; null if unknown, ambiguous, or not a safe integer.", + ), is_required: z.boolean().nullable(), is_optional: z.boolean().nullable(), }); @@ -184,6 +193,33 @@ type ObjcDispatchImplementation = z.infer< typeof objcDispatchImplementationSchema >; +/** Project class declarations without selecting an ambiguous or unrepresentable address. */ +export const projectObjcClassMethods = ( + implementations: readonly ObjcDispatchImplementation[], +): ObjcMethod[] => { + const methods = new Map(); + for (const implementation of implementations) { + const key = compositeKey([ + implementation.selector, + implementation.method_type, + ]); + const address = parseMetadataAddress(implementation.implementation_address); + const previous = methods.get(key); + if (previous !== undefined) { + if (previous.address !== address) previous.address = null; + continue; + } + methods.set(key, { + selector: implementation.selector, + method_type: implementation.method_type, + address, + is_required: null, + is_optional: null, + }); + } + return [...methods.values()]; +}; + /** Objective-C category: methods, protocols and properties added to a class. */ export const objcCategorySchema = z.strictObject({ name: z.string().min(1), @@ -453,12 +489,17 @@ export const inspectNativeDispatchMetadata = ( }, ]; const classNames = new Set(); - const implementations = new Map(); + const implementations = new Map< + string | symbol, + ObjcDispatchImplementation + >(); const swiftSymbols: SwiftSymbol[] = []; const examinedNames = names.slice(0, maxRecords); for (const entry of examinedNames) { - const address = entry.address === "" ? null : entry.address; + const parsedAddress = nativeAddressInputSchema.safeParse(entry.address); + const address = parsedAddress.success ? parsedAddress.data : null; + const sourceAddress = parsedAddress.success ? entry.address : null; const classMatch = /(?:OBJC_CLASS_|OBJC_METACLASS_)\$_([^\s]+)/u.exec( entry.name, ); @@ -468,15 +509,27 @@ export const inspectNativeDispatchMetadata = ( if (methodMatch?.[1] !== undefined && methodMatch[2] && methodMatch[3]) { const methodType = methodMatch[1] === "+" ? "class" : "instance"; const selector = methodMatch[3]; - implementations.set(`${methodMatch[2]}:${selector}:${methodType}`, { - class_name: methodMatch[2], - selector, - method_type: methodType, - implementation_address: address, - location: { address, file_offset: null }, - decode: { status: "partial", reason: "symbol_name_only" }, - evidence: symbolEvidence(entry, address), - }); + const key = parsedAddress.success + ? compositeKey([ + methodMatch[2], + selector, + methodType, + parsedAddress.data, + ]) + : Symbol(); + const evidence = symbolEvidence(entry, sourceAddress); + const previous = implementations.get(key); + if (previous !== undefined) previous.evidence.push(...evidence); + else + implementations.set(key, { + class_name: methodMatch[2], + selector, + method_type: methodType, + implementation_address: address, + location: { address, file_offset: null }, + decode: { status: "partial", reason: "symbol_name_only" }, + evidence, + }); classNames.add(methodMatch[2]); } @@ -485,28 +538,26 @@ export const inspectNativeDispatchMetadata = ( mangled_name: entry.name, address, decode: { status: "partial", reason: "mangled_symbol_not_demangled" }, - evidence: symbolEvidence(entry, address), + evidence: symbolEvidence(entry, sourceAddress), }); } - const methodsByClass = new Map(); + const implementationsByClass = new Map< + string, + ObjcDispatchImplementation[] + >(); for (const implementation of implementations.values()) { - const methods = methodsByClass.get(implementation.class_name) ?? []; - methods.push({ - selector: implementation.selector, - method_type: implementation.method_type, - address: parseMetadataAddress(implementation.implementation_address), - is_required: null, - is_optional: null, - }); - methodsByClass.set(implementation.class_name, methods); + const candidates = + implementationsByClass.get(implementation.class_name) ?? []; + candidates.push(implementation); + implementationsByClass.set(implementation.class_name, candidates); } const classes: ObjcClass[] = [...classNames].sort().map((name) => ({ name, super_class: null, is_meta_class: null, is_root_class: null, - methods: methodsByClass.get(name) ?? [], + methods: projectObjcClassMethods(implementationsByClass.get(name) ?? []), properties: [], protocols: [], ivar_count: null, @@ -515,8 +566,18 @@ export const inspectNativeDispatchMetadata = ( const decodedImplementations = [...implementations.values()].sort( (left, right) => compareUnicodeCodePoints( - compositeKey([left.class_name, left.selector]), - compositeKey([right.class_name, right.selector]), + compositeKey([ + left.class_name, + left.selector, + left.method_type, + left.implementation_address ?? "", + ]), + compositeKey([ + right.class_name, + right.selector, + right.method_type, + right.implementation_address ?? "", + ]), ), ); const swift = swiftSymbols.length > 0; @@ -586,9 +647,10 @@ export const inspectNativeDispatchMetadata = ( }; const parseMetadataAddress = (address: string | null): number | null => { - if (address === null) return null; - const match = /(?:^|:)0x([0-9a-f]+)$/iu.exec(address); - if (match?.[1] === undefined) return null; - const value = Number.parseInt(match[1], 16); - return Number.isSafeInteger(value) ? value : null; + const parsed = nativeAddressCoordinateInputSchema.safeParse(address); + return parsed.success && + parsed.data.space === null && + parsed.data.offset <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(parsed.data.offset) + : null; }; diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index efbd58e4c1..a8dcde33a9 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -120,6 +120,25 @@ const fixture = ( }; const provenance = { path: "/fixture/app", sha256: "a".repeat(64) }; +it("retains duplicate encoded method candidates without choosing one class-method address", () => { + const bytes = objcRecordBudgetMacho("class", [0x700, 0x720]); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect( + result.objc_dispatch_implementations.map( + ({ implementation_address }) => implementation_address, + ), + ).toEqual(["0x100000700", "0x100000720"]); + expect(result.objc_classes[0]?.methods).toEqual([ + { + selector: "classMethod", + method_type: "instance", + address: null, + is_required: null, + is_optional: null, + }, + ]); +}); + const swiftExternalSwiftBindingImage = ( boundField: "protocol" | "type" | "objc-class" = "protocol", ) => { diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts index 61397dbd91..047642bbd9 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -1,5 +1,8 @@ import { err, ok, type Result } from "../domain/result.js"; -import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js"; +import { + projectObjcClassMethods, + type ObjcSwiftMetadata, +} from "../domain/native/objcSwiftMetadata.js"; import type { Segment, Section } from "./AppleMachoSelection.js"; import { boundClassName, decodeObjcCategories } from "./AppleObjcCategories.js"; import { readObjcPropertiesOf } from "./AppleObjcProperties.js"; @@ -366,24 +369,13 @@ export const decodeObjcDispatchFacets = (input: { super_class: superclass.name, is_meta_class: meta, is_root_class: root, - methods: result.objc_dispatch_implementations - .filter( + methods: projectObjcClassMethods( + result.objc_dispatch_implementations.filter( (item) => item.class_name === name && item.method_type === (meta ? "class" : "instance"), - ) - .map((item) => ({ - selector: item.selector, - method_type: item.method_type, - address: - item.implementation_address !== null && - BigInt(item.implementation_address) <= - BigInt(Number.MAX_SAFE_INTEGER) - ? Number(BigInt(item.implementation_address)) - : null, - is_required: null, - is_optional: null, - })), + ), + ), properties, protocols, ivar_count: ivarCount, diff --git a/tests/acceptance/nativeDispatchSnapshot.test.ts b/tests/acceptance/nativeDispatchSnapshot.test.ts index 17b81dd1b7..d169978779 100644 --- a/tests/acceptance/nativeDispatchSnapshot.test.ts +++ b/tests/acceptance/nativeDispatchSnapshot.test.ts @@ -14,6 +14,64 @@ import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; import { nativeObjcClassImportMachO } from "../fixtures/nativeObjcClassImportMachO.js"; +import { objcRecordBudgetMacho } from "../fixtures/objcRecordBudgetMacho.js"; + +cliTest.skipIf(process.platform !== "darwin")( + "preserves ambiguous encoded method implementations through the public CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-objc-method-candidates-"); + const target = join(root, "methods.macho"); + const bytes = objcRecordBudgetMacho("class", [0x700, 0x720]); + await writeFile(target, bytes); + const environment = { + PATH: process.env.PATH ?? "", + REA_ANALYSIS_PROVIDER: "auto", + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper", + GHIDRA_INSTALL_DIR: "/rea-unconfigured-deep-provider/ghidra", + JAVA_HOME: "/rea-unconfigured-deep-provider/java", + REA_IDA_MCP_CONFIG: "/rea-unconfigured-deep-provider/ida-mcp.json", + REA_KUNA_COMMAND: "/rea-unconfigured-deep-provider/kuna", + REA_KUNA_CACHE_DIR: "/rea-unconfigured-deep-provider/kuna-cache", + REA_LOG_LEVEL: "silent", + }; + const result = await cli.run({ + arguments: ["inspect-native-dispatch-metadata", target, "--json"], + environment, + }); + expect( + result.exitCode, + `${result.stderr}\n${JSON.stringify(result.json)}`, + ).toBe(0); + const cliEvidence = toolContract( + "inspect_native_dispatch_metadata", + ).outputSchema.parse(result.json); + const { client, call } = await connectLocalToolsMcp(environment); + const opened = await call("open_binary", { path: target }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const inspected = await call("inspect_native_dispatch_metadata", {}); + expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); + const mcpEvidence = parseEvidence(inspected.structuredContent); + for (const evidence of [cliEvidence, mcpEvidence]) { + expect(evidence.normalized_result).toMatchObject({ + result: { + objc_classes: [ + { + name: "FixtureClass", + methods: [{ selector: "classMethod", address: null }], + }, + ], + objc_dispatch_implementations: [ + { selector: "classMethod", implementation_address: "0x100000700" }, + { selector: "classMethod", implementation_address: "0x100000720" }, + ], + }, + }); + } + expect(await readFile(target)).toEqual(bytes); + await client.ping(); + }, + 30000, +); cliTest.skipIf(process.platform !== "darwin")( "retains and reopens profileless native dispatch workflow snapshots through CLI and stdio MCP", diff --git a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts index a492fdf8e8..d260d927b2 100644 --- a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts +++ b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts @@ -14,6 +14,7 @@ import { createAnalysisExecution } from "../../../src/application/AnalysisProvid import { ok as resultOk } from "../../../src/domain/result.js"; import { err } from "../../../src/domain/result.js"; import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js"; +import { nativeDispatchMetadataResultSchema } from "../../../src/domain/native/objcSwiftMetadata.js"; const targetObservation = (value: unknown) => resultOk( @@ -481,3 +482,54 @@ it("retains unlocated provider symbols through the advertised native dispatch MC }, }); }); + +it.each([ + { addresses: ["0x2000", "0x3000"], candidates: 2, numeric: null }, + { addresses: ["ram:0x2000", "rom:0x2000"], candidates: 2, numeric: null }, + { addresses: ["0x2000", "0X002000"], candidates: 1, numeric: 8192 }, + { + addresses: ["overlay%2f1:0x2000", "overlay%2F1:0X002000"], + candidates: 1, + numeric: null, + }, +])( + "retains implementation identity and source spelling through MCP: $addresses", + async ({ addresses, candidates, numeric }) => { + const ordinary = fixturePort(); + const client = await connect({ + execute: (operation, parameters, options) => + operation === "list_names" + ? Promise.resolve( + targetObservation( + addresses.map((address) => ({ + address, + value: "-[Fixture buildTapped:]", + })), + ), + ) + : ordinary.execute(operation, parameters, options), + }); + const response = await client.callTool({ + name: "inspect_native_dispatch_metadata", + arguments: {}, + }); + expect( + response.isError, + JSON.stringify(response.structuredContent), + ).not.toBe(true); + const result = nativeDispatchMetadataResultSchema.parse( + jsonResult(response), + ).result; + expect(result.objc_dispatch_implementations).toHaveLength(candidates); + expect(result.objc_classes[0]?.methods).toMatchObject([ + { selector: "buildTapped:", address: numeric }, + ]); + expect( + result.objc_dispatch_implementations + .flatMap(({ evidence }) => + evidence.map(({ location }) => location.address), + ) + .sort(), + ).toEqual([...addresses].sort()); + }, +); diff --git a/tests/fixtures/objcRecordBudgetMacho.ts b/tests/fixtures/objcRecordBudgetMacho.ts index 53329226ea..747fac20f2 100644 --- a/tests/fixtures/objcRecordBudgetMacho.ts +++ b/tests/fixtures/objcRecordBudgetMacho.ts @@ -1,7 +1,10 @@ const base = 0x100000000n; /** Minimal inert Mach-O with one class instance method and protocol method. */ -export const objcRecordBudgetMacho = (owner: "class" | "protocol"): Buffer => { +export const objcRecordBudgetMacho = ( + owner: "class" | "protocol", + classMethodOffsets: readonly number[] = [0x700], +): Buffer => { const bytes = Buffer.alloc(4096); const u32 = (offset: number, value: number) => bytes.writeUInt32LE(value, offset); @@ -51,10 +54,13 @@ export const objcRecordBudgetMacho = (owner: "class" | "protocol"): Buffer => { nullPointer(0x340); bytes.write("FixtureClass\0", 0x900); u32(0x500, 24); - u32(0x504, 1); - pointer(0x508, 0x920); - pointer(0x510, 0x940); - pointer(0x518, 0x700); + u32(0x504, classMethodOffsets.length); + for (const [index, implementation] of classMethodOffsets.entries()) { + const entry = 0x508 + index * 24; + pointer(entry, 0x920); + pointer(entry + 8, 0x940); + pointer(entry + 16, implementation); + } bytes.write("classMethod\0", 0x920); bytes.write("v@:\0", 0x940);