mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 15:56:35 +00:00
feat(artifacts): trace Mach-O dylib resolution in bundles
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>
This commit is contained in:
1 parent
55249704c5
commit
11b7f918c2
35 files changed
+2820
-29
No files matched your search
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CPU,
|
||||
FILE_TYPE,
|
||||
LC,
|
||||
codeSignatureCommand,
|
||||
dyldEnvironmentCommand,
|
||||
dylibCommand,
|
||||
dylibUseCommand,
|
||||
fatImage,
|
||||
machoImage,
|
||||
readerOf,
|
||||
rpathCommand,
|
||||
} from "./MachoImage.fixture.js";
|
||||
import {
|
||||
MAX_LOAD_COMMAND_BYTES,
|
||||
hasMachoMagic,
|
||||
readMachoImage,
|
||||
} from "./MachoLoadCommandReader.js";
|
||||
|
||||
const read = (bytes: Uint8Array) =>
|
||||
readMachoImage(readerOf(bytes), bytes.length);
|
||||
|
||||
describe("Mach-O load command reader", () => {
|
||||
it("decodes dylib loading commands of a thin image", async () => {
|
||||
const image = machoImage({
|
||||
fileType: FILE_TYPE.dylib,
|
||||
commands: [
|
||||
dylibCommand(LC.ID_DYLIB, "@rpath/Core.framework/Core"),
|
||||
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib", {
|
||||
current: 0x050f0400,
|
||||
compatibility: 0x10000,
|
||||
}),
|
||||
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libweak.dylib"),
|
||||
dylibCommand(LC.REEXPORT_DYLIB, "@loader_path/libre.dylib"),
|
||||
dylibCommand(LC.LOAD_UPWARD_DYLIB, "@rpath/libup.dylib"),
|
||||
dylibCommand(LC.LAZY_LOAD_DYLIB, "@rpath/liblazy.dylib"),
|
||||
rpathCommand("@loader_path/Frameworks"),
|
||||
rpathCommand("/usr/lib/swift"),
|
||||
dyldEnvironmentCommand("DYLD_FRAMEWORK_PATH=@executable_path"),
|
||||
codeSignatureCommand(),
|
||||
],
|
||||
});
|
||||
const facts = await read(image);
|
||||
expect(facts.status).toBe("parsed");
|
||||
if (facts.status !== "parsed") return;
|
||||
expect(facts.slices).toHaveLength(1);
|
||||
const [slice] = facts.slices;
|
||||
expect(slice).toMatchObject({
|
||||
architecture: "arm64",
|
||||
file_type: "dylib",
|
||||
install_name: "@rpath/Core.framework/Core",
|
||||
rpaths: ["@loader_path/Frameworks", "/usr/lib/swift"],
|
||||
dyld_environment: ["DYLD_FRAMEWORK_PATH=@executable_path"],
|
||||
code_signature_present: true,
|
||||
});
|
||||
expect(
|
||||
slice?.dependencies.map(
|
||||
({ command, install_name: name, weak, reexport, upward }) => [
|
||||
command,
|
||||
name,
|
||||
weak,
|
||||
reexport,
|
||||
upward,
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
["LC_LOAD_DYLIB", "/usr/lib/libSystem.B.dylib", false, false, false],
|
||||
["LC_LOAD_WEAK_DYLIB", "@rpath/libweak.dylib", true, false, false],
|
||||
["LC_REEXPORT_DYLIB", "@loader_path/libre.dylib", false, true, false],
|
||||
["LC_LOAD_UPWARD_DYLIB", "@rpath/libup.dylib", false, false, true],
|
||||
["LC_LAZY_LOAD_DYLIB", "@rpath/liblazy.dylib", false, false, false],
|
||||
]);
|
||||
expect(slice?.dependencies[0]).toMatchObject({
|
||||
encoding: "dylib_command",
|
||||
current_version: "1295.4.0",
|
||||
compatibility_version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes dylib_use_command flags", async () => {
|
||||
const facts = await read(
|
||||
machoImage({
|
||||
commands: [
|
||||
dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libdelay.dylib", 0x8),
|
||||
dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libweakup.dylib", 0x1 | 0x4),
|
||||
dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libre.dylib", 0x2),
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (facts.status !== "parsed") throw new Error(facts.status);
|
||||
expect(
|
||||
facts.slices[0]?.dependencies.map(
|
||||
({ encoding, weak, upward, reexport, delayed_init: delayed }) => ({
|
||||
encoding,
|
||||
weak,
|
||||
upward,
|
||||
reexport,
|
||||
delayed,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
encoding: "dylib_use_command",
|
||||
weak: false,
|
||||
upward: false,
|
||||
reexport: false,
|
||||
delayed: true,
|
||||
},
|
||||
{
|
||||
encoding: "dylib_use_command",
|
||||
weak: true,
|
||||
upward: true,
|
||||
reexport: false,
|
||||
delayed: false,
|
||||
},
|
||||
{
|
||||
encoding: "dylib_use_command",
|
||||
weak: false,
|
||||
upward: false,
|
||||
reexport: true,
|
||||
delayed: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads every slice of FAT and FAT_64 containers", async () => {
|
||||
for (const wide of [false, true]) {
|
||||
const facts = await read(
|
||||
fatImage(
|
||||
[
|
||||
{
|
||||
cpu: CPU.arm64e,
|
||||
bytes: machoImage({
|
||||
cpu: CPU.arm64e,
|
||||
commands: [rpathCommand("@executable_path/a")],
|
||||
}),
|
||||
},
|
||||
{
|
||||
cpu: CPU.x86_64,
|
||||
bytes: machoImage({
|
||||
cpu: CPU.x86_64,
|
||||
commands: [rpathCommand("@executable_path/b")],
|
||||
}),
|
||||
},
|
||||
],
|
||||
wide,
|
||||
),
|
||||
);
|
||||
if (facts.status !== "parsed") throw new Error(facts.status);
|
||||
expect(
|
||||
facts.slices.map(({ architecture, rpaths }) => [architecture, rpaths]),
|
||||
).toEqual([
|
||||
["arm64e", ["@executable_path/a"]],
|
||||
["x86_64", ["@executable_path/b"]],
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mach-O load command reader inputs", () => {
|
||||
it("names 32-bit and non-Mach-O inputs without guessing", async () => {
|
||||
const i386 = await read(
|
||||
machoImage({ cpu: { type: 7, subtype: 3 }, wide: false }),
|
||||
);
|
||||
expect(i386.status === "parsed" && i386.slices[0]?.architecture).toBe(
|
||||
"i386",
|
||||
);
|
||||
// A Java class file shares the FAT magic; its version is not a slice count.
|
||||
const javaClass = Uint8Array.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0x34]);
|
||||
expect(hasMachoMagic(javaClass)).toBe(true);
|
||||
expect(await read(javaClass)).toEqual({ status: "not-mach-o" });
|
||||
expect(await read(new TextEncoder().encode("#!/bin/sh\n"))).toEqual({
|
||||
status: "not-mach-o",
|
||||
});
|
||||
expect(await read(Uint8Array.from([0xcf, 0xfa]))).toEqual({
|
||||
status: "not-mach-o",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mach-O load command reader failures", () => {
|
||||
const withCommandSize = (size: number): Uint8Array => {
|
||||
const image = machoImage({ commands: [rpathCommand("@loader_path")] });
|
||||
new DataView(image.buffer).setUint32(32 + 4, size, true);
|
||||
return image;
|
||||
};
|
||||
|
||||
it("reports malformed command tables with the failing command", async () => {
|
||||
expect(await read(withCommandSize(0))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("load command 0"),
|
||||
});
|
||||
expect(await read(withCommandSize(4096))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("invalid cmdsize"),
|
||||
});
|
||||
const extraCommand = machoImage({ commands: [rpathCommand("x")] });
|
||||
new DataView(extraCommand.buffer).setUint32(16, 2, true);
|
||||
expect(await read(extraCommand)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "load command 1 starts beyond sizeofcmds",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects strings that are unterminated, misplaced or not UTF-8", async () => {
|
||||
const unterminated = rpathCommand("abcd");
|
||||
unterminated.fill(0x41, 12);
|
||||
expect(await read(machoImage({ commands: [unterminated] }))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("unterminated"),
|
||||
});
|
||||
const misplaced = rpathCommand("abcd");
|
||||
new DataView(misplaced.buffer).setUint32(8, 4, true);
|
||||
expect(await read(machoImage({ commands: [misplaced] }))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("string offset 4"),
|
||||
});
|
||||
const invalid = rpathCommand("ab");
|
||||
invalid[12] = 0xff;
|
||||
expect(await read(machoImage({ commands: [invalid] }))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("not UTF-8"),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses truncated, oversized, big-endian and out-of-range images", async () => {
|
||||
const image = machoImage({ commands: [rpathCommand("@loader_path")] });
|
||||
expect(await read(image.subarray(0, 20))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "Mach-O header is truncated",
|
||||
});
|
||||
const oversized = machoImage({});
|
||||
new DataView(oversized.buffer).setUint32(
|
||||
20,
|
||||
MAX_LOAD_COMMAND_BYTES + 8,
|
||||
true,
|
||||
);
|
||||
expect(await read(oversized)).toMatchObject({ status: "unsupported" });
|
||||
const beyond = machoImage({ commands: [rpathCommand("x")] });
|
||||
new DataView(beyond.buffer).setUint32(20, beyond.length, true);
|
||||
expect(await read(beyond)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "load commands extend beyond the Mach-O slice",
|
||||
});
|
||||
const bigEndian = Uint8Array.from([0xfe, 0xed, 0xfa, 0xcf, 0, 0, 0, 0]);
|
||||
expect(await read(bigEndian)).toMatchObject({ status: "unsupported" });
|
||||
const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
|
||||
expect(await read(fat.subarray(0, 4100))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "FAT architecture 0 extends beyond the file",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user