Join provider class observations only when selected-image identity and exact import bindings establish equivalence. Preserve distinct observations and unsupported coverage. Refs #5607
447 lines
14 KiB
TypeScript
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 });
|
|
});
|
|
});
|