Compare commits

..
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 106f260d18 fix(swift): keep nested types with undecoded identifiers unclassified
classifySwiftSymbol stopped at the first identifier it could not decode and
kept the enclosing nominal's category. A nested type named by a punycode or
word-substituted identifier (`0...`) inside a struct was therefore filed as a
struct, so analyze_swift_types reported nested enums and classes under
structs and dropped them from a classes filter and from unclassified. Such
an identifier may name a type of any kind, so the category is now unknown.

Fixes #5553

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:04:37 +00:00
10 changed files with 41 additions and 365 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.",
+6 -1
View File
@@ -38,7 +38,12 @@ export const classifySwiftSymbol = (
let category: SwiftTypeCategory | null = null;
for (;;) {
const end = identifierEnd(mangling, offset);
if (end === undefined) break;
if (end === undefined) {
// A word-substituted or punycode identifier (`0…`) may name a nested
// type of any kind, so the enclosing nominal no longer decides it.
if (mangling.charAt(offset) === "0") category = null;
break;
}
const kind = mangling.charAt(end);
const nominal = NOMINAL_CATEGORIES.get(kind);
if (nominal === undefined) {
+14
View File
@@ -28,6 +28,10 @@ describe("modern Swift category evidence", () => {
"$sSi4mainE7doubledSiyF",
"$s9999999999999999999999BadVMa",
"$s4main04PairVMa",
// Nested types named by punycode identifiers inside struct Envelope.
"_$s6Nested8EnvelopeV007IhuqBAbO8describeSSyF",
"_$s6Nested8EnvelopeV007jvrtDBlCAEycfC",
"_$s6Nested8EnvelopeV007jvrtDBlCMa",
];
const result = categorizeSwiftTypes(
names.map((name, index) => ({ address: String(index), name })),
@@ -52,6 +56,16 @@ describe("modern Swift category evidence", () => {
).toMatchObject({ total: 0, unclassified: [] });
});
it("keeps a nested punycode class unresolved under a class filter", () => {
const name = "_$s6Nested8EnvelopeV007jvrtDBlCMa";
expect(
categorizeSwiftTypes([{ address: "0x1", name }], { category: "classes" }),
).toMatchObject({
total: 0,
unclassified: [{ name, reason: "category_not_decoded" }],
});
});
it("deduplicates display names after selecting a category", () => {
expect(
categorizeSwiftTypes(
+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();