mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 16:26:36 +00:00
Add trace_dylib_resolution (CLI trace-dylib-resolution) to the artifact family. For the active Mach-O, or every executable in the active .app (the main app, XPC services, extensions, login items, helpers), it reports which file each dylib load reaches and how. A pure TypeScript reader decodes FAT and Mach-O load commands: LC_LOAD/WEAK/REEXPORT/LAZY/UPWARD_DYLIB, including the newer dylib_use_command encoding with weak, re-export, upward and delayed-init flags, plus LC_ID_DYLIB, LC_RPATH, LC_DYLD_ENVIRONMENT and code-signature presence. Malformed tables are reported with the failing command instead of being guessed. Resolution follows dyld: - @executable_path comes from each process root; - @loader_path comes from the loading image, or from the owner of the LC_RPATH entry being expanded; - @rpath searches the loader's rpaths first, then up the load chain; - dependencies load dependents-first in load-command order, once per process, and a matching install name reuses an already loaded image; - symlinks are followed inside the analyzed root, and escapes stop. Every candidate path is reported with its outcome. Each edge resolves to resolved, conditional (an outside-root candidate such as /usr/lib/swift precedes), unresolved, or undetermined. Absolute and shared-cache system paths are never checked against the host. Derived findings cover unresolved required or weak loads, absent earlier @rpath candidates (with library validation left unknown), and LC_DYLD_ENVIRONMENT. verify:macos-bundle now also: - checks every traced image against otool -l; - asserts each resolution status on a clang-built fixture that adds a missing Overrides rpath, an absolute rpath, and a delay-init library; - compares the predicted load order of two process roots with what dyld loads under DYLD_PRINT_LIBRARIES. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
5.4 KiB
TypeScript
168 lines
5.4 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 { 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;
|
|
};
|
|
|
|
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" });
|
|
}
|
|
},
|
|
);
|
|
|
|
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 },
|
|
]);
|
|
},
|
|
);
|
|
});
|