fix(native): retain Objective-C implementation alternatives

This commit is contained in:
morluto
2026-10-23 23:42:38 +08:00
parent 567b559474
commit 7fd722e15b
8 changed files with 409 additions and 52 deletions
@@ -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({
+100
View File
@@ -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(
+93 -31
View File
@@ -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<string, ObjcMethod>();
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<string>();
const implementations = new Map<string, ObjcDispatchImplementation>();
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<string, ObjcMethod[]>();
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;
};
+19
View File
@@ -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",
) => {
+8 -16
View File
@@ -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,
@@ -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",
@@ -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());
},
);
+11 -5
View File
@@ -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);