Files
rea/src/application/EnhancedTools.objcClasses.test.ts
T
morluto 4d7de00d4c fix(native): coalesce Objective-C class imports by binding identity
Join provider class observations only when selected-image identity and exact import bindings establish equivalence. Preserve distinct observations and unsupported coverage.

Refs #5607
2026-10-21 06:12:21 +08:00

447 lines
14 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
createAnalysisExecution,
type AnalysisOperation,
type AnalysisOperationPort,
} from "./AnalysisProvider.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { EnhancedTools } from "./EnhancedTools.js";
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
import { objcSwiftMetadataSchema } from "../domain/native/objcSwiftMetadata.js";
import { err, ok } from "../domain/result.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import {
AnalysisOutputError,
AnalysisCancelledError,
} from "../domain/analysisErrorCore.js";
import { createAnalysisProfile } from "../domain/analysisProfile.js";
import type { MachoTargetSelection } from "../domain/apple/machoTargetSelection.js";
const sha256 = "a".repeat(64);
const provider = { id: "native", name: "Native", version: "1" };
const namesProvider = { id: "hopper", name: "Hopper", version: "2" };
const selection = {
kind: "macho-selected-image-v1" as const,
container: "thin" as const,
offset: 0,
byte_length: 4096,
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
container_table_sha256: null,
};
const profile = createAnalysisProfile(namesProvider, {
target_selection: selection,
});
const subject = {
sha256,
path: "/fixture",
format: "mach-o" as const,
architecture: "arm64" as const,
};
const createPort = (
nativeOverrides: Partial<{
sha256: string;
architecture: BinaryArchitecture;
names: Array<{ address: string; value: string }>;
nameLimitations: string[];
metadataFailure: AnalysisError;
bindings: Array<{
requested_address: string;
symbol: string;
binding_source: "chained" | "bind" | "weak" | "lazy";
library_ordinal: number;
library: string | null;
weak: boolean;
addend: string;
}>;
targetSelection: MachoTargetSelection | null;
nameProfile: boolean;
}> = {},
): AnalysisOperationPort => ({
execute: async (operation: AnalysisOperation, parameters) => {
if (operation === "list_names")
return ok(
createAnalysisExecution(
nativeOverrides.names ?? [
{ address: "0x1000", value: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x1000", value: "OBJC_CLASS_$_Document" },
],
namesProvider,
{
subject,
limitations: nativeOverrides.nameLimitations ?? [],
...(nativeOverrides.nameProfile === false
? {}
: { analysisProfile: profile }),
},
),
);
if (operation !== "inspect_native_dispatch_metadata")
return err(
new AnalysisCapabilityUnavailableError(
provider.id,
operation,
"Unavailable in fixture",
),
);
expect(parameters).toMatchObject({
max_records: 20_000,
objc_class_import_addresses: [
...new Set(
(
nativeOverrides.names ?? [
{ address: "0x1000", value: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x1000", value: "OBJC_CLASS_$_Document" },
]
).map(({ address }) => address),
),
],
objc_class_import_addresses_complete: true,
});
if (nativeOverrides.metadataFailure !== undefined)
return err(nativeOverrides.metadataFailure);
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
if (nativeOverrides.targetSelection !== null)
result.target_selection = nativeOverrides.targetSelection ?? selection;
result.objc_class_import_bindings = nativeOverrides.bindings ?? [
{
requested_address: "0x1000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: false,
addend: "0",
},
];
result.objc_class_import_binding_coverage = {
facet: "objc_class_import_bindings",
status: "complete",
reason: null,
examined: 1,
decoded: result.objc_class_import_bindings.length,
};
return ok(
createAnalysisExecution(
{
target_sha256: nativeOverrides.sha256 ?? sha256,
target_architecture: nativeOverrides.architecture ?? "arm64",
provider,
analysis_profile_digest: null,
result,
},
provider,
{
subject: {
...subject,
...(nativeOverrides.sha256 === undefined
? {}
: { sha256: nativeOverrides.sha256 }),
...(nativeOverrides.architecture === undefined
? {}
: { architecture: nativeOverrides.architecture }),
},
},
),
);
},
});
it.each([
["list_names", "omitted"],
["list_names", "unbound"],
["inspect_native_dispatch_metadata", "omitted"],
["inspect_native_dispatch_metadata", "unbound"],
] as const)(
"retains class candidates when %s subject is %s",
async (operation, state) => {
const port = createPort();
const tools = new EnhancedTools({
execute: async (...args) => {
const result = await port.execute(...args);
if (!result.ok || args[0] !== operation) return result;
const value = { ...result.value };
if (state === "omitted") delete value.subject;
else value.subject = null;
return ok(value);
},
});
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 2,
classes: [
{ address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x1000", name: "OBJC_CLASS_$_Document" },
],
identity_coverage: { status: "unknown" },
},
});
},
);
describe("Objective-C class import identity composition", () => {
it("joins only exact same-target bind slots and preserves literal filter selection", async () => {
const tools = new EnhancedTools(createPort());
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 1,
classes: [
{
address: "0x1000",
name: "OBJC_CLASS_$_Document",
binding_identity: {
symbol: "_OBJC_CLASS_$_Document",
slot_addresses: ["0x1000"],
},
},
],
identity_coverage: { status: "complete", reason: null },
},
});
await expect(
tools.execute("get_objc_classes", { pattern: "$GOT" }),
).resolves.toMatchObject({
ok: true,
value: {
count: 1,
classes: [{ address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" }],
},
});
});
it("keeps rows unjoined when the native observation has a different subject", async () => {
const tools = new EnhancedTools(createPort({ sha256: "b".repeat(64) }));
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 2,
classes: [
{ address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x1000", name: "OBJC_CLASS_$_Document" },
],
identity_coverage: { status: "unknown" },
},
});
});
it("keeps identity unknown when exact selected-image evidence is missing or mismatched", async () => {
const cases = [
{ nameProfile: false },
{ targetSelection: null },
{ targetSelection: { ...selection, cpu_type: 0x01000007 } },
{ targetSelection: { ...selection, cpu_subtype: 2 } },
{
targetSelection: {
...selection,
container: "fat" as const,
offset: 4096,
container_table_sha256: "b".repeat(64),
},
},
];
for (const overrides of cases) {
const tools = new EnhancedTools(createPort(overrides));
await expect(
tools.execute("get_objc_classes", {}),
).resolves.toMatchObject({
ok: true,
value: { count: 2, identity_coverage: { status: "unknown" } },
});
}
});
it("marks identity coverage partial when the selected names inventory is limited", async () => {
const tools = new EnhancedTools(
createPort({ nameLimitations: ["provider capped the name inventory"] }),
);
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 1,
identity_coverage: {
status: "partial",
reason: "list_names: provider capped the name inventory",
},
},
});
});
it("keeps names with unknown identity when binding inspection is unavailable", async () => {
const tools = new EnhancedTools(
createPort({
metadataFailure: new AnalysisCapabilityUnavailableError(
provider.id,
"inspect_native_dispatch_metadata",
"No static Mach-O reader",
),
}),
);
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 2,
identity_coverage: { status: "unknown" },
},
});
});
it("preserves non-capability binding failures", async () => {
const failure = new AnalysisOutputError(
"inspect_native_dispatch_metadata",
"fixture failed",
);
const tools = new EnhancedTools(createPort({ metadataFailure: failure }));
await expect(tools.execute("get_objc_classes", {})).resolves.toEqual({
ok: false,
error: failure,
});
});
});
it("preserves same-named rows whose exact import slots have different identities", async () => {
const addresses = ["0x1000", "0x2000", "0x3000", "0x4000"];
const name = "OBJC_CLASS_$_Document";
const tools = new EnhancedTools(
createPort({
names: [
...addresses.map((address) => ({ address, value: name })),
{ address: "0x1000", value: name },
],
bindings: [
{
requested_address: "0x1000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: false,
addend: "0",
},
{
requested_address: "0x2000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/UIKit.framework/UIKit",
weak: false,
addend: "0",
},
{
requested_address: "0x3000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: true,
addend: "0",
},
{
requested_address: "0x4000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: false,
addend: "1",
},
],
}),
);
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 4,
classes: addresses.map((address, index) => ({
address,
name,
binding_identity: {
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library:
index === 1
? "/System/Library/Frameworks/UIKit.framework/UIKit"
: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: index === 2,
addend: index === 3 ? "1" : "0",
slot_addresses: [address],
},
})),
identity_coverage: { status: "complete", reason: null },
},
});
});
describe("EnhancedTools cancellation ownership", () => {
it("preserves the caller reason at admission", async () => {
const reason = { request: "cancel-before-observation" };
const controller = new AbortController();
controller.abort(reason);
const execute = vi.fn(createPort().execute);
const tools = new EnhancedTools({ execute });
const result = await tools.execute(
"get_objc_classes",
{},
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Pre-cancelled observation was accepted");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(execute).not.toHaveBeenCalled();
});
it("preserves a post-success caller reason without replacing provider failures", async () => {
const reason = { request: "cancel-after-provider-success" };
const controller = new AbortController();
const successfulPort = createPort();
const successTools = new EnhancedTools({
execute: async (...args) => {
const result = await successfulPort.execute(...args);
if (args[0] === "list_names" && result.ok) controller.abort(reason);
return result;
},
});
const cancelled = await successTools.execute(
"get_objc_classes",
{},
controller.signal,
);
expect(cancelled.ok).toBe(false);
if (cancelled.ok)
throw new Error("Post-cancelled observation was accepted");
expect(cancelled.error).toBeInstanceOf(AnalysisCancelledError);
expect(cancelled.error.cause).toBe(reason);
const physicalFailure = new AnalysisOutputError(
"list_names",
"fixture read failed",
);
const failingController = new AbortController();
const failureTools = new EnhancedTools({
execute: async (...args) => {
if (args[0] === "list_names") {
failingController.abort(reason);
return err(physicalFailure);
}
return successfulPort.execute(...args);
},
});
const failed = await failureTools.execute(
"get_objc_classes",
{},
failingController.signal,
);
expect(failed).toEqual({ ok: false, error: physicalFailure });
});
});