Files
reverse-engineering-anything/tests/boundary/cli/dylibResolutionCli.test.ts
T
Kaoru0822andClaude Opus 5.5 ade11fa02d feat(apple): inspect dyld shared caches and resolve system paths
macOS 11 and later ship system libraries only inside the dyld shared
cache. trace_dylib_resolution therefore left every /usr/lib and /System
load undetermined, and REA had no way to see what a cache contains.

inspect_dyld_shared_cache (CLI inspect-dyld-shared-cache) reads a cache
file set selected by path, without an open target. Its pure TypeScript
reader parses modern and legacy dyld_cache_header layouts and reports:
- UUID, architecture, platform, OS version, Mac Catalyst alternate,
  cache type, shared region, max slide;
- every VM mapping;
- each subcache by file suffix, checked by UUID. A missing or
  mismatched subcache makes coverage partial;
- the complete image list.
For each requested install path it reads that image's load commands
through the cache mappings with the existing Mach-O reader.

trace_dylib_resolution accepts an optional shared_cache. Absolute
candidates found in a cache that serves the process's CPU family resolve
with the new outcome and status `shared-cache`. That status also takes
precedence over later in-bundle fallbacks such as a back-deployed
libswiftCore. The result names the cache UUID and OS version it used.

verify:dyld-shared-cache checks every image in the host cache (3,649 on
macOS 26.6), with dependencies and their re-export, weak, upward and
delay-init attributes, against `dyld_info -all_dyld_cache
-linked_dylibs`. It also checks CLI/MCP parity, and that each
shared-cache edge in the fixture trace is a library dyld actually loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:43:15 +00:00

241 lines
7.8 KiB
TypeScript

import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, expect } from "vitest";
import {
FILE_TYPE,
LC,
dylibCommand,
machoImage,
rpathCommand,
} from "../../../src/artifacts/MachoImage.fixture.js";
import { dyldCacheFixture } from "../../../src/artifacts/DyldSharedCache.fixture.js";
import { dylibResolutionResultSchema } from "../../../src/domain/dylibResolution.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const ENVIRONMENT = { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" };
const writeFiles = async (
root: string,
files: Readonly<Record<string, string | Uint8Array>>,
): Promise<void> => {
for (const [path, content] of Object.entries(files)) {
await mkdir(dirname(join(root, path)), { recursive: true });
await writeFile(join(root, path), content);
}
};
const fixtureApp = async (): Promise<string> => {
const app = join(
await createTestTempDirectory("rea-dylib-cli-"),
"Fixture.app",
);
await writeFiles(app, {
"Contents/Info.plist":
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
"Contents/MacOS/App": machoImage({
commands: [
rpathCommand("@executable_path/../Frameworks"),
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libgone.dylib"),
],
}),
"Contents/Frameworks/libcore.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib")],
}),
"Contents/Helpers/tool": machoImage({
commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib")],
}),
});
return app;
};
const withBrokenHelper = async (app: string): Promise<void> => {
const broken = machoImage({ commands: [rpathCommand("x")] });
// cmdsize 0 makes the command table unreadable.
new DataView(broken.buffer).setUint32(32 + 4, 0, true);
await writeFiles(app, { "Contents/Helpers/broken": broken });
};
describe("trace-dylib-resolution CLI", () => {
cliTest("traces every executable root of an app bundle", async ({ cli }) => {
const app = await fixtureApp();
const result = await cli.run({
arguments: ["trace-dylib-resolution", app, "--json"],
environment: ENVIRONMENT,
});
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
const trace = dylibResolutionResultSchema.parse(
(result.json as { normalized_result: unknown }).normalized_result,
);
expect(trace.root_path).toBe(app);
expect(trace.roots.map(({ image }) => image)).toEqual([
"Contents/Helpers/tool",
"Contents/MacOS/App",
]);
expect(
trace.edges.map(({ loader, install_name: name, resolution }) => [
loader,
name,
resolution.status,
resolution.image,
]),
).toEqual([
[
"Contents/Helpers/tool",
"@loader_path/libnone.dylib",
"unresolved",
null,
],
[
"Contents/MacOS/App",
"@rpath/libcore.dylib",
"resolved",
"Contents/Frameworks/libcore.dylib",
],
["Contents/MacOS/App", "@rpath/libgone.dylib", "unresolved", null],
]);
expect(trace.findings.map(({ kind }) => kind)).toEqual([
"required-load-unresolved",
"weak-load-unresolved",
]);
expect(
trace.images.every(({ sha256 }) => /^[0-9a-f]{64}$/u.test(sha256)),
).toBe(true);
});
cliTest(
"narrows roots and architecture, and rejects escaping roots",
async ({ cli }) => {
const app = await fixtureApp();
const narrowed = await cli.run({
arguments: [
"trace-dylib-resolution",
app,
"--root",
"Contents/MacOS/App",
"--architecture",
"arm64",
"--json",
],
environment: ENVIRONMENT,
});
expect(narrowed.exitCode).toBe(0);
expect(
(narrowed.json as { normalized_result: { roots: unknown } })
.normalized_result.roots,
).toEqual([{ image: "Contents/MacOS/App", architecture: "arm64" }]);
for (const root of ["../outside", "Contents/Info.plist"]) {
const rejected = await cli.run({
arguments: ["trace-dylib-resolution", app, "--root", root, "--json"],
environment: ENVIRONMENT,
});
expect(rejected.exitCode).toBe(1);
expect(rejected.json).toMatchObject({ error: "Analysis failed" });
}
},
);
});
describe("trace-dylib-resolution CLI inputs", () => {
cliTest(
"resolves a standalone Mach-O against its own directory",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-dylib-standalone-");
await writeFiles(directory, {
tool: machoImage({
commands: [
dylibCommand(LC.LOAD_DYLIB, "@loader_path/lib/libhelper.dylib"),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/../escape.dylib"),
],
}),
"lib/libhelper.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
"unrelated.txt": "not traversed",
});
const result = await cli.run({
arguments: [
"trace-dylib-resolution",
join(directory, "tool"),
"--json",
],
environment: ENVIRONMENT,
});
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
const trace = dylibResolutionResultSchema.parse(
(result.json as { normalized_result: unknown }).normalized_result,
);
expect(trace.roots).toEqual([{ image: "tool", architecture: "arm64" }]);
expect(trace.edges.map(({ resolution }) => resolution)).toEqual([
{ status: "resolved", image: "lib/libhelper.dylib" },
{ status: "undetermined", image: null },
]);
},
);
cliTest(
"reports unparsed Mach-O files found while enumerating roots",
async ({ cli }) => {
const app = await fixtureApp();
await withBrokenHelper(app);
const result = await cli.run({
arguments: ["trace-dylib-resolution", app, "--json"],
environment: ENVIRONMENT,
});
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
const trace = dylibResolutionResultSchema.parse(
(result.json as { normalized_result: unknown }).normalized_result,
);
expect(trace.coverage).toMatchObject({
status: "partial",
unparsed_images: ["Contents/Helpers/broken"],
});
},
);
cliTest(
"answers system install paths from a supplied shared cache",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-dylib-cache-");
await writeFiles(directory, {
tool: machoImage({
commands: [
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libnotcached.dylib"),
],
}),
dyld_shared_cache_arm64e: dyldCacheFixture([
{
path: "/usr/lib/libSystem.B.dylib",
bytes: machoImage({ fileType: FILE_TYPE.dylib }),
},
]).main,
});
const result = await cli.run({
arguments: [
"trace-dylib-resolution",
join(directory, "tool"),
"--shared-cache",
join(directory, "dyld_shared_cache_arm64e"),
"--json",
],
environment: ENVIRONMENT,
});
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
const trace = dylibResolutionResultSchema.parse(
(result.json as { normalized_result: unknown }).normalized_result,
);
expect(trace.shared_cache).toMatchObject({
architecture: "arm64e",
os_version: "26.6.0",
});
expect(trace.edges.map(({ resolution }) => resolution.status)).toEqual([
"shared-cache",
"undetermined",
]);
},
);
});