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
This commit is contained in:
morluto
2026-10-21 06:12:21 +08:00
parent b231c28cf6
commit 4d7de00d4c
24 changed files with 2287 additions and 100 deletions
+15
View File
@@ -466,6 +466,21 @@ complete observation.
that operation is unavailable; runtime failures remain failures rather than
being replaced by symbol-only results.
`get_objc_classes` keeps names from the provider inventory. It coalesces rows
only when their exact selected-image pointer slots decode to the same class
import, including its binding stream, raw library ordinal, resolved library, weak status, and addend. A literal filter is
applied to the original names before that join; if the filter selects only an
alias, the output keeps that exact row. `identity_coverage` distinguishes a
complete selected-inventory scan from partial, unsupported, and unknown cases.
Recognized Mach-O formats outside the native metadata reader's support retain
the provider's class labels with unknown import identity; malformed headers and
read failures remain errors.
The join also requires matching whole-artifact digests and matching provider-
owned selected-image facts: container, byte range, full raw CPU type/subtype,
and FAT table digest when present. A missing or overridden loader selection
remains unknown; the result describes agreement on the selected artifact image,
not an atomic observation of a running process or whole-program class coverage.
## Native desktop observation
`observe_native_ui` captures one explicitly selected existing PID/window ID.
@@ -0,0 +1,446 @@
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 });
});
});
+147 -7
View File
@@ -9,6 +9,7 @@ import type {
} from "./AnalysisProvider.js";
import {
enhancedInputSchemas,
MAX_ENHANCED_RECORDS,
type CallRelationshipDirection,
type EnhancedToolName,
} from "../contracts/enhancedInputs.js";
@@ -30,15 +31,24 @@ import {
import { err, ok, type Result } from "../domain/result.js";
import {
categorizeSwiftTypes,
discoverObjcClasses,
discoverObjcProtocols,
projectObjcClassCandidates,
selectObjcClassCandidates,
} from "../domain/symbolAnalysis.js";
import { inspectNativeDispatch } from "./native/NativeDispatchMetadataInspection.js";
import { traceNativeValues } from "./native/NativeValueTrace.js";
import { traceNativeUiAction } from "./native/NativeUiActionTrace.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { addressedValueSchema } from "../domain/native/nativeInventory.js";
import { nativeDispatchMetadataResultSchema } from "../domain/native/objcSwiftMetadata.js";
import { isMachOFormat } from "../domain/binaryTargetTypes.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import {
machoTargetSelectionMatchesArchitecture,
machoTargetSelectionMatchesFormat,
profileMachoTargetSelection,
sameMachoTargetSelection,
} from "../domain/apple/machoTargetSelection.js";
import {
invalidEnhancedInput,
@@ -187,6 +197,8 @@ export class EnhancedTools {
return this.#inspectNativeDispatchMetadata(
call.input.max_records,
signal,
call.input.objc_class_import_addresses,
call.input.objc_class_import_addresses_complete,
);
case "get_objc_classes":
return this.#objcClasses(call.input.pattern, signal);
@@ -286,15 +298,137 @@ export class EnhancedTools {
}
async #objcClasses(pattern: string, signal?: AbortSignal): EnhancedResult {
const names = await this.#allAddressed("list_names", signal);
return names.ok ? ok(discoverObjcClasses(names.value, pattern)) : names;
const namesExecution = await this.#observe("list_names", {}, signal);
if (!namesExecution.ok) return namesExecution;
const names = this.#parseAddressed(
"list_names",
namesExecution.value.result,
);
if (!names.ok) return names;
const candidates = selectObjcClassCandidates(names.value, pattern);
if (candidates.length === 0)
return ok(
projectObjcClassCandidates(candidates, {
bindings: [],
coverage: { status: "not_applicable", reason: null },
}),
);
const addresses = [...new Set(candidates.map(({ address }) => address))];
const scannedAddresses = addresses.slice(0, MAX_ENHANCED_RECORDS);
const bindingsExecution = await this.#observe(
"inspect_native_dispatch_metadata",
{
max_records: MAX_ENHANCED_RECORDS,
objc_class_import_addresses: scannedAddresses,
objc_class_import_addresses_complete:
addresses.length <= MAX_ENHANCED_RECORDS,
},
signal,
);
if (!bindingsExecution.ok) {
if (bindingsExecution.error._tag === "AnalysisCancelledError")
return bindingsExecution;
if (bindingsExecution.error._tag !== "AnalysisCapabilityUnavailableError")
return bindingsExecution;
return ok(
projectObjcClassCandidates(candidates, {
bindings: [],
coverage: {
status: "unknown",
reason: `Native class-import binding evidence is unavailable (${bindingsExecution.error._tag}).`,
},
}),
);
}
const native = nativeDispatchMetadataResultSchema.safeParse(
bindingsExecution.value.result,
);
const nameSubject = namesExecution.value.subject;
const bindingSubject = bindingsExecution.value.subject;
const namesSelection = profileMachoTargetSelection(
namesExecution.value.analysisProfile,
);
const nativeSelection = native.success
? native.data.result.target_selection
: undefined;
if (
!native.success ||
nameSubject === null ||
nameSubject === undefined ||
bindingSubject === null ||
bindingSubject === undefined ||
nameSubject.sha256 !== bindingSubject.sha256 ||
nameSubject.architecture === undefined ||
bindingSubject.architecture === undefined ||
nameSubject.architecture !== bindingSubject.architecture ||
!isMachOFormat(nameSubject.format) ||
nameSubject.format !== bindingSubject.format ||
native.data.target_sha256 !== nameSubject.sha256 ||
native.data.target_architecture !== nameSubject.architecture ||
native.data.provider.id !== bindingsExecution.value.provider.id ||
namesExecution.value.analysisProfile?.provider.id !==
namesExecution.value.provider.id ||
nativeSelection === undefined ||
!machoTargetSelectionMatchesArchitecture(
nativeSelection,
nameSubject.architecture,
) ||
!machoTargetSelectionMatchesFormat(nativeSelection, nameSubject.format) ||
!sameMachoTargetSelection(namesSelection, nativeSelection) ||
native.data.result.objc_class_import_bindings === undefined ||
native.data.result.objc_class_import_binding_coverage === undefined
)
return ok(
projectObjcClassCandidates(candidates, {
bindings: [],
coverage: {
status: "unknown",
reason:
"Native class-import facts did not prove matching artifact, architecture, and complete producer identity.",
},
}),
);
const coverage = native.data.result.objc_class_import_binding_coverage;
const inventoryLimitations = namesExecution.value.limitations;
const inventoryIncomplete = inventoryLimitations.length > 0;
const coverageReason = [
coverage.reason,
...inventoryLimitations.map((limitation) => `list_names: ${limitation}`),
]
.filter((reason): reason is string => reason !== null)
.join("; ");
return ok(
projectObjcClassCandidates(candidates, {
bindings: native.data.result.objc_class_import_bindings,
coverage: {
status:
coverage.status === "not_requested"
? "unknown"
: coverage.status === "complete" && inventoryIncomplete
? "partial"
: coverage.status,
reason: coverageReason.length === 0 ? null : coverageReason,
},
}),
);
}
#inspectNativeDispatchMetadata(
maxRecords: number,
signal?: AbortSignal,
objcClassImportAddresses?: readonly string[],
objcClassImportAddressesComplete?: boolean,
): EnhancedResult {
return inspectNativeDispatch(this.analysis, maxRecords, signal);
return inspectNativeDispatch(
this.analysis,
maxRecords,
signal,
objcClassImportAddresses,
objcClassImportAddressesComplete,
);
}
async #objcProtocols(signal?: AbortSignal): EnhancedResult {
@@ -677,7 +811,11 @@ export class EnhancedTools {
) {
const result = await this.#call(tool, {}, signal);
if (!result.ok) return result;
const parsed = z.array(addressedValueSchema).safeParse(result.value);
return this.#parseAddressed(tool, result.value);
}
#parseAddressed(tool: "list_names" | "list_procedures", value: JsonValue) {
const parsed = z.array(addressedValueSchema).safeParse(value);
return parsed.success
? ok(
parsed.data.map(({ address, value: name, objc_class_label }) => ({
@@ -715,13 +853,15 @@ export class EnhancedTools {
signal?: AbortSignal,
selectorOrigin?: ExecutionOptions["selectorOrigin"],
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (isAborted(signal)) return err(new AnalysisCancelledError(name));
if (isAborted(signal))
return err(new AnalysisCancelledError(name, { cause: signal?.reason }));
const execution = await this.analysis.execute(name, arguments_, {
...(signal === undefined ? {} : { signal }),
...(selectorOrigin === undefined ? {} : { selectorOrigin }),
});
if (!execution.ok) return execution;
if (isAborted(signal)) return err(new AnalysisCancelledError(name));
if (isAborted(signal))
return err(new AnalysisCancelledError(name, { cause: signal?.reason }));
return execution;
}
}
@@ -21,10 +21,22 @@ export const inspectNativeDispatch = async (
analysis: AnalysisOperationPort,
maxRecords: number,
signal?: AbortSignal,
objcClassImportAddresses?: readonly string[],
objcClassImportAddressesComplete?: boolean,
): EnhancedResult => {
const input = {
max_records: maxRecords,
...(objcClassImportAddresses === undefined
? {}
: {
objc_class_import_addresses: [...objcClassImportAddresses],
objc_class_import_addresses_complete:
objcClassImportAddressesComplete ?? true,
}),
};
const binaryMetadata = await analysis.execute(
NATIVE_DISPATCH_METADATA_SOURCES.binary,
{ max_records: maxRecords },
input,
signal === undefined ? {} : { signal },
);
if (binaryMetadata.ok)
@@ -59,20 +71,32 @@ export const inspectNativeDispatch = async (
"provider returned an invalid inventory",
),
);
const result = inspectNativeDispatchMetadata(
names.data.map((entry) => ({
address: entry.address,
name: "value" in entry ? entry.value : entry.name,
})),
maxRecords,
);
if (objcClassImportAddresses !== undefined) {
result.objc_class_import_bindings = [];
result.objc_class_import_binding_coverage = {
facet: "objc_class_import_bindings",
status: "unsupported",
reason: "Native Mach-O pointer binding inspection is unavailable.",
examined: 0,
decoded: 0,
};
}
return ok(
jsonValueSchema.parse(
nativeDispatchMetadataResultSchema.parse({
target_sha256: execution.value.subject?.sha256 ?? null,
target_architecture: execution.value.subject?.architecture ?? null,
provider: execution.value.provider,
analysis_profile_digest:
execution.value.analysisProfile?.digest ?? null,
result: inspectNativeDispatchMetadata(
names.data.map((entry) => ({
address: entry.address,
name: "value" in entry ? entry.value : entry.name,
})),
maxRecords,
),
result,
}),
),
);
+20 -1
View File
@@ -6,6 +6,7 @@ const traceLiteralInputSchema = z.strictObject({
});
export const DEFAULT_CALL_RELATIONSHIP_DIRECTION = "forward";
export const MAX_ENHANCED_RECORDS = 20_000;
const callRelationshipDirectionSchema = z
.enum(["forward", "backward"])
.default(DEFAULT_CALL_RELATIONSHIP_DIRECTION);
@@ -17,7 +18,25 @@ export type CallRelationshipDirection = z.output<
export const enhancedInputSchemas = {
trace_native_values: nativeValueTraceInputSchema,
inspect_native_dispatch_metadata: z.strictObject({
max_records: z.number().int().min(1).max(20_000).default(5_000),
max_records: z
.number()
.int()
.min(1)
.max(MAX_ENHANCED_RECORDS)
.default(5_000),
objc_class_import_addresses: z
.array(z.string())
.max(MAX_ENHANCED_RECORDS)
.describe(
"Optional exact pointer-slot addresses from the selected name inventory; used to report decoded Objective-C class import bindings.",
)
.optional(),
objc_class_import_addresses_complete: z
.boolean()
.describe(
"Whether the candidate addresses include every selected class-label row; false keeps binding coverage partial when the existing record budget truncates the scan.",
)
.optional(),
}),
get_objc_classes: z.strictObject({ pattern: z.string().default("") }),
get_objc_protocols: z.strictObject({}),
+2 -2
View File
@@ -27,12 +27,12 @@ const enhanced = <Name extends string, Schema extends z.ZodObject>(
export const ENHANCED_TOOL_CONTRACTS = [
enhanced(
"inspect_native_dispatch_metadata",
"Inspect the bound provider's name inventory for Objective-C class/method symbols and Swift mangled symbols, returning at most max_records decoded names. Includes target SHA-256 when available, provider identity/version, analysis-profile digest, facet coverage, and per-record Evidence. This is symbol-level evidence, not runtime metadata: ivar layouts, protocol conformances, witness/class tables, and relative pointers are explicitly marked unsupported unless a provider supplies those decoders. The provider controls how its name inventory is acquired.",
"Inspect the bound provider's name inventory for Objective-C class/method symbols and Swift mangled symbols, returning at most max_records decoded names. Optional objc_class_import_addresses request exact pointer-slot binding facts for those addresses; they are decoded only from the selected Mach-O slice and retain partial or unsupported coverage when not established. Includes target SHA-256 when available, provider identity/version, analysis-profile digest, facet coverage, and per-record Evidence. This is static symbol and metadata evidence, not runtime metadata: ivar layouts, protocol conformances, witness/class tables, and relative pointers are explicitly marked unsupported unless a provider supplies those decoders. The provider controls how its name inventory is acquired.",
enhancedInputSchemas.inspect_native_dispatch_metadata,
),
enhanced(
"get_objc_classes",
"Discover and deduplicate Objective-C class labels, optionally filtering by literal substring.",
"Discover Objective-C class labels, optionally filtering by literal substring. Rows are coalesced only when exact selected-image pointer-fixup evidence proves that their own addresses bind the same imported class symbol, binding stream, raw library ordinal and resolved library, weak status, and addend. Rows without that evidence remain separate, with identity coverage reported as unknown, partial, or unsupported.",
enhancedInputSchemas.get_objc_classes,
),
enhanced(
+2 -1
View File
@@ -88,6 +88,7 @@ import {
lifecycleResultOf,
nullableText,
procedureInfoOutput,
objcClassDiscoveryOutput,
evidenceResultOf,
segmentOutput,
sessionProvider,
@@ -309,7 +310,7 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
),
trace_native_values: evidenceResultOf(nativeValueTraceSchema),
trace_native_ui_action: evidenceResultOf(nativeInvestigationTraceSchema),
get_objc_classes: symbolDiscoveryOutput("classes"),
get_objc_classes: objcClassDiscoveryOutput,
get_objc_protocols: symbolDiscoveryOutput("protocols"),
batch_decompile: evidenceResultOf(
z.object({
@@ -23,6 +23,7 @@ import {
import { analysisErrorProjectionSchema } from "./errorSchemas.js";
import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js";
import { ZIP_PACKAGE_FORMATS } from "../domain/zipPackageFormat.js";
import { objcClassImportIdentitySchema } from "../domain/native/objcSwiftMetadata.js";
/** Specialize the single normalized result inside a complete Evidence record. */
export const evidenceResultOf = <Schema extends z.ZodType>(schema: Schema) =>
@@ -606,6 +607,32 @@ export const symbolDiscoveryOutput = (property: "classes" | "protocols") =>
}),
);
export const objcClassDiscoveryOutput = evidenceResultOf(
z.object({
count: z.number().int().min(0),
classes: z.array(
addressedEntry.extend({
binding_identity: objcClassImportIdentitySchema
.extend({
slot_addresses: z.array(z.string().min(1)).min(1),
})
.optional(),
}),
),
identity_coverage: z.strictObject({
status: z.enum([
"complete",
"partial",
"unsupported",
"not_requested",
"unknown",
"not_applicable",
]),
reason: z.string().nullable(),
}),
}),
);
export const graphNode = z.discriminatedUnion("status", [
z.object({
address: z.string(),
+38 -1
View File
@@ -1,8 +1,13 @@
import { z } from "zod";
import { isMachOFormat, type BinaryTarget } from "../binaryTargetTypes.js";
import {
BINARY_ARCHITECTURES,
isMachOFormat,
type BinaryTarget,
} from "../binaryTargetTypes.js";
import { providerSchema } from "../evidence.js";
import { isSwiftMangledSymbolName } from "../swiftSymbolClassification.js";
import { nativeCallReferenceSchema } from "./nativeCallReference.js";
import { machoTargetSelectionSchema } from "../apple/machoTargetSelection.js";
import {
compareUnicodeCodePoints,
@@ -248,6 +253,33 @@ export const nativeMetadataCoverageSchema = z.strictObject({
decoded: z.number().int().nonnegative(),
});
/** Exact Mach-O import binding observed at a caller-selected pointer slot. */
export const objcClassImportBindingSchema = z.strictObject({
requested_address: z.string().min(1),
symbol: z.string().min(1),
binding_source: z.enum(["chained", "bind", "weak", "lazy"]),
library_ordinal: z.number().int().min(-3).max(Number.MAX_SAFE_INTEGER),
library: z.string().nullable(),
weak: z.boolean(),
addend: z.string().regex(/^-?\d+$/u),
});
export const objcClassImportIdentitySchema = objcClassImportBindingSchema.omit({
requested_address: true,
});
export const objcClassImportBindingCoverageSchema = z.strictObject({
facet: z.literal("objc_class_import_bindings"),
status: z.enum(["complete", "partial", "unsupported", "not_requested"]),
reason: z.string().nullable(),
examined: z.number().int().nonnegative(),
decoded: z.number().int().nonnegative(),
});
export type ObjcClassImportBinding = z.infer<
typeof objcClassImportBindingSchema
>;
export type ObjcClassImportBindingCoverage = z.infer<
typeof objcClassImportBindingCoverageSchema
>;
/** Swift declaration kind. */
export const swiftDeclKindSchema = z.enum([
"class",
@@ -316,6 +348,10 @@ export const objcSwiftMetadataSchema = z.strictObject({
swift_conformances: z.array(swiftConformanceSchema).default([]),
swift_dispatch_slots: z.array(swiftDispatchSlotSchema).default([]),
swift_symbols: z.array(swiftSymbolSchema).default([]),
objc_class_import_bindings: z.array(objcClassImportBindingSchema).optional(),
objc_class_import_binding_coverage:
objcClassImportBindingCoverageSchema.optional(),
target_selection: machoTargetSelectionSchema.optional(),
relative_pointers: z.array(relativePointerSchema).default([]),
coverage: z.array(nativeMetadataCoverageSchema).default([]),
db_save_result: dbSaveResultSchema.nullable(),
@@ -328,6 +364,7 @@ export const nativeDispatchMetadataResultSchema = z.strictObject({
.string()
.regex(/^[a-f0-9]{64}$/u)
.nullable(),
target_architecture: z.enum(BINARY_ARCHITECTURES).nullable().optional(),
provider: z.strictObject({
id: z.string().min(1),
name: z.string().min(1),
+180 -2
View File
@@ -1,6 +1,20 @@
import { describe, expect, it } from "vitest";
import { discoverObjcClasses } from "./symbolAnalysis.js";
import {
discoverObjcClasses,
projectObjcClassCandidates,
} from "./symbolAnalysis.js";
const completeCoverage = { status: "complete" as const, reason: null };
const classImport = {
requested_address: "0x1000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "bind" as const,
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: false,
addend: "0",
};
describe("Objective-C class label discovery", () => {
it("does not reinterpret a provider-specific label without an owner tag", () => {
@@ -9,7 +23,11 @@ describe("Objective-C class label discovery", () => {
[{ address: "0x1", name: "__objc_class_HuntRoot_class" }],
"Hunt",
),
).toEqual({ count: 0, classes: [] });
).toEqual({
count: 0,
classes: [],
identity_coverage: { status: "not_applicable", reason: null },
});
});
it("uses provider-tagged stripped class labels and filters their exact text", () => {
@@ -39,10 +57,170 @@ describe("Objective-C class label discovery", () => {
address,
name,
})),
identity_coverage: {
status: "unknown",
reason: "Exact class-import binding identity is unavailable.",
},
});
expect(discoverObjcClasses(names, "__objc_class_HuntRoot_class")).toEqual({
count: 1,
classes: [{ address: "0x1", name: "__objc_class_HuntRoot_class" }],
identity_coverage: {
status: "unknown",
reason: "Exact class-import binding identity is unavailable.",
},
});
});
it("coalesces only selected rows whose own addresses prove the same import", () => {
const alias = { address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" };
const canonical = { address: "0x1000", name: "OBJC_CLASS_$_Document" };
const unrelated = { address: "0x2000", name: "OBJC_CLASS_$_Document" };
const scan = {
bindings: [classImport],
coverage: completeCoverage,
};
expect(
projectObjcClassCandidates([alias, canonical, unrelated], scan),
).toEqual({
count: 2,
classes: [
{
...canonical,
binding_identity: {
symbol: classImport.symbol,
binding_source: classImport.binding_source,
library_ordinal: classImport.library_ordinal,
library: classImport.library,
weak: false,
addend: "0",
slot_addresses: ["0x1000"],
},
},
unrelated,
],
identity_coverage: completeCoverage,
});
expect(discoverObjcClasses([alias, canonical], "$GOT", scan)).toEqual({
count: 1,
classes: [
{
...alias,
binding_identity: {
symbol: classImport.symbol,
binding_source: classImport.binding_source,
library_ordinal: classImport.library_ordinal,
library: classImport.library,
weak: false,
addend: "0",
slot_addresses: ["0x1000"],
},
},
],
identity_coverage: completeCoverage,
});
});
it("does not join same-name rows without exact slot evidence", () => {
const alias = { address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" };
const canonical = { address: "0x2000", name: "OBJC_CLASS_$_Document" };
expect(
projectObjcClassCandidates([alias, canonical], {
bindings: [classImport],
coverage: completeCoverage,
}),
).toMatchObject({ count: 2, classes: [alias, canonical] });
});
it("keeps rows separate when equal symbols have different bind identities", () => {
const rows = [
{ address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x2000", name: "OBJC_CLASS_$_Document$GOT" },
];
expect(
projectObjcClassCandidates(rows, {
bindings: [
classImport,
{
...classImport,
requested_address: "0x2000",
library: "/other/framework.dylib",
},
],
coverage: completeCoverage,
}),
).toMatchObject({ count: 2, classes: rows });
});
it("keeps same-named candidates at distinct addresses until bind identity is known", () => {
const rows = [
{ address: "0x1000", name: "OBJC_CLASS_$_Document" },
{ address: "0x1000", name: "OBJC_CLASS_$_Document" },
{ address: "0x2000", name: "OBJC_CLASS_$_Document" },
];
expect(discoverObjcClasses(rows, "")).toEqual({
count: 2,
classes: [rows[0], rows[2]],
identity_coverage: {
status: "unknown",
reason: "Exact class-import binding identity is unavailable.",
},
});
});
});
it("keeps equal symbols separate when only their lookup ordinals differ", () => {
const rows = [
{ address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x2000", name: "OBJC_CLASS_$_Document$GOT" },
];
expect(
discoverObjcClasses(rows, "", {
bindings: [
{ ...classImport, library_ordinal: -1, library: null },
{
...classImport,
requested_address: "0x2000",
library_ordinal: -2,
library: null,
},
],
coverage: completeCoverage,
}),
).toMatchObject({
count: 2,
classes: [
{ ...rows[0], binding_identity: { library_ordinal: -1 } },
{ ...rows[1], binding_identity: { library_ordinal: -2 } },
],
});
});
it("keeps regular self-scope and weak rebinding records separate", () => {
const rows = [
{ address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x2000", name: "OBJC_CLASS_$_Document$GOT" },
];
expect(
discoverObjcClasses(rows, "", {
bindings: [
{ ...classImport, library_ordinal: 0, library: null },
{
...classImport,
requested_address: "0x2000",
binding_source: "weak",
library_ordinal: 0,
library: null,
},
],
coverage: completeCoverage,
}),
).toMatchObject({
count: 2,
classes: [
{ ...rows[0], binding_identity: { binding_source: "bind" } },
{ ...rows[1], binding_identity: { binding_source: "weak" } },
],
});
});
+191 -5
View File
@@ -1,5 +1,9 @@
import type { JsonValue } from "./jsonValue.js";
import type { AddressedName } from "./hopperValues.js";
import type {
ObjcClassImportBinding,
ObjcClassImportBindingCoverage,
} from "./native/objcSwiftMetadata.js";
import {
classifySwiftSymbol,
isSwiftMangledSymbolName,
@@ -18,12 +22,25 @@ const LEGACY_OBJC_CLASS_PREFIX = "l_OBJC_CLASS_" as const;
*/
const OBJC_PROTOCOL_MARKER = "OBJC_PROTOCOL_$_" as const;
/** Select and deduplicate Objective-C class labels. */
export const discoverObjcClasses = (
export interface ObjcClassDiscoveryCoverage {
readonly status:
| ObjcClassImportBindingCoverage["status"]
| "unknown"
| "not_applicable";
readonly reason: string | null;
}
export interface ObjcClassImportBindingScan {
readonly bindings: readonly ObjcClassImportBinding[];
readonly coverage: ObjcClassDiscoveryCoverage;
}
/** Select class labels and apply the caller's literal filter before identity joins. */
export const selectObjcClassCandidates = (
names: readonly AddressedName[],
pattern: string,
): JsonValue => {
const classes = uniqueByName(
): AddressedName[] =>
uniqueByAddressAndName(
names
.filter(
({ name, objc_class_label }) =>
@@ -35,12 +52,43 @@ export const discoverObjcClasses = (
)
.filter(({ name }) => pattern.length === 0 || name.includes(pattern)),
);
/** Project selected class labels, coalescing only exact proven import identities. */
export const projectObjcClassCandidates = (
candidates: readonly AddressedName[],
bindingScan: ObjcClassImportBindingScan = {
bindings: [],
coverage: {
status: candidates.length === 0 ? "not_applicable" : "unknown",
reason:
candidates.length === 0
? null
: "Exact class-import binding identity is unavailable.",
},
},
): JsonValue => {
const classes = coalesceBoundClassAliases(candidates, bindingScan.bindings);
return {
count: classes.length,
classes: classes.map(toJsonEntry),
classes: classes.map(toObjcClassEntry),
identity_coverage: {
status: bindingScan.coverage.status,
reason: bindingScan.coverage.reason,
},
};
};
/** Select, then project Objective-C class labels for provider-neutral callers. */
export const discoverObjcClasses = (
names: readonly AddressedName[],
pattern: string,
bindingScan?: ObjcClassImportBindingScan,
): JsonValue =>
projectObjcClassCandidates(
selectObjcClassCandidates(names, pattern),
bindingScan,
);
/** Select and deduplicate Objective-C and Swift protocol labels. */
export const discoverObjcProtocols = (
names: readonly AddressedName[],
@@ -185,6 +233,144 @@ const uniqueByName = (entries: readonly AddressedName[]): AddressedName[] => {
});
};
/** Keep same-named rows at distinct locations until slot identity is examined. */
const uniqueByAddressAndName = (
entries: readonly AddressedName[],
): AddressedName[] => {
const seen = new Set<string>();
return entries.filter(({ address, name }) => {
const key = JSON.stringify([address, name]);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
interface ObjcClassOutputEntry extends AddressedName {
readonly binding_identity?: Omit<
ObjcClassImportBinding,
"requested_address"
> & {
readonly slot_addresses: readonly string[];
};
}
const coalesceBoundClassAliases = (
candidates: readonly AddressedName[],
bindings: readonly ObjcClassImportBinding[],
): ObjcClassOutputEntry[] => {
const bindingsByAddress = new Map(
bindings.map((binding) => [binding.requested_address, binding]),
);
const bindingKey = (binding: ObjcClassImportBinding) =>
JSON.stringify([
binding.symbol,
binding.binding_source,
binding.library_ordinal,
binding.library,
binding.weak,
binding.addend,
]);
const groupedBindings = new Map<string, ObjcClassImportBinding[]>();
for (const binding of bindings) {
const key = bindingKey(binding);
const existing = groupedBindings.get(key);
if (existing === undefined) groupedBindings.set(key, [binding]);
else existing.push(binding);
}
const groupedCandidates = new Map<string, AddressedName[]>();
const ordered: Array<AddressedName | { readonly group: string }> = [];
for (const candidate of candidates) {
const binding = bindingsByAddress.get(candidate.address);
if (binding === undefined) {
ordered.push(candidate);
continue;
}
const key = bindingKey(binding);
let group = groupedCandidates.get(key);
if (group === undefined) {
group = [];
groupedCandidates.set(key, group);
ordered.push({ group: key });
}
group.push(candidate);
}
const output: ObjcClassOutputEntry[] = [];
for (const entry of ordered) {
if ("group" in entry) {
const key = entry.group;
const facts = groupedBindings.get(key) ?? [];
const group = groupedCandidates.get(key) ?? [];
const identity = facts[0];
const canonicalName =
identity === undefined
? undefined
: classNameFromImport(identity.symbol);
const representative =
(canonicalName === undefined
? undefined
: group.find(
({ name }) => classNameFromDisplayName(name) === canonicalName,
)) ?? group[0];
if (representative === undefined || identity === undefined) continue;
output.push({
...representative,
binding_identity: {
symbol: identity.symbol,
binding_source: identity.binding_source,
library_ordinal: identity.library_ordinal,
library: identity.library,
weak: identity.weak,
addend: identity.addend,
slot_addresses: [
...new Set(facts.map(({ requested_address }) => requested_address)),
],
},
});
} else {
const candidate = entry;
output.push(candidate);
}
}
return output;
};
const classNameFromImport = (symbol: string): string | undefined => {
const canonical = symbol.startsWith("_") ? symbol.slice(1) : symbol;
return canonical.startsWith(OBJC_CLASS_MARKER) &&
canonical.length > OBJC_CLASS_MARKER.length
? canonical
: undefined;
};
const classNameFromDisplayName = (name: string): string | undefined =>
name.startsWith(OBJC_CLASS_MARKER) && name.length > OBJC_CLASS_MARKER.length
? name
: name.startsWith(`_${OBJC_CLASS_MARKER}`) &&
name.length > OBJC_CLASS_MARKER.length + 1
? name.slice(1)
: undefined;
const toObjcClassEntry = (entry: ObjcClassOutputEntry): JsonValue => ({
address: entry.address,
name: entry.name,
...(entry.binding_identity === undefined
? {}
: {
binding_identity: {
symbol: entry.binding_identity.symbol,
binding_source: entry.binding_identity.binding_source,
library_ordinal: entry.binding_identity.library_ordinal,
library: entry.binding_identity.library,
weak: entry.binding_identity.weak,
addend: entry.binding_identity.addend,
slot_addresses: [...entry.binding_identity.slot_addresses],
},
}),
});
const toJsonEntry = ({ address, name }: AddressedName): JsonValue => ({
address,
name,
+12 -6
View File
@@ -1048,20 +1048,26 @@ describe("byte-swapped universal Apple dispatch metadata", () => {
},
);
it("validates the selected thin slice's own byte order", () => {
it("reads the selected thin slice's CPU identity in its own byte order", () => {
const bytes = wrapped(false);
bytes.writeUInt32BE(0xfeedfacf, 256);
bytes.writeUInt32BE(0x0100000c, 260);
bytes.writeUInt32BE(0, 264);
expect(() => decodeAppleDispatchMetadata(bytes, 100, provenance)).toThrow(
"Only little-endian 64-bit Mach-O metadata is supported",
"Unsupported big-endian Mach-O CPU type",
);
});
it("rejects a 32-bit slice payload inside a little-endian container", () => {
const bytes = wrapped(false);
bytes.writeUInt32BE(0xfeedface, 256);
expect(() => decodeAppleDispatchMetadata(bytes, 100, provenance)).toThrow(
"Unsupported 32-bit Mach-O metadata for requested CPU type arm64 (FAT header byte order: little-endian)",
);
bytes.writeUInt32LE(12, 8);
bytes.writeUInt32LE(0, 12);
bytes.writeUInt32LE(0xfeedface, 256);
bytes.writeUInt32LE(12, 260);
bytes.writeUInt32LE(0, 264);
expect(() =>
decodeAppleDispatchMetadata(bytes, 100, provenance, "arm"),
).toThrow("Unsupported 32-bit Mach-O metadata for requested CPU type arm");
});
it("checks the full little-endian FAT64 slice extent", () => {
+48 -2
View File
@@ -35,7 +35,10 @@ import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
import { pushDispatchCoverage } from "./AppleDispatchCoverage.js";
import { parsePointerFixups } from "./AppleMachoFixups.js";
import { parseMachoLayout } from "./AppleMachoSelection.js";
import {
parseMachoLayout,
UnsupportedMachoMetadataFormatError,
} from "./AppleMachoSelection.js";
import { decodeObjcDispatchFacets } from "./AppleObjcDispatchFacets.js";
import {
createSwiftRelativeReader,
@@ -43,6 +46,7 @@ import {
type DispatchRecordBudget,
} from "./AppleSwiftDispatchFacets.js";
import { decodeSwiftClassVtables } from "./AppleSwiftVtables.js";
import { inspectObjcClassImportBindings } from "./AppleObjcClassImportBindings.js";
/** Decode validated little-endian 64-bit Apple Objective-C metadata without loading target code. */
export const decodeAppleDispatchMetadata = (
@@ -50,6 +54,8 @@ export const decodeAppleDispatchMetadata = (
maxRecords: number,
provenance: { path: string; sha256: string },
architecture = "arm64",
objcClassImportAddresses?: readonly string[],
objcClassImportAddressesComplete = true,
): ObjcSwiftMetadata => {
const layout = parseMachoLayout(bytes, architecture);
const { segments, sections, offset } = layout;
@@ -59,6 +65,14 @@ export const decodeAppleDispatchMetadata = (
segments,
layout.baseAddress,
);
const objcClassImports = inspectObjcClassImportBindings({
bytes,
layout,
fixups,
addresses: objcClassImportAddresses,
addressesComplete: objcClassImportAddressesComplete,
maxRecords,
});
const u32 = (address: bigint) => bytes.readUInt32LE(offset(address, 4));
const decodePointer = (address: bigint) =>
fixups.decode(address, bytes.readBigUInt64LE(offset(address, 8)));
@@ -96,7 +110,27 @@ export const decodeAppleDispatchMetadata = (
bytes.subarray(start, end),
);
};
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const result = objcSwiftMetadataSchema.parse({
db_save_result: null,
target_selection: {
kind: "macho-selected-image-v1",
container: layout.selectedImage.container,
offset: layout.selectedImage.offset,
byte_length: layout.selectedImage.byteLength,
cpu_type: layout.selectedImage.cpuType,
cpu_subtype: layout.selectedImage.cpuSubtype,
container_table_sha256: layout.selectedImage.containerTableSha256,
},
...(objcClassImportAddresses === undefined
? {}
: {
objc_class_import_bindings: objcClassImports.bindings,
objc_class_import_binding_coverage: {
facet: "objc_class_import_bindings",
...objcClassImports.coverage,
},
}),
});
let records = 0;
const budget: DispatchRecordBudget = {
truncated: false,
@@ -208,6 +242,8 @@ export const inspectAppleDispatchMetadata = async (
maxRecords: number,
resources: ArtifactResourceScope,
signal?: AbortSignal,
objcClassImportAddresses?: readonly string[],
objcClassImportAddressesComplete = true,
) => {
if (target.kind !== "executable" || !isMachOFormat(target.format))
throw new TypeError("Apple dispatch metadata requires a Mach-O target");
@@ -257,6 +293,7 @@ export const inspectAppleDispatchMetadata = async (
);
return nativeDispatchMetadataResultSchema.parse({
target_sha256: target.sha256,
target_architecture: target.architecture,
provider: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider,
analysis_profile_digest: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.digest,
result: decodeAppleDispatchMetadata(
@@ -264,6 +301,8 @@ export const inspectAppleDispatchMetadata = async (
maxRecords,
{ path: target.path, sha256: digest },
target.architecture,
objcClassImportAddresses,
objcClassImportAddressesComplete,
),
});
};
@@ -302,6 +341,13 @@ export const appleDispatchMetadataFailure = (
return failure;
}
if (cause instanceof AnalysisError) return cause;
if (cause instanceof UnsupportedMachoMetadataFormatError)
return new AnalysisCapabilityUnavailableError(
IDENTITY.id,
OPERATION,
cause.message,
{ cause },
);
if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled")
return new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(cause),
+173 -30
View File
@@ -11,17 +11,21 @@ const BASE = 0x100000000n;
const IMPORTS = [
{
symbol: "_OBJC_CLASS_$_NSObject",
binding_source: "chained",
library_ordinal: 1,
library: "/usr/lib/libobjc.A.dylib",
weak: false,
addend: 0n,
},
{
symbol: "_free",
binding_source: "chained",
library_ordinal: 2,
library: "/usr/lib/libSystem.B.dylib",
weak: true,
addend: 4n,
},
];
] as const;
const field = (value: bigint, from: number): bigint => value << BigInt(from);
@@ -40,6 +44,8 @@ describe("chained pointer decoding", () => {
expect(decodeChainedPointer(bind, 6, BASE, IMPORTS)).toEqual({
kind: "bind",
symbol: "_free",
binding_source: "chained",
library_ordinal: 2,
library: "/usr/lib/libSystem.B.dylib",
weak: true,
addend: 6n,
@@ -190,7 +196,7 @@ describe("independent chained segment coverage", () => {
chained: { offset: 0, size: imports },
binds: [],
rebases: [],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
},
segments,
BASE,
@@ -217,7 +223,7 @@ describe("pointer fixup tables", () => {
chained: { offset: 0, size: data.length },
binds: [],
rebases: [],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
BASE,
@@ -233,7 +239,7 @@ describe("pointer fixup tables", () => {
chained: { offset: 0, size: data.length },
binds: [],
rebases: [{ offset: 8, size: 12 }],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
BASE,
@@ -255,7 +261,7 @@ describe("pointer fixup tables", () => {
chained: { offset: 0, size: 128 },
binds: [],
rebases: [{ offset: 8, size: 12 }],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
BASE,
@@ -330,7 +336,7 @@ describe("parsed chained pointer membership", () => {
chained: { offset: 0, size: 128 },
binds: [],
rebases: [],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
BASE,
@@ -422,7 +428,7 @@ describe("parsed chained pointer membership", () => {
chained: { offset: 0, size: imports },
binds: [],
rebases: [],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
},
segments,
BASE,
@@ -479,7 +485,7 @@ const aliasedStarts = (format: number, count = 32, pages = 128) => {
chained: { offset: 0, size: end },
binds: [],
rebases: [],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
},
};
};
@@ -609,7 +615,7 @@ describe("chained pointer membership", () => {
chained: { offset: 0, size: 160 },
binds: [],
rebases: [],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
BASE,
@@ -639,7 +645,7 @@ describe("chained pointer membership", () => {
chained: { offset: 0, size: 128 },
binds: [],
rebases: [],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
BASE,
@@ -675,7 +681,7 @@ describe("chained pointer membership", () => {
chained: { offset: 0, size: 128 },
binds: [],
rebases: [],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
} as const;
const fixups = parsePointerFixups(data, commands, SEGMENTS, BASE);
expect(fixups.decode(BASE + 0x4008n, 0n)).toEqual({
@@ -749,7 +755,7 @@ describe("malformed chained pointer coverage", () => {
chained: { offset: 0, size: 128 },
binds: [],
rebases: [],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
BASE,
@@ -785,7 +791,7 @@ describe("malformed chained pointer coverage", () => {
chained: { offset: 0, size: 128 },
binds: [],
rebases: [],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
BASE,
@@ -796,32 +802,26 @@ describe("malformed chained pointer coverage", () => {
});
describe("chained import library identity", () => {
it.each([1, 2, 3] as const)(
"retains positive and special library ordinals for import format %i",
"retains valid positive and special ordinals and rejects invalid ones for format %i",
(format) => {
const data = chainedData(format);
const importAt = data.readUInt32LE(8);
const width = format === 3 ? 16 : 8;
const maximum = format === 3 ? 0xfff0 : 0xf0;
const libraries = Array.from(
{ length: maximum },
(_, index) => `/library-${index + 1}`,
);
for (const ordinal of [
1 << (width - 1),
maximum,
maximum + 1,
(1 << width) - 1,
]) {
const libraries = ["/library-1"];
const writeOrdinal = (ordinal: number) => {
if (format === 3)
data.writeBigUInt64LE(
(data.readBigUInt64LE(importAt) & ~0xffffn) | BigInt(ordinal),
(data.readBigUInt64LE(importAt) & ~0xffffn) |
BigInt(ordinal & 0xffff),
importAt,
);
else
data.writeUInt32LE(
(data.readUInt32LE(importAt) & ~0xff) | ordinal,
(data.readUInt32LE(importAt) & ~0xff) | (ordinal & 0xff),
importAt,
);
};
for (const ordinal of [1, 0, -1, -2, -3]) {
writeOrdinal(ordinal);
data.writeBigUInt64LE(field(1n, 63), 0x4008);
const fixups = parsePointerFixups(
data,
@@ -838,9 +838,46 @@ describe("chained import library identity", () => {
fixups.decode(BASE + 0x4008n, data.readBigUInt64LE(0x4008)),
).toMatchObject({
kind: "bind",
library: ordinal <= maximum ? libraries[ordinal - 1] : null,
library_ordinal: ordinal,
library: ordinal === 1 ? libraries[0] : null,
});
}
writeOrdinal(2);
const invalidPositive = parsePointerFixups(
data,
{
chained: { offset: 0, size: 160 },
binds: [],
rebases: [],
dylibs: libraries,
},
SEGMENTS,
BASE,
);
expect(
invalidPositive.decode(BASE + 0x4008n, data.readBigUInt64LE(0x4008)),
).toMatchObject({
kind: "unsupported",
reason: "Dylib ordinal 2 exceeds the 1 declared dependencies",
});
writeOrdinal(-4);
const invalidSpecial = parsePointerFixups(
data,
{
chained: { offset: 0, size: 160 },
binds: [],
rebases: [],
dylibs: libraries,
},
SEGMENTS,
BASE,
);
expect(
invalidSpecial.decode(BASE + 0x4008n, data.readBigUInt64LE(0x4008)),
).toMatchObject({
kind: "unsupported",
reason: "Unsupported dylib ordinal -4",
});
},
);
});
@@ -948,6 +985,34 @@ describe("legacy pointer fixup streams", () => {
target: 0x99n,
});
});
it("retains weak-bind stream provenance independently from its default ordinal", () => {
const opcodes = Buffer.from([
0x40,
...Buffer.from("_OBJC_CLASS_$_NSObject\0"),
0x71,
0x18,
0x90,
0x00,
]);
const fixups = parsePointerFixups(
opcodes,
{
chained: null,
binds: [{ offset: 0, size: opcodes.length, stream: "weak" }],
rebases: [],
dylibs: [],
},
SEGMENTS,
BASE,
);
expect(fixups.decode(BASE + 0x4018n, 0n)).toMatchObject({
kind: "bind",
binding_source: "weak",
library_ordinal: 0,
library: null,
});
});
});
describe("legacy bind operand decoding", () => {
@@ -1045,7 +1110,7 @@ describe("legacy bind operand decoding", () => {
chained: null,
binds: [{ offset: 0, size: opcodes.length - 1, stream: "bind" }],
rebases: [],
dylibs: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
BASE,
@@ -1149,3 +1214,81 @@ describe("Objective-C property attributes", () => {
});
});
});
it("preserves classic special ordinals and returns unsupported at invalid bind locations", () => {
const parseOrdinal = (ordinal: number, dylibs: readonly string[]) => {
const immediate = ordinal < 0 ? ordinal + 16 : ordinal;
const opcodes = Buffer.from([
ordinal > 0 ? 0x10 | ordinal : 0x30 | immediate,
0x40,
...Buffer.from("_OBJC_CLASS_$_NSObject\0"),
0x71,
0x08,
0x90,
0x00,
]);
return parsePointerFixups(
opcodes,
{
chained: null,
binds: [{ offset: 0, size: opcodes.length, stream: "bind" }],
rebases: [],
dylibs,
},
SEGMENTS,
BASE,
);
};
for (const ordinal of [0, -1, -2, -3]) {
const fixups = parseOrdinal(ordinal, []);
expect(fixups.decode(BASE + 0x4008n, 0n)).toMatchObject({
kind: "bind",
symbol: "_OBJC_CLASS_$_NSObject",
library_ordinal: ordinal,
library: null,
});
}
for (const [ordinal, dylibs, reason] of [
[1, [], "Dylib ordinal 1 exceeds the 0 declared dependencies"],
[-4, [], "Unsupported dylib ordinal -4"],
] as const) {
const fixups = parseOrdinal(ordinal, dylibs);
expect(fixups.decode(BASE + 0x4008n, 0n)).toEqual({
kind: "unsupported",
reason,
});
}
const oversized = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
let remaining = oversized;
const encoded: number[] = [];
while (remaining >= 0x80n) {
encoded.push(Number(remaining & 0x7fn) | 0x80);
remaining >>= 7n;
}
encoded.push(Number(remaining));
const oversizedStream = Buffer.from([
0x20,
...encoded,
0x40,
...Buffer.from("_OBJC_CLASS_$_NSObject\0"),
0x71,
0x08,
0x90,
0x00,
]);
const oversizedFixups = parsePointerFixups(
oversizedStream,
{
chained: null,
binds: [{ offset: 0, size: oversizedStream.length, stream: "bind" }],
rebases: [],
dylibs: [],
},
SEGMENTS,
BASE,
);
expect(oversizedFixups.decode(BASE + 0x4008n, 0n)).toEqual({
kind: "unsupported",
reason: `Dylib ordinal ${oversized} exceeds the safe integer range`,
});
});
+78 -18
View File
@@ -27,6 +27,10 @@ export type DecodedPointer =
| {
readonly kind: "bind";
readonly symbol: string;
/** Producer stream, which is part of lookup identity for weak binds. */
readonly binding_source: "chained" | "bind" | "weak" | "lazy";
/** Signed dyld library ordinal; 0 and negative values are special scopes. */
readonly library_ordinal: number;
readonly library: string | null;
readonly weak: boolean;
readonly addend: bigint;
@@ -66,6 +70,8 @@ const signed = (value: bigint, width: number): bigint =>
interface Import {
readonly symbol: string;
readonly binding_source: "chained" | "bind" | "weak" | "lazy";
readonly library_ordinal: number;
readonly library: string | null;
readonly weak: boolean;
readonly addend: bigint;
@@ -75,15 +81,26 @@ interface BindRun {
readonly start: bigint;
count: bigint;
readonly stride: bigint;
readonly binding: Import;
readonly binding:
| { readonly kind: "bind"; readonly value: Import }
| { readonly kind: "unsupported"; readonly reason: string };
}
/** Library ordinal to install name; 0 and negative ordinals are special lookups. */
/** Resolve only valid dylib ordinals; preserve special scopes as raw ordinals. */
const libraryName = (
ordinal: number,
dylibs: readonly string[],
): string | null => {
if (ordinal > 0) return dylibs[ordinal - 1] ?? null;
if (!Number.isSafeInteger(ordinal) || ordinal < -3)
throw new RangeError(`Unsupported dylib ordinal ${String(ordinal)}`);
if (ordinal > 0) {
const name = dylibs[ordinal - 1];
if (name === undefined)
throw new RangeError(
`Dylib ordinal ${ordinal} exceeds the ${dylibs.length} declared dependencies`,
);
return name;
}
return null;
};
@@ -132,6 +149,8 @@ const readImport = (
base + header.symbols + Number(bits(word, 32, 32)),
end,
),
binding_source: "chained",
library_ordinal: ordinal,
library: libraryName(ordinal, dylibs),
weak: bits(word, 16, 1) === 1n,
addend: bytes.readBigInt64LE(at + 8),
@@ -143,6 +162,8 @@ const readImport = (
encodedOrdinal > 0xf0 ? encodedOrdinal - 0x100 : encodedOrdinal;
return {
symbol: cString(bytes, base + header.symbols + (word >>> 9), end),
binding_source: "chained",
library_ordinal: ordinal,
library: libraryName(ordinal, dylibs),
weak: ((word >>> 8) & 1) === 1,
addend: header.format === 2 ? BigInt(bytes.readInt32LE(at + 4)) : 0n,
@@ -549,6 +570,7 @@ const sleb = (bytes: Buffer, cursor: { at: number }, end: number): bigint => {
/** Registers of the `LC_DYLD_INFO` bind state machine. */
interface BindState {
ordinal: number;
ordinalFailure: string | null;
segmentIndex: number;
symbol: string;
weak: boolean;
@@ -569,12 +591,23 @@ const setBindRegister = (
switch (byte & 0xf0) {
case 0x10:
state.ordinal = immediate;
state.ordinalFailure = null;
return true;
case 0x20:
state.ordinal = Number(uleb(bytes, cursor, end));
{
const ordinal = uleb(bytes, cursor, end);
if (ordinal > BigInt(Number.MAX_SAFE_INTEGER)) {
state.ordinal = 0;
state.ordinalFailure = `Dylib ordinal ${ordinal} exceeds the safe integer range`;
} else {
state.ordinal = Number(ordinal);
state.ordinalFailure = null;
}
}
return true;
case 0x30:
state.ordinal = immediate === 0 ? 0 : immediate - 16;
state.ordinalFailure = null;
return true;
case 0x40: {
const stop = bytes.indexOf(0, cursor.at);
@@ -621,6 +654,7 @@ const readBindStream = (
const cursor = { at: range.offset };
const state: BindState = {
ordinal: 0,
ordinalFailure: null,
segmentIndex: -1,
symbol: "",
weak: false,
@@ -641,24 +675,48 @@ const readBindStream = (
if (count === 0n) return;
preflightRun(count, skip);
const stride = 8n + skip;
const binding: Import = {
symbol: state.symbol,
library:
range.stream === "weak"
? null
: libraryName(state.ordinal, image.dylibs),
weak: state.weak,
addend: state.addend,
};
let binding: BindRun["binding"];
try {
if (state.ordinalFailure !== null)
throw new RangeError(state.ordinalFailure);
const resolvedLibrary = libraryName(state.ordinal, image.dylibs);
binding = {
kind: "bind",
value: {
symbol: state.symbol,
binding_source: range.stream,
library_ordinal: state.ordinal,
library: range.stream === "weak" ? null : resolvedLibrary,
weak: state.weak,
addend: state.addend,
},
};
} catch (cause: unknown) {
binding = {
kind: "unsupported",
reason: cause instanceof Error ? cause.message : String(cause),
};
}
const previous = binds.at(-1);
const sameBinding = (
left: BindRun["binding"],
right: BindRun["binding"],
) =>
left.kind === "unsupported" && right.kind === "unsupported"
? left.reason === right.reason
: left.kind === "bind" && right.kind === "bind"
? left.value.symbol === right.value.symbol &&
left.value.binding_source === right.value.binding_source &&
left.value.library_ordinal === right.value.library_ordinal &&
left.value.library === right.value.library &&
left.value.weak === right.value.weak &&
left.value.addend === right.value.addend
: false;
if (
previous !== undefined &&
previous.stride === stride &&
previous.start + previous.count * stride === state.address &&
previous.binding.symbol === binding.symbol &&
previous.binding.library === binding.library &&
previous.binding.weak === binding.weak &&
previous.binding.addend === binding.addend
sameBinding(previous.binding, binding)
) {
previous.count += count;
} else {
@@ -769,7 +827,9 @@ export const parsePointerFixups = (
}
return match === undefined
? { kind: "rebase", target: raw }
: { kind: "bind", ...match.binding };
: match.binding.kind === "unsupported"
? match.binding
: { kind: "bind", ...match.binding.value };
},
};
};
+58 -1
View File
@@ -8,13 +8,33 @@ import {
machoImage,
machoImageWithUnknownCommand,
} from "../artifacts/apple/MachoImage.fixture.js";
import { parseMachoLayout, selectMachoSlice } from "./AppleMachoSelection.js";
import {
parseMachoLayout,
selectMachoSlice,
UnsupportedMachoMetadataFormatError,
} from "./AppleMachoSelection.js";
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
/** macOS 27 system binaries ship CPU_SUBTYPE_ARM64E_X1 (12) with the PAC ABI bit. */
const ARM64E_X1 = { type: 0x0100000c, subtype: 0x8000000c } as const;
const X86_64H = { type: 0x01000007, subtype: 8 } as const;
it("keeps unknown magic distinct from recognized unsupported Mach-O formats", () => {
expect(() => parseMachoLayout(Buffer.alloc(10), "arm64")).toThrow(
"Truncated Mach-O header",
);
const unknown = Buffer.alloc(32);
let failure: unknown;
try {
parseMachoLayout(unknown, "arm64");
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(TypeError);
expect(failure).not.toBeInstanceOf(UnsupportedMachoMetadataFormatError);
});
describe("native Mach-O slice selection", () => {
it("rejects misaligned load commands", () => {
expect(() =>
@@ -56,6 +76,43 @@ describe("native Mach-O slice selection", () => {
expect(parseMachoLayout(Buffer.from(fat), "arm64").segments).toEqual([]);
});
it("retains the selected image range and complete raw CPU identity", () => {
const thin = Buffer.from(machoImage({ cpu: CPU.arm64e }));
expect(parseMachoLayout(thin, "arm64").selectedImage).toEqual({
container: "thin",
offset: 0,
byteLength: thin.length,
cpuType: CPU.arm64e.type,
cpuSubtype: CPU.arm64e.subtype,
containerTableSha256: null,
});
const fat = Buffer.from(
fatImage([{ cpu: ARM64E_X1, bytes: machoImage({ cpu: ARM64E_X1 }) }]),
);
const selection = parseMachoLayout(fat, "arm64").selectedImage;
expect(selection).toMatchObject({
container: "fat",
cpuType: ARM64E_X1.type,
cpuSubtype: ARM64E_X1.subtype,
});
expect(selection.offset).toBeGreaterThan(0);
expect(selection.byteLength).toBeGreaterThan(0);
expect(selection.containerTableSha256).toMatch(/^[a-f0-9]{64}$/u);
});
it("rejects a FAT 32-bit declaration whose embedded CPU identity disagrees", () => {
const fat = fatImage([
{ cpu: CPU.i386, bytes: machoImage({ cpu: CPU.i386, wide: false }) },
]);
const sliceOffset = new DataView(fat.buffer).getUint32(16, false);
new DataView(fat.buffer).setUint32(sliceOffset + 4, CPU.arm64.type, true);
expect(() => parseMachoLayout(Buffer.from(fat), "x86")).toThrow(
/FAT architecture 0 CPU type\/subtype disagrees with its Mach-O header/u,
);
});
it("selects only the admitted CPU type and rejects a 32-bit slice", () => {
const sixtyFour = fatImage([
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
+68 -11
View File
@@ -1,3 +1,5 @@
import { createHash } from "node:crypto";
import type { FixupCommands } from "./AppleMachoFixups.js";
import {
MACHO_ARCHITECTURE_POLICIES,
@@ -46,6 +48,15 @@ export interface MachoSlice {
export interface MachoLayout {
segments: Segment[];
sections: Section[];
/** CPU identity and exact byte range selected from this source artifact. */
selectedImage: {
readonly container: "thin" | "fat";
readonly offset: number;
readonly byteLength: number;
readonly cpuType: number;
readonly cpuSubtype: number;
readonly containerTableSha256: string | null;
};
/** Fixup load commands and dependent dylibs of the selected slice. */
fixups: FixupCommands;
/** VM address of the segment that maps file offset 0 (the preferred load address). */
@@ -135,18 +146,20 @@ export const selectMachoSlice = (
sliceEnd: best.offset + best.size,
fatDeclaration: { ...best, tableEnd: headerEnd },
};
if (selected.slice + 4 > bytes.length)
throw malformed("Truncated Mach-O header");
const sliceMagic = bytes.readUInt32LE(selected.slice);
if (sliceMagic === MH_MAGIC || sliceMagic === MH_CIGAM)
throw invalid(
`Unsupported 32-bit Mach-O metadata for requested CPU type ${architecture}`,
);
return selected;
};
const MH_MAGIC = 0xfeedface;
const MH_CIGAM = 0xcefaedfe;
const MH_CIGAM_64 = 0xcffaedfe;
/** A recognized Mach-O class outside this metadata reader's supported formats. */
export class UnsupportedMachoMetadataFormatError extends Error {
constructor(message: string) {
super(message);
this.name = "UnsupportedMachoMetadataFormatError";
}
}
/** Parse segments/sections for a little-endian 64-bit Mach-O architecture slice. */
export const parseMachoLayout = (
@@ -156,20 +169,51 @@ export const parseMachoLayout = (
if (bytes.length < 4) throw new RangeError("Truncated Mach-O header");
const selection = selectMachoSlice(bytes, architecture);
const { slice, sliceEnd } = selection;
if (slice === 0 && sliceEnd === bytes.length && bytes.length < 32)
if (slice + 4 > sliceEnd) throw new RangeError("Truncated Mach-O header");
const selectedMagic = bytes.readUInt32LE(slice);
const is32Bit = selectedMagic === MH_MAGIC || selectedMagic === MH_CIGAM;
const isBigEndian =
selectedMagic === MH_CIGAM || selectedMagic === MH_CIGAM_64;
const headerSize = is32Bit ? 28 : 32;
const recognizedMagic =
is32Bit || selectedMagic === 0xfeedfacf || selectedMagic === MH_CIGAM_64;
if (!recognizedMagic && slice + 32 > sliceEnd)
throw new RangeError("Truncated Mach-O header");
if (slice + 32 > sliceEnd || bytes.readUInt32LE(slice) !== 0xfeedfacf)
if (!recognizedMagic)
throw new TypeError(
"Only little-endian 64-bit Mach-O metadata is supported",
);
if (slice + headerSize > sliceEnd)
throw new RangeError("Truncated Mach-O header");
const readHeaderUInt32 = isBigEndian
? (offset: number) => bytes.readUInt32BE(offset)
: (offset: number) => bytes.readUInt32LE(offset);
const cpuType = readHeaderUInt32(slice + 4);
const cpuSubtype = readHeaderUInt32(slice + 8);
const fat = selection.fatDeclaration;
if (fat !== undefined) {
const issue = validateFatSlice(fat, bytes.length, fat.tableEnd, {
cpuType: bytes.readUInt32LE(slice + 4),
cpuSubtype: bytes.readUInt32LE(slice + 8),
cpuType,
cpuSubtype,
});
if (issue !== null) throw new TypeError(issue);
}
if (is32Bit) {
if (MACHO_ARCHITECTURE_POLICIES[architecture]?.cpuType !== cpuType)
throw new TypeError(
`Mach-O CPU type does not match requested architecture ${architecture}`,
);
throw new UnsupportedMachoMetadataFormatError(
`Unsupported 32-bit Mach-O metadata for requested CPU type ${architecture}`,
);
}
if (selectedMagic === MH_CIGAM_64) {
if (cpuType !== 0x01000012)
throw new TypeError("Unsupported big-endian Mach-O CPU type");
throw new UnsupportedMachoMetadataFormatError(
"Unsupported big-endian 64-bit PowerPC Mach-O metadata",
);
}
const commands = bytes.readUInt32LE(slice + 16);
const commandEnd = slice + 32 + bytes.readUInt32LE(slice + 20);
if (commands > 4096 || commandEnd > sliceEnd)
@@ -221,6 +265,19 @@ export const parseMachoLayout = (
return {
segments,
sections,
selectedImage: {
container: fat === undefined ? "thin" : "fat",
offset: slice,
byteLength: sliceEnd - slice,
cpuType,
cpuSubtype,
containerTableSha256:
fat === undefined
? null
: createHash("sha256")
.update(bytes.subarray(0, fat.tableEnd))
.digest("hex"),
},
offset,
fixups: { chained, binds, rebases, dylibs },
baseAddress: state.baseAddress ?? 0n,
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import type { PointerFixups } from "./AppleMachoFixups.js";
import type { MachoLayout } from "./AppleMachoSelection.js";
import { inspectObjcClassImportBindings } from "./AppleObjcClassImportBindings.js";
const createInput = (
overrides: Partial<Parameters<typeof inspectObjcClassImportBindings>[0]> = {},
) => {
const bytes = Buffer.alloc(16);
const fixups: PointerFixups = {
kind: "chained",
formats: ["DYLD_CHAINED_PTR_64"],
failures: [],
issues: [],
examined: 1,
decoded: 1,
unreadRebaseBytes: 0,
decode: (address) =>
address === 0x1000n
? {
kind: "bind",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: false,
addend: 0n,
}
: address === 0x1008n
? {
kind: "bind",
symbol: "_free",
binding_source: "chained",
library_ordinal: 2,
library: "/usr/lib/libSystem.B.dylib",
weak: false,
addend: 0n,
}
: { kind: "rebase", target: 0n },
};
const layout: MachoLayout = {
segments: [],
sections: [],
selectedImage: {
container: "thin",
offset: 0,
byteLength: bytes.length,
cpuType: 0x0100000c,
cpuSubtype: 0,
containerTableSha256: null,
},
fixups: { chained: null, binds: [], rebases: [], dylibs: [] },
baseAddress: 0n,
offset: (address, size = 1) => {
if (address === 0x1000n && size === 8) return 0;
if (address === 0x1008n && size === 8) return 8;
throw new RangeError("address is outside fixture");
},
};
return {
bytes,
layout,
fixups,
addresses: ["0x1000", "0x1008"],
addressesComplete: true,
maxRecords: 100,
...overrides,
};
};
describe("Objective-C import binding address inspection", () => {
it("retains only exact class-symbol binds for the requested slot addresses", () => {
expect(inspectObjcClassImportBindings(createInput())).toEqual({
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",
},
],
coverage: {
status: "complete",
reason: null,
examined: 2,
decoded: 1,
},
});
});
it("keeps exact binds while marking malformed or unresolved addresses partial", () => {
expect(
inspectObjcClassImportBindings(
createInput({ addresses: ["0x1000", "not-an-address", "0x2000"] }),
),
).toMatchObject({
bindings: [{ requested_address: "0x1000" }],
coverage: {
status: "partial",
examined: 3,
decoded: 1,
reason: expect.stringContaining("not a 64-bit hexadecimal address"),
},
});
});
it("keeps an invalid raw ordinal unsupported instead of treating it as an absent bind", () => {
const input = createInput();
expect(
inspectObjcClassImportBindings({
...input,
addresses: ["0x1000"],
fixups: {
...input.fixups,
decode: () => ({
kind: "unsupported",
reason: "Dylib ordinal 4 exceeds the 3 declared dependencies",
}),
},
}),
).toEqual({
bindings: [],
coverage: {
status: "partial",
reason: "0x1000: Dylib ordinal 4 exceeds the 3 declared dependencies",
examined: 1,
decoded: 0,
},
});
});
it("does not infer absent imports from unsupported fixup coverage", () => {
const input = createInput({
fixups: { ...createInput().fixups, kind: "none" },
addresses: ["0x1000"],
});
expect(inspectObjcClassImportBindings(input)).toMatchObject({
bindings: [],
coverage: {
status: "unsupported",
reason: expect.stringContaining("no supported pointer fixup commands"),
},
});
});
it("preserves partial facts when the existing record budget truncates candidates", () => {
expect(
inspectObjcClassImportBindings(
createInput({
addresses: ["0x1000", "0x1008"],
maxRecords: 1,
}),
),
).toMatchObject({
bindings: [{ requested_address: "0x1000" }],
coverage: {
status: "partial",
examined: 1,
decoded: 1,
reason: expect.stringContaining("max_records limit"),
},
});
});
});
+119
View File
@@ -0,0 +1,119 @@
import type { PointerFixups } from "./AppleMachoFixups.js";
import type { MachoLayout } from "./AppleMachoSelection.js";
import type { ObjcClassImportBinding } from "../domain/native/objcSwiftMetadata.js";
export interface ObjcClassImportBindingScan {
readonly bindings: readonly ObjcClassImportBinding[];
readonly coverage: {
readonly status: "complete" | "partial" | "unsupported" | "not_requested";
readonly reason: string | null;
readonly examined: number;
readonly decoded: number;
};
}
/** Resolve caller-supplied names only at their exact selected-image addresses. */
export const inspectObjcClassImportBindings = (input: {
readonly bytes: Buffer;
readonly layout: MachoLayout;
readonly fixups: PointerFixups;
readonly addresses: readonly string[] | undefined;
readonly addressesComplete: boolean;
readonly maxRecords: number;
}): ObjcClassImportBindingScan => {
const { bytes, layout, fixups, addresses, addressesComplete, maxRecords } =
input;
if (addresses === undefined || addresses.length === 0)
return {
bindings: [],
coverage: {
status: "not_requested",
reason: null,
examined: 0,
decoded: 0,
},
};
if (fixups.kind === "none")
return {
bindings: [],
coverage: {
status: "unsupported",
reason: "Selected Mach-O contains no supported pointer fixup commands",
examined: 0,
decoded: 0,
},
};
const reasons = new Set<string>();
if (!addressesComplete)
reasons.add(
"Candidate address scan reached the existing max_records limit",
);
for (const failure of fixups.failures) reasons.add(failure);
const bindings: ObjcClassImportBinding[] = [];
const examinedAddresses = new Set<string>();
const candidates = addresses.slice(0, maxRecords);
if (candidates.length < addresses.length)
reasons.add(
"Candidate address scan reached the existing max_records limit",
);
for (const requestedAddress of candidates) {
if (examinedAddresses.has(requestedAddress)) continue;
examinedAddresses.add(requestedAddress);
const parsedAddress = parseAddress(requestedAddress);
if (parsedAddress === undefined) {
reasons.add("A provider address is not a 64-bit hexadecimal address");
continue;
}
try {
const offset = layout.offset(parsedAddress, 8);
const raw = bytes.readBigUInt64LE(offset);
const decoded = fixups.decode(parsedAddress, raw);
if (decoded.kind === "unsupported") {
reasons.add(`${requestedAddress}: ${decoded.reason}`);
continue;
}
if (decoded.kind !== "bind" || !isObjcClassImport(decoded.symbol))
continue;
bindings.push({
requested_address: requestedAddress,
symbol: decoded.symbol,
binding_source: decoded.binding_source,
library_ordinal: decoded.library_ordinal,
library: decoded.library,
weak: decoded.weak,
addend: decoded.addend.toString(10),
});
} catch (cause: unknown) {
reasons.add(
`${requestedAddress}: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
}
const reason = [...reasons].join("; ") || null;
return {
bindings,
coverage: {
status: reason === null ? "complete" : "partial",
reason,
examined: examinedAddresses.size,
decoded: bindings.length,
},
};
};
const parseAddress = (address: string): bigint | undefined => {
if (!/^0x[0-9a-f]{1,16}$/iu.test(address)) return undefined;
try {
return BigInt(address);
} catch {
return undefined;
}
};
const isObjcClassImport = (symbol: string): boolean =>
/^_?OBJC_CLASS_\$_.+$/u.test(symbol);
+2
View File
@@ -278,6 +278,8 @@ class NativeMacOSClient implements AnalysisClient {
input.data.max_records,
this.#dispatchResources,
options?.signal,
input.data.objc_class_import_addresses,
input.data.objc_class_import_addresses_complete ?? true,
),
);
return ok(
@@ -13,6 +13,7 @@ import {
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";
cliTest.skipIf(process.platform !== "darwin")(
"retains and reopens profileless native dispatch workflow snapshots through CLI and stdio MCP",
@@ -116,3 +117,80 @@ cliTest.skipIf(process.platform !== "darwin")(
},
30000,
);
cliTest.skipIf(process.platform !== "darwin")(
"reports exact Objective-C class imports through the compiled native MCP adapter",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-objc-import-mcp-");
const target = join(root, "class-import.macho");
const fixture = nativeObjcClassImportMachO();
await writeFile(target, fixture.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 cliResult = await cli.run({
arguments: ["inspect-native-dispatch-metadata", target, "--json"],
environment,
});
expect(cliResult.exitCode, cliResult.stderr).toBe(0);
const cliEvidence = toolContract(
"inspect_native_dispatch_metadata",
).outputSchema.parse(cliResult.json);
expect(cliEvidence.normalized_result).toMatchObject({
result: {
target_selection: {
container: "thin",
offset: 0,
cpu_type: CPU.arm64.type,
cpu_subtype: CPU.arm64.subtype,
},
},
});
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", {
max_records: 100,
objc_class_import_addresses: [fixture.address, fixture.unboundAddress],
objc_class_import_addresses_complete: true,
});
expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true);
const evidence = parseEvidence(inspected.structuredContent);
expect(evidence.normalized_result).toMatchObject({
target_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
target_architecture: "arm64",
provider: { id: "native-macos" },
result: {
target_selection: {
container: "thin",
offset: 0,
cpu_type: CPU.arm64.type,
cpu_subtype: CPU.arm64.subtype,
},
objc_class_import_bindings: [
{
requested_address: fixture.address,
symbol: "_OBJC_CLASS_$_Document",
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
},
],
objc_class_import_binding_coverage: {
status: "complete",
examined: 2,
decoded: 1,
},
},
});
await client.ping();
},
);
@@ -10,12 +10,19 @@ import type {
AnalysisExecution,
AnalysisOperationPort,
} from "../../../src/application/AnalysisProvider.js";
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
import type { AnalysisOperation } from "../../../src/application/AnalysisProvider.js";
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisProtocolError,
} from "../../../src/domain/analysisErrorCore.js";
import { err, type Result } from "../../../src/domain/result.js";
import {
err,
ok as resultOk,
type Result,
} from "../../../src/domain/result.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { observed as ok } from "../../fixtures/analysisExecution.js";
afterEach(closeEnhancedToolResources);
@@ -37,9 +44,19 @@ const connect = (analysis: AnalysisOperationPort) =>
: analysis.execute(operation, parameters, options),
});
it("discovers declarations without counting duplicates or compiler bookkeeping", async () => {
it("retains distinct class locations and excludes compiler bookkeeping", async () => {
const client = await connect({
execute: (name) => {
if (name === "inspect_native_dispatch_metadata")
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"fixture",
name,
"Native metadata is unavailable in this provider fixture",
),
),
);
expect(name).toBe("list_names");
return Promise.resolve(
ok([
@@ -68,12 +85,14 @@ it("discovers declarations without counting duplicates or compiler bookkeeping",
await client.callTool({ name: "get_objc_classes", arguments: {} }),
);
expect(result).toMatchObject({
count: 3,
count: 4,
classes: [
{ address: "0x1", name: "_OBJC_CLASS_$_First" },
{ address: "0x2", name: "_OBJC_CLASS_$_Last" },
{ address: "0x3", name: "_OBJC_CLASS_$_First" },
{ address: "0x10", name: "l_OBJC_CLASS_Legacy" },
],
identity_coverage: { status: "unknown" },
});
expect(
jsonResult(
@@ -83,8 +102,12 @@ it("discovers declarations without counting duplicates or compiler bookkeeping",
}),
),
).toMatchObject({
count: 1,
classes: [{ address: "0x1", name: "_OBJC_CLASS_$_First" }],
count: 2,
identity_coverage: { status: "unknown" },
classes: [
{ address: "0x1", name: "_OBJC_CLASS_$_First" },
{ address: "0x3", name: "_OBJC_CLASS_$_First" },
],
});
expect(
jsonResult(
@@ -112,6 +135,16 @@ it("discovers stripped Hopper class labels through MCP with literal filtering",
];
const client = await connect({
execute: (name) => {
if (name === "inspect_native_dispatch_metadata")
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"fixture",
name,
"Native metadata is unavailable in this provider fixture",
),
),
);
expect(name).toBe("list_names");
return Promise.resolve(
ok(
@@ -132,6 +165,7 @@ it("discovers stripped Hopper class labels through MCP with literal filtering",
);
expect(all).toMatchObject({
count: 5,
identity_coverage: { status: "unknown" },
classes: names.slice(0, 5).map((name, index) => ({
address: `0x${index + 1}`,
name,
@@ -150,6 +184,102 @@ it("discovers stripped Hopper class labels through MCP with literal filtering",
});
});
it("reports exact selected-image class-import identity through MCP", async () => {
const hopper = { id: "hopper", name: "Hopper", version: "1" };
const native = { id: "native", name: "Native", version: "1" };
const subject = {
sha256: "a".repeat(64),
path: "/fixture",
format: "mach-o" as const,
architecture: "arm64" as const,
};
const targetSelection = {
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(hopper, {
target_selection: targetSelection,
});
const analysis: AnalysisOperationPort = {
execute: async (operation: AnalysisOperation) => {
if (operation === "list_names")
return resultOk(
createAnalysisExecution(
[
{ address: "0x1000", value: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x2000", value: "OBJC_CLASS_$_Document" },
],
hopper,
{ subject, analysisProfile: profile },
),
);
if (operation === "inspect_native_dispatch_metadata")
return resultOk(
createAnalysisExecution(
{
target_sha256: subject.sha256,
target_architecture: subject.architecture,
provider: native,
analysis_profile_digest: null,
result: {
db_save_result: null,
target_selection: targetSelection,
objc_class_import_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",
},
],
objc_class_import_binding_coverage: {
facet: "objc_class_import_bindings",
status: "complete",
reason: null,
examined: 2,
decoded: 1,
},
},
},
native,
{ subject },
),
);
throw new Error(`Unexpected operation: ${operation}`);
},
};
const client = await connect(analysis);
const result = jsonResult(
await client.callTool({ name: "get_objc_classes", arguments: {} }),
);
expect(result).toMatchObject({
count: 2,
classes: [
{
address: "0x1000",
name: "OBJC_CLASS_$_Document$GOT",
binding_identity: {
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
slot_addresses: ["0x1000"],
},
},
{ address: "0x2000", name: "OBJC_CLASS_$_Document" },
],
identity_coverage: { status: "complete", reason: null },
});
});
const completeOverviewPort: AnalysisOperationPort = {
execute: (name) => {
switch (name) {
@@ -0,0 +1,179 @@
import { createHash } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type {
AnalysisOperation,
AnalysisOperationPort,
} from "../../../../src/application/AnalysisProvider.js";
import { createAnalysisExecution } from "../../../../src/application/AnalysisProvider.js";
import { EnhancedTools } from "../../../../src/application/EnhancedTools.js";
import {
UnsupportedMachoMetadataFormatError,
parseMachoLayout,
} from "../../../../src/native/AppleMachoSelection.js";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import { NativeFixtureRunner } from "../../../fixtures/nativeCommands.js";
import { ok } from "../../../../src/domain/result.js";
const thin32MachOHeader = (cpuType: number): Buffer => {
const bytes = Buffer.alloc(84);
bytes.writeUInt32LE(0xfeedface, 0);
bytes.writeUInt32LE(cpuType, 4);
bytes.writeUInt32LE(cpuType === 7 ? 3 : 0, 8);
bytes.writeUInt32LE(2, 12);
bytes.writeUInt32LE(1, 16);
bytes.writeUInt32LE(56, 20);
bytes.writeUInt32LE(1, 28); // LC_SEGMENT
bytes.writeUInt32LE(56, 32);
bytes.write("__TEXT", 36);
bytes.writeUInt32LE(0x1000, 52); // vmaddr
bytes.writeUInt32LE(0x1000, 56); // vmsize
bytes.writeUInt32LE(0, 60); // fileoff
bytes.writeUInt32LE(bytes.length, 64); // filesize
bytes.writeUInt32LE(7, 68); // maxprot
bytes.writeUInt32LE(5, 72); // initprot
return bytes;
};
const inspect = async (
bytes: Buffer,
architecture: "x86" | "arm" | "x86_64",
) => {
const directory = await mkdtemp(join(tmpdir(), "rea-objc-macho-fallback-"));
const path = join(directory, "fixture");
await writeFile(path, bytes);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const target = {
path,
sha256,
kind: "executable" as const,
format: "mach-o" as const,
architecture,
availableArchitectures: [architecture],
};
const nativeClient = new NativeMacOSProvider(
{},
new NativeFixtureRunner(),
"darwin",
).createClient(target);
try {
const hopper = { id: "hopper", name: "Hopper", version: "1" };
const analysis: AnalysisOperationPort = {
execute: async (operation: AnalysisOperation, parameters, options) => {
if (operation === "list_names")
return ok(
createAnalysisExecution(
[{ address: "0x1000", value: "OBJC_CLASS_$_Known" }],
hopper,
{ subject: target },
),
);
return nativeClient.execute(operation, parameters, options);
},
};
return await new EnhancedTools(analysis).execute("get_objc_classes", {});
} finally {
let closeResult: Awaited<ReturnType<typeof nativeClient.close>> | undefined;
let closeError: unknown;
try {
closeResult = await nativeClient.close();
} catch (cause: unknown) {
closeError = cause;
}
await rm(directory, { recursive: true, force: true });
if (closeError !== undefined) throw closeError;
expect(closeResult).toMatchObject({ ok: true });
}
};
describe("native Objective-C class metadata capability fallback", () => {
it.each([
{ architecture: "x86" as const, cpuType: 7 },
{ architecture: "arm" as const, cpuType: 12 },
])(
"keeps $architecture names when a valid 32-bit executable is unsupported",
async ({ architecture, cpuType }) => {
const result = await inspect(thin32MachOHeader(cpuType), architecture);
expect(result).toMatchObject({
ok: true,
value: {
count: 1,
classes: [{ address: "0x1000", name: "OBJC_CLASS_$_Known" }],
identity_coverage: { status: "unknown" },
},
});
},
);
it("classifies exactly the complete 28-byte 32-bit header as unsupported", () => {
const bytes = Buffer.alloc(28);
bytes.writeUInt32LE(0xfeedface, 0);
bytes.writeUInt32LE(7, 4);
bytes.writeUInt32LE(3, 8);
bytes.writeUInt32LE(2, 12);
let failure: unknown;
try {
parseMachoLayout(bytes, "x86");
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(UnsupportedMachoMetadataFormatError);
});
it("keeps truncated 32-bit and 64-bit headers as errors", async () => {
const truncated32 = Buffer.alloc(27);
truncated32.writeUInt32LE(0xfeedface, 0);
const truncated32Result = await inspect(truncated32, "x86");
expect(truncated32Result).toMatchObject({
ok: false,
error: { _tag: "ProviderAdapterError" },
});
const truncated64 = Buffer.alloc(31);
truncated64.writeUInt32LE(0xfeedfacf, 0);
truncated64.writeUInt32LE(0x01000007, 4);
const truncated64Result = await inspect(truncated64, "x86_64");
expect(truncated64Result).toMatchObject({
ok: false,
error: { _tag: "ProviderAdapterError" },
});
});
it("does not treat an unknown header as a supported-format capability limit", async () => {
const result = await inspect(Buffer.alloc(32), "x86_64");
expect(result).toMatchObject({
ok: false,
error: { _tag: "ProviderAdapterError" },
});
});
it("classifies a complete big-endian PowerPC64 Mach-O header as unsupported", () => {
const bytes = Buffer.alloc(32);
bytes.writeUInt32BE(0xfeedfacf, 0);
bytes.writeUInt32BE(0x01000012, 4); // CPU_TYPE_POWERPC64
bytes.writeUInt32BE(0, 8);
bytes.writeUInt32BE(2, 12);
let failure: unknown;
try {
parseMachoLayout(bytes, "powerpc");
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(UnsupportedMachoMetadataFormatError);
bytes.writeUInt32BE(0x01000007, 4);
let mismatchedCpuFailure: unknown;
try {
parseMachoLayout(bytes, "powerpc");
} catch (cause: unknown) {
mismatchedCpuFailure = cause;
}
expect(mismatchedCpuFailure).toBeInstanceOf(TypeError);
expect(mismatchedCpuFailure).not.toBeInstanceOf(
UnsupportedMachoMetadataFormatError,
);
});
});
+69
View File
@@ -0,0 +1,69 @@
/** Source-authored thin arm64 Mach-O with one classic dyld class-symbol bind. */
export const nativeObjcClassImportMachO = () => {
const address = "0x100001000";
const unboundAddress = "0x100001008";
const bindStream = Buffer.from([
0x11, // Set dylib ordinal 1.
0x40,
...Buffer.from("_OBJC_CLASS_$_Document\0"),
0x51, // Set pointer bind type.
0x71,
0x00, // Segment 1, offset 0.
0x90, // Bind once.
0x00,
]);
const bindOffset = 0x300;
const slotOffset = 0x400;
const bytes = Buffer.alloc(slotOffset + 16);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
view.setUint32(0, 0xfeedfacf, true);
view.setUint32(4, 0x0100000c, true); // CPU_TYPE_ARM64
view.setUint32(8, 0, true); // CPU_SUBTYPE_ARM64_ALL
view.setUint32(12, 2, true); // MH_EXECUTE
view.setUint32(16, 4, true);
view.setUint32(20, 72 + 72 + 48 + 48, true);
const writeSegment = (
at: number,
name: string,
vmAddress: bigint,
fileOffset: number,
fileSize: number,
protection: number,
) => {
view.setUint32(at, 0x19, true); // LC_SEGMENT_64
view.setUint32(at + 4, 72, true);
bytes.write(name, at + 8, "ascii");
view.setBigUint64(at + 24, vmAddress, true);
view.setBigUint64(at + 32, BigInt(fileSize), true);
view.setBigUint64(at + 40, BigInt(fileOffset), true);
view.setBigUint64(at + 48, BigInt(fileSize), true);
view.setUint32(at + 56, 7, true);
view.setUint32(at + 60, protection, true);
};
const text = 32;
writeSegment(text, "__TEXT", 0x100000000n, 0, slotOffset, 5);
const data = text + 72;
writeSegment(data, "__DATA", 0x100001000n, slotOffset, 16, 3);
const dylib = data + 72;
const library = Buffer.from(
"/System/Library/Frameworks/AppKit.framework/AppKit\0",
);
const dylibSize = Math.ceil((24 + library.length) / 8) * 8;
view.setUint32(20, 72 + 72 + dylibSize + 48, true);
view.setUint32(dylib, 0x0c, true); // LC_LOAD_DYLIB
view.setUint32(dylib + 4, dylibSize, true);
view.setUint32(dylib + 8, 24, true);
bytes.set(library, dylib + 24);
const dyld = dylib + dylibSize;
view.setUint32(dyld, 0x80000022, true); // LC_DYLD_INFO_ONLY
view.setUint32(dyld + 4, 48, true);
view.setUint32(dyld + 16, bindOffset, true);
view.setUint32(dyld + 20, bindStream.length, true);
bytes.set(bindStream, bindOffset);
return { bytes, address, unboundAddress };
};