Compare commits

..
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 23ec9931ba fix(native): mark Objective-C declarations partial when the budget cuts their methods
inspect-native-dispatch-metadata returned protocols, classes and categories
with decode status "decoded" and a short or empty method list when the
max_records budget stopped inside that list, which made a truncated list
indistinguishable from a genuinely empty one. Each method-list reader now
reports whether the budget stopped it, and the declaring record reports
partial / method_list_record_budget_exhausted.

Fixes #5543

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:00:52 +00:00
13 changed files with 114 additions and 401 deletions

No files matched your search

+3 -6
View File
@@ -180,10 +180,8 @@ rea trace-dylib-resolution MyApp.app --root Contents/MacOS/MyApp --architecture
Load commands are parsed in TypeScript from Mach-O and FAT headers, including
the `dylib_use_command` encoding newer linkers emit for flags such as delayed
initialization, and the `LC_LAZY_LOAD_DYLIB_INFO` records they emit for
`-lazy_library`. Lazy-info records carry no dylib versions, so their
`current_version` and `compatibility_version` are null. Nothing is executed and
no Apple tool is required, so the tool also works off macOS.
initialization. Nothing is executed and no Apple tool is required, so the tool
also works off macOS.
Resolution follows dyld:
@@ -224,8 +222,7 @@ cache rather than on disk, so REA does not check them against the host.
`findings` are derived, not observed:
- `required-load-unresolved`, `weak-load-unresolved`, and `lazy-load-unresolved`.
Lazy dependencies (`lazy: true`, from `LC_LAZY_LOAD_DYLIB` or
`LC_LAZY_LOAD_DYLIB_INFO`) are resolved but not traversed, because dyld
`LC_LAZY_LOAD_DYLIB` dependencies are resolved but not traversed, because dyld
loads them on first use; an unresolved one is not a launch failure.
- `earlier-rpath-candidate-absent`: a compatible Mach-O placed at an earlier
modeled search path could take precedence, subject to unmodeled search inputs
+2 -67
View File
@@ -7,7 +7,6 @@ export const LC = {
REEXPORT_DYLIB: 0x8000001f,
LAZY_LOAD_DYLIB: 0x20,
LOAD_UPWARD_DYLIB: 0x80000023,
LAZY_LOAD_DYLIB_INFO: 0x3a,
RPATH: 0x8000001c,
DYLD_ENVIRONMENT: 0x27,
CODE_SIGNATURE: 0x1d,
@@ -103,71 +102,18 @@ export const versionMinCommand = (command: number): Uint8Array => {
return bytes;
};
/**
* dyld's `LazyLoadDylibLinkEdit` payload as Apple's linker lays it out: the
* 24-byte header, the symbol offset array, the path and the symbol names.
*/
export const lazyLoadDylibInfo = (
path: string,
options: {
readonly flags?: number;
readonly symbols?: readonly string[];
} = {},
): Uint8Array => {
const symbols = (options.symbols ?? ["_lf"]).map((name) =>
encoder.encode(name),
);
const name = encoder.encode(path);
const pathOffset = 24 + symbols.length * 4;
let end = pathOffset + name.length + 1;
const symbolOffsets = symbols.map((symbol) => {
const offset = end;
end += symbol.length + 1;
return offset;
});
const bytes = new Uint8Array(Math.ceil(end / 8) * 8);
const view = new DataView(bytes.buffer);
view.setUint32(0, pathOffset, true);
view.setUint32(4, 0x8008, true);
view.setUint16(8, options.flags ?? 0, true);
view.setUint16(10, 6, true);
view.setUint32(12, 0x8000, true);
view.setUint32(16, symbols.length, true);
view.setUint32(20, 24, true);
symbolOffsets.forEach((offset, index) => {
view.setUint32(24 + index * 4, offset, true);
bytes.set(symbols[index] ?? new Uint8Array(), offset);
});
bytes.set(name, pathOffset);
return bytes;
};
/** A 64-bit (or 32-bit) little-endian Mach-O image made of the given commands. */
export const machoImage = (options: {
readonly cpu?: { readonly type: number; readonly subtype: number };
readonly fileType?: number;
readonly commands?: readonly Uint8Array[];
/** `linkedit_data_command`s appended to `commands`, with data placed after them. */
readonly linkedit?: readonly {
readonly command: number;
readonly data: Uint8Array;
}[];
readonly wide?: boolean;
}): Uint8Array => {
const wide = options.wide ?? true;
const linkedit = options.linkedit ?? [];
const commands = [
...(options.commands ?? []),
...linkedit.map(() => new Uint8Array(16)),
];
const commands = options.commands ?? [];
const headerSize = wide ? 32 : 28;
const commandBytes = commands.reduce((total, item) => total + item.length, 0);
const dataStart = headerSize + commandBytes + 16;
const dataBytes = linkedit.reduce(
(total, item) => total + item.data.length,
0,
);
const bytes = new Uint8Array(dataStart + dataBytes);
const bytes = new Uint8Array(headerSize + commandBytes + 16);
const view = new DataView(bytes.buffer);
const cpu = options.cpu ?? CPU.arm64;
view.setUint32(0, wide ? 0xfeedfacf : 0xfeedface, true);
@@ -181,17 +127,6 @@ export const machoImage = (options: {
bytes.set(command, offset);
offset += command.length;
}
let dataOffset = dataStart;
let commandOffset = headerSize + commandBytes - linkedit.length * 16;
for (const { command, data } of linkedit) {
view.setUint32(commandOffset, command, true);
view.setUint32(commandOffset + 4, 16, true);
view.setUint32(commandOffset + 8, dataOffset, true);
view.setUint32(commandOffset + 12, data.length, true);
bytes.set(data, dataOffset);
commandOffset += 16;
dataOffset += data.length;
}
return bytes;
};
@@ -14,7 +14,6 @@ import {
dylibCommand,
dylibUseCommand,
fatImage,
lazyLoadDylibInfo,
machoImage,
readerOf,
rpathCommand,
@@ -354,98 +353,6 @@ describe("Mach-O load command reader: decodes thin-image load commands", () => {
});
});
describe("Mach-O load command reader: decodes LC_LAZY_LOAD_DYLIB_INFO", () => {
it("decodes LC_LAZY_LOAD_DYLIB_INFO records in load-command order", async () => {
const facts = await read(
machoImage({
commands: [dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib")],
linkedit: [
{
command: LC.LAZY_LOAD_DYLIB_INFO,
data: lazyLoadDylibInfo("@rpath/libl.dylib"),
},
{
command: LC.LAZY_LOAD_DYLIB_INFO,
data: lazyLoadDylibInfo("@rpath/libmaybe.dylib", {
flags: 0x1,
symbols: ["_a", "_b"],
}),
},
],
}),
);
if (facts.status !== "parsed") throw new Error(facts.status);
expect(facts.slices[0]?.dependencies.slice(1)).toEqual([
{
command: "LC_LAZY_LOAD_DYLIB_INFO",
encoding: "lazy_load_dylib_info",
install_name: "@rpath/libl.dylib",
weak: false,
upward: false,
reexport: false,
delayed_init: false,
lazy: true,
current_version: null,
compatibility_version: null,
},
expect.objectContaining({
install_name: "@rpath/libmaybe.dylib",
weak: true,
lazy: true,
}),
]);
});
it("rejects LC_LAZY_LOAD_DYLIB_INFO data that dyld would reject", async () => {
const withData = (data: Uint8Array) =>
read(
machoImage({
linkedit: [{ command: LC.LAZY_LOAD_DYLIB_INFO, data }],
}),
);
const valid = lazyLoadDylibInfo("@rpath/libl.dylib");
const patched = (offset: number, value: number) => {
const bytes = valid.slice();
new DataView(bytes.buffer).setUint32(offset, value, true);
return bytes;
};
await expect(withData(valid.subarray(0, 20))).resolves.toEqual({
status: "malformed",
reason:
"load command 0 LC_LAZY_LOAD_DYLIB_INFO data is too short for its header",
});
await expect(withData(patched(0, valid.length))).resolves.toMatchObject({
status: "malformed",
reason: expect.stringContaining("path offset"),
});
await expect(withData(patched(16, 1000))).resolves.toMatchObject({
status: "malformed",
reason: expect.stringContaining("symbol table exceeds its data"),
});
await expect(
withData(patched(24, valid.length + 1)),
).resolves.toMatchObject({
status: "malformed",
reason: expect.stringContaining("symbol 0 is outside its data"),
});
const unterminated = valid.slice();
unterminated.fill(0x41, 28);
await expect(withData(unterminated)).resolves.toMatchObject({
status: "malformed",
reason: "load command 0 has an unterminated string",
});
const beyond = machoImage({
linkedit: [{ command: LC.LAZY_LOAD_DYLIB_INFO, data: valid }],
});
await expect(
readMachoImage(readerOf(beyond), beyond.length - 1),
).resolves.toMatchObject({
status: "malformed",
reason: expect.stringContaining("data extends beyond the Mach-O slice"),
});
});
});
describe("Mach-O load command reader: reads and validates universal binary slices", () => {
it("reads every slice of FAT and FAT_64 containers in either byte order", async () => {
for (const [wide, littleEndian] of [
+6 -109
View File
@@ -33,7 +33,6 @@ const LC_LOAD_WEAK_DYLIB = 0x80000018;
const LC_REEXPORT_DYLIB = 0x8000001f;
const LC_LAZY_LOAD_DYLIB = 0x20;
const LC_LOAD_UPWARD_DYLIB = 0x80000023;
const LC_LAZY_LOAD_DYLIB_INFO = 0x3a;
const LC_RPATH = 0x8000001c;
const LC_DYLD_ENVIRONMENT = 0x27;
const LC_CODE_SIGNATURE = 0x1d;
@@ -91,16 +90,6 @@ const DYLIB_USE_REEXPORT = 0x2;
const DYLIB_USE_UPWARD = 0x4;
const DYLIB_USE_DELAYED_INIT = 0x8;
/**
* dyld's `LazyLoadDylibLinkEdit` (dyld `mach_o/LazyLoadDylib.h`), the
* __LINKEDIT payload of `LC_LAZY_LOAD_DYLIB_INFO`: loadPathOffset,
* flagImageOffset, flags, pointerFormat, chainStartImageOffset, symbolsCount
* and symbolStringArrayOffset, followed by the path and symbol strings.
*/
const LAZY_LOAD_DYLIB_INFO_HEADER_BYTES = 24;
/** `LazyLoadDylib::dylibMayBeMissing()`: the lazy dylib is weak linked. */
const LAZY_LOAD_DYLIB_WEAK_LINK = 0x1;
const DEPENDENCY_COMMANDS: ReadonlyMap<number, MachoDependency["command"]> =
new Map([
[LC_LOAD_DYLIB, "LC_LOAD_DYLIB"],
@@ -304,7 +293,7 @@ const readSlice = async (
commandBytes,
"load commands",
);
const slice = await decodeCommands(readAt, commands, {
const slice = decodeCommands(commands, {
wide,
cpuType: identity.cpuType,
cpuSubtype: identity.cpuSubtype,
@@ -331,8 +320,7 @@ const readSlice = async (
);
};
const decodeCommands = async (
readAt: ReadAt,
const decodeCommands = (
bytes: Uint8Array,
header: {
readonly wide: boolean;
@@ -346,7 +334,7 @@ const decodeCommands = async (
readonly fatCpuSubtype: number | null;
readonly fatAlignmentExponent: number | null;
},
): Promise<MachoSlice> => {
): MachoSlice => {
const view = viewOf(bytes);
const slice: MachoSlice = {
architecture: architectureName(header.cpuType, header.cpuSubtype),
@@ -386,11 +374,7 @@ const decodeCommands = async (
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
);
const body = bytes.subarray(offset, offset + size);
if (command === LC_LAZY_LOAD_DYLIB_INFO)
slice.dependencies.push(
await readLazyLoadDylibInfo(readAt, body, index, header),
);
else decodeCommand(command, body, index, slice);
decodeCommand(command, body, index, slice);
offset += size;
}
if (offset !== bytes.byteLength)
@@ -489,89 +473,11 @@ const decodeDependency = (
reexport:
command === "LC_REEXPORT_DYLIB" || (flags & DYLIB_USE_REEXPORT) !== 0,
delayed_init: (flags & DYLIB_USE_DELAYED_INIT) !== 0,
lazy: command === "LC_LAZY_LOAD_DYLIB",
current_version: version(view.getUint32(16, true)),
compatibility_version: version(view.getUint32(20, true)),
};
};
/**
* Decode one `LC_LAZY_LOAD_DYLIB_INFO` record from its __LINKEDIT payload.
* Validation follows dyld's `LazyLoadDylib::valid()` except for the flag and
* chain-start image offsets, which need the VM layout and do not affect
* which dylib is named.
*/
const readLazyLoadDylibInfo = async (
readAt: ReadAt,
body: Uint8Array,
index: number,
slice: { readonly sliceOffset: number; readonly sliceSize: number },
): Promise<MachoDependency> => {
// linkedit_data_command: cmd, cmdsize, dataoff, datasize.
if (body.byteLength < 16)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short for LC_LAZY_LOAD_DYLIB_INFO`,
);
const command = viewOf(body);
const dataOffset = command.getUint32(8, true);
const dataSize = command.getUint32(12, true);
if (dataSize > MAX_LOAD_COMMAND_BYTES)
throw new MachoFormatIssue(
"unsupported",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO data occupies ${dataSize} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
);
if (dataOffset + dataSize > slice.sliceSize)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO data extends beyond the Mach-O slice`,
);
if (dataSize < LAZY_LOAD_DYLIB_INFO_HEADER_BYTES)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO data is too short for its header`,
);
const blob = await readExact(
readAt,
slice.sliceOffset + dataOffset,
dataSize,
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO data`,
);
const view = viewOf(blob);
const symbolCount = view.getUint32(16, true);
const symbolArrayOffset = view.getUint32(20, true);
if (symbolArrayOffset + symbolCount * 4 > dataSize)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO symbol table exceeds its data`,
);
for (let symbol = 0; symbol < symbolCount; symbol++)
if (view.getUint32(symbolArrayOffset + symbol * 4, true) > dataSize)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO symbol ${symbol} is outside its data`,
);
const pathOffset = view.getUint32(0, true);
if (pathOffset >= dataSize)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO path offset ${pathOffset} is outside its data`,
);
return {
command: "LC_LAZY_LOAD_DYLIB_INFO",
encoding: "lazy_load_dylib_info",
install_name: terminatedString(blob, pathOffset, index),
weak: (view.getUint16(8, true) & LAZY_LOAD_DYLIB_WEAK_LINK) !== 0,
upward: false,
reexport: false,
delayed_init: false,
lazy: true,
// The record carries no dylib versions.
current_version: null,
compatibility_version: null,
};
};
/** NUL-terminated UTF-8 string at a `lc_str` offset inside one command. */
const commandString = (
body: Uint8Array,
@@ -589,16 +495,7 @@ const commandString = (
"malformed",
`load command ${index} has string offset ${offset} outside the command`,
);
return terminatedString(body, offset, index);
};
/** NUL-terminated UTF-8 string starting at `offset` inside one record. */
const terminatedString = (
bytes: Uint8Array,
offset: number,
index: number,
): string => {
const end = bytes.indexOf(0, offset);
const end = body.indexOf(0, offset);
if (end < 0)
throw new MachoFormatIssue(
"malformed",
@@ -607,7 +504,7 @@ const terminatedString = (
try {
// lc_str contains name/path data, so a leading U+FEFF is part of its identity.
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
bytes.subarray(offset, end),
body.subarray(offset, end),
);
} catch {
throw new MachoFormatIssue(
-1
View File
@@ -47,7 +47,6 @@ const dependency = (
upward: false,
reexport: false,
delayed_init: false,
lazy: overrides.command === "LC_LAZY_LOAD_DYLIB",
current_version: "1.0.0",
compatibility_version: "1.0.0",
...overrides,
+5 -13
View File
@@ -47,26 +47,18 @@ const dependencyCommandSchema = z.enum([
"LC_REEXPORT_DYLIB",
"LC_LAZY_LOAD_DYLIB",
"LC_LOAD_UPWARD_DYLIB",
"LC_LAZY_LOAD_DYLIB_INFO",
]);
const dependencySchema = z.strictObject({
command: dependencyCommandSchema,
encoding: z.enum([
"dylib_command",
"dylib_use_command",
"lazy_load_dylib_info",
]),
encoding: z.enum(["dylib_command", "dylib_use_command"]),
install_name: z.string(),
weak: z.boolean(),
upward: z.boolean(),
reexport: z.boolean(),
delayed_init: z.boolean(),
/** dyld loads the dylib on first use of one of its symbols, not at launch. */
lazy: z.boolean(),
/** Null when the encoding records no dylib versions. */
current_version: z.string().nullable(),
compatibility_version: z.string().nullable(),
current_version: z.string(),
compatibility_version: z.string(),
});
/** Observed dylib-loading facts of one Mach-O slice. */
@@ -536,8 +528,8 @@ const traceProcess = async (
for (const dependency of current.slice.dependencies) {
const edge = await resolveDependency(dependency, loader, context);
edges.push(edge);
// dyld loads lazy dylibs on first use, not at launch.
if (dependency.lazy) continue;
// dyld loads LC_LAZY_LOAD_DYLIB images on first use, not at launch.
if (dependency.command === "LC_LAZY_LOAD_DYLIB") continue;
const image = edge.resolution.image;
if (image === null || context.loaded.has(image)) continue;
const facts = context.images.get(image);
+4 -6
View File
@@ -62,18 +62,16 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
image: edge.loader,
basis: "derived",
} as const;
const missing = `No candidate for ${edge.lazy ? `lazily loaded ${edge.weak ? "weak " : ""}` : edge.weak ? "weak dependency " : ""}${edge.install_name} exists in the analyzed root`;
const missing = `No candidate for ${edge.command === "LC_LAZY_LOAD_DYLIB" ? "lazily loaded " : edge.weak ? "weak dependency " : ""}${edge.install_name} exists in the analyzed root`;
// Below a conditional fallback, every consequence applies only if it loads.
const when = edge.loader_conditional
? `. ${edge.loader} loads only conditionally; if it loads, `
: "; ";
if (edge.lazy)
if (edge.command === "LC_LAZY_LOAD_DYLIB")
return {
...base,
kind: "lazy-load-unresolved",
explanation: edge.weak
? `${missing}${when}dyld tries to load it on first use and continues without it.`
: `${missing}${when}dyld loads it on first use, which would fail unless the image is supplied elsewhere.`,
explanation: `${missing}${when}dyld loads it on first use, which would fail unless the image is supplied elsewhere.`,
};
return edge.weak
? {
@@ -89,7 +87,7 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
};
export const DYLIB_RESOLUTION_LIMITATIONS = [
"Lazy dependencies (LC_LAZY_LOAD_DYLIB and LC_LAZY_LOAD_DYLIB_INFO) are resolved but not traversed, because dyld loads them only on first use.",
"LC_LAZY_LOAD_DYLIB dependencies are resolved but not traversed, because dyld loads them only on first use.",
"Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.",
"Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.",
"Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.",
+41
View File
@@ -1193,3 +1193,44 @@ describe("malformed Objective-C protocol method lists", () => {
});
});
});
describe("Objective-C method lists cut by the record budget", () => {
it("never reports a declaration decoded while the budget omitted its methods", () => {
const complete = decodeAppleDispatchMetadata(fixture(), 100, provenance);
const classKey = (item: { name: string; is_meta_class: boolean | null }) =>
`${item.name}:${String(item.is_meta_class)}`;
const completeProtocols = new Map(
complete.objc_protocol_records.map((item) => [item.name, item]),
);
const completeClasses = new Map(
complete.objc_classes.map((item) => [classKey(item), item]),
);
const cutReasons = new Set<string>();
for (let budget = 1; budget < 40; budget++) {
const result = decodeAppleDispatchMetadata(fixture(), budget, provenance);
for (const record of result.objc_protocol_records) {
const full = completeProtocols.get(record.name);
if (record.decode.status === "decoded") {
expect(record.methods).toEqual(full?.methods);
expect(record.optional_methods).toEqual(full?.optional_methods);
} else if (record.decode.reason !== null)
cutReasons.add(`protocol:${record.decode.reason}`);
}
for (const record of result.objc_classes) {
const full = completeClasses.get(classKey(record));
if (record.decode?.status === "decoded")
expect(record.methods).toEqual(full?.methods);
else if (record.decode?.reason != null)
cutReasons.add(`class:${record.decode.reason}`);
}
}
// The fixture has methods on both declaration kinds, so some budget must
// admit each declaration but stop inside its method list.
expect([...cutReasons]).toEqual(
expect.arrayContaining([
"protocol:method_list_record_budget_exhausted",
"class:method_list_record_budget_exhausted",
]),
);
});
});
+23 -16
View File
@@ -31,7 +31,7 @@ export const decodeObjcCategories = (input: {
className: string,
methodType: "instance" | "class",
category: string,
) => string[];
) => { selectors: string[]; budgetExhausted: boolean };
readonly protocols: (list: bigint) => string[];
/** Name of a class defined in this image, from its class_ro_t. */
readonly localClassName: (address: bigint) => string;
@@ -71,6 +71,18 @@ export const decodeObjcCategories = (input: {
);
}
const owner = className ?? `(${name})`;
const instanceMethods = input.methods(
read.pointer(category + 16n),
owner,
"instance",
name,
);
const classMethods = input.methods(
read.pointer(category + 24n),
owner,
"class",
name,
);
result.objc_categories.push({
name,
class_name: className,
@@ -80,18 +92,8 @@ export const decodeObjcCategories = (input: {
: className === null
? "unresolved"
: "local",
instance_methods: input.methods(
read.pointer(category + 16n),
owner,
"instance",
name,
),
class_methods: input.methods(
read.pointer(category + 24n),
owner,
"class",
name,
),
instance_methods: instanceMethods.selectors,
class_methods: classMethods.selectors,
protocols: input.protocols(read.pointer(category + 32n)),
properties: readObjcPropertiesOf(read, read.pointer(category + 40n), {
owner: `category ${name}`,
@@ -101,9 +103,14 @@ export const decodeObjcCategories = (input: {
}),
location: read.location(category),
decode:
className === null
? { status: "partial", reason: "category_class_unresolved" }
: { status: "decoded", reason: null },
instanceMethods.budgetExhausted || classMethods.budgetExhausted
? {
status: "partial",
reason: "method_list_record_budget_exhausted",
}
: className === null
? { status: "partial", reason: "category_class_unresolved" }
: { status: "decoded", reason: null },
evidence: read.evidence(category, "Objective-C category_t record"),
});
} catch (cause: unknown) {
+12 -7
View File
@@ -103,9 +103,9 @@ export const decodeObjcDispatchFacets = (input: {
className: string,
methodType: "instance" | "class",
category?: string,
): string[] => {
): { selectors: string[]; budgetExhausted: boolean } => {
const selectors: string[] = [];
if (list === 0n) return selectors;
if (list === 0n) return { selectors, budgetExhausted: false };
const flags = read.u32(list),
count = read.u32(list + 4n),
relative = (flags & 0x80000000) !== 0;
@@ -113,7 +113,7 @@ export const decodeObjcDispatchFacets = (input: {
if (stride !== (relative ? 12 : 24))
throw new TypeError(`Unsupported method stride/flags at ${hex(list)}`);
for (let index = 0; index < count; index++) {
if (!budget.admit()) break;
if (!budget.admit()) return { selectors, budgetExhausted: true };
const entry = list + 8n + BigInt(index * stride);
const rel = (field: bigint) => {
const displacement = bytes.readInt32LE(read.offset(field, 4));
@@ -196,7 +196,7 @@ export const decodeObjcDispatchFacets = (input: {
),
});
}
return selectors;
return { selectors, budgetExhausted: false };
};
const protocolReader = createObjcProtocolReader({
readers: {
@@ -326,7 +326,11 @@ export const decodeObjcDispatchFacets = (input: {
});
}
}
methods(read.pointer(ro + 32n), name, meta ? "class" : "instance");
const { budgetExhausted } = methods(
read.pointer(ro + 32n),
name,
meta ? "class" : "instance",
);
result.objc_classes.push({
name,
super_class: superclass.name,
@@ -362,8 +366,9 @@ export const decodeObjcDispatchFacets = (input: {
location: read.location(address),
superclass_address: superclass.address,
metaclass_address: meta ? null : hex(read.pointer(address)),
decode:
superclass.name === null && !root
decode: budgetExhausted
? { status: "partial", reason: "method_list_record_budget_exhausted" }
: superclass.name === null && !root
? { status: "partial", reason: "superclass_binding_unresolved" }
: decoded,
evidence: read.evidence(
+16 -7
View File
@@ -39,6 +39,8 @@ export const createObjcProtocolReader = (input: {
instance: boolean,
/** Entries skipped for an empty selector, reported by the declaring protocol. */
emptySelectors: string[],
/** Set when the record budget stops the list before its last entry. */
budget: { exhausted: boolean },
) => {
const output: ObjcSwiftMetadata["objc_protocols"][number]["methods"] = [];
if (address === 0n) return output;
@@ -49,7 +51,10 @@ export const createObjcProtocolReader = (input: {
if (stride !== (relative ? 12 : 24))
throw new TypeError("Unsupported Objective-C protocol method entry size");
for (let index = 0; index < count; index++) {
if (!read.admit()) break;
if (!read.admit()) {
budget.exhausted = true;
break;
}
const entry = address + 8n + BigInt(index * stride);
let selector = relative
? entry + BigInt(read.i32(entry))
@@ -88,13 +93,14 @@ export const createObjcProtocolReader = (input: {
if (protocolName.length === 0)
throw new TypeError("Objective-C protocol has an empty name");
const skipped: string[] = [];
const budget = { exhausted: false };
const required = [
...methods(read.pointer(address + 24n), true, true, skipped),
...methods(read.pointer(address + 32n), true, false, skipped),
...methods(read.pointer(address + 24n), true, true, skipped, budget),
...methods(read.pointer(address + 32n), true, false, skipped, budget),
];
const optional = [
...methods(read.pointer(address + 40n), false, true, skipped),
...methods(read.pointer(address + 48n), false, false, skipped),
...methods(read.pointer(address + 40n), false, true, skipped, budget),
...methods(read.pointer(address + 48n), false, false, skipped, budget),
];
for (const message of skipped)
failures.push(
@@ -112,8 +118,11 @@ export const createObjcProtocolReader = (input: {
methods: required,
optional_methods: optional,
location: read.location(address),
decode:
skipped.length === 0
// A budget-cut list is recoverable with a larger budget, so that
// cause is reported first; an empty list is otherwise complete.
decode: budget.exhausted
? { status: "partial", reason: "method_list_record_budget_exhausted" }
: skipped.length === 0
? { status: "decoded", reason: null }
: { status: "partial", reason: "protocol_method_selector_empty" },
evidence: read.evidence(
+1 -69
View File
@@ -1,4 +1,4 @@
import { chmod, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, expect } from "vitest";
@@ -11,7 +11,6 @@ import {
dylibCommand,
dyldEnvironmentCommand,
fatImage,
lazyLoadDylibInfo,
machoImage,
rpathCommand,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
@@ -158,73 +157,6 @@ cliTest(
},
);
cliTest(
"resolves LC_LAZY_LOAD_DYLIB_INFO dependencies as lazy loads",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-dylib-lazy-");
const executable = join(directory, "lazy");
// Apple's linker encodes `-lazy_library` as LC_LAZY_LOAD_DYLIB_INFO.
await writeFiles(directory, {
lazy: machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
rpathCommand("@executable_path/lib"),
],
linkedit: [
{
command: LC.LAZY_LOAD_DYLIB_INFO,
data: lazyLoadDylibInfo("@rpath/libl.dylib"),
},
],
}),
"lib/libl.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [
buildVersionCommand(1),
dylibCommand(LC.ID_DYLIB, "@rpath/libl.dylib"),
],
}),
});
const trace = async () => {
const output = await cli.run({
arguments: ["trace-dylib-resolution", executable, "--json"],
environment: ENVIRONMENT,
});
expect(output.exitCode, output.stderr).toBe(0);
return dylibResolutionResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
};
const lazyEdge = (result: Awaited<ReturnType<typeof trace>>) =>
result.edges.find(
({ install_name }) => install_name === "@rpath/libl.dylib",
);
const present = await trace();
expect(lazyEdge(present)).toMatchObject({
command: "LC_LAZY_LOAD_DYLIB_INFO",
encoding: "lazy_load_dylib_info",
lazy: true,
weak: false,
current_version: null,
resolution: { status: "resolved", image: "lib/libl.dylib" },
});
expect(present.findings).toEqual([]);
expect(present.coverage.status).toBe("complete");
await rm(join(directory, "lib/libl.dylib"));
const absent = await trace();
expect(lazyEdge(absent)?.resolution).toEqual({
status: "unresolved",
image: null,
});
// A missing lazy dylib fails on first use, not at launch.
expect(absent.findings.map(({ kind }) => kind)).toEqual([
"lazy-load-unresolved",
]);
expect(absent.coverage.status).toBe("complete");
},
);
describe("trace-dylib-resolution CLI", () => {
cliTest("traces every executable root of an app bundle", async ({ cli }) => {
const app = await fixtureApp();
@@ -176,13 +176,7 @@ it.each(["caller", "host"] as const)(
);
let closing: ReturnType<NonNullable<typeof host.close>> | undefined;
try {
// The preceding Ghidra probe may prepare Darwin process ownership before
// Kuna starts; keep startup separate from the bounded post-abort join.
await expect
.poll(async () => (await fakeKunaCalls(kuna)).length, {
timeout: 10_000,
})
.toBe(1);
await expect.poll(async () => (await fakeKunaCalls(kuna)).length).toBe(1);
const probe = (await fakeKunaCalls(kuna))[0];
if (probe === undefined) throw new Error("Missing Kuna version probe");
expect(probe.argv).toEqual(["--version"]);