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:
Kaoru0822andClaude Opus 5.5 committed 2026-10-07 10:31:17 +00:00
1 parent 55249704c5
commit 11b7f918c2
35 files changed
+2820 -29

No files matched your search

+22 -2
View File
@@ -138,7 +138,11 @@ const char *core_chain(void) { return sibling_value() == 7 ? chain_variant() : "
return join(version, "Core");
};
/** The main executable, with a weak dependency deleted after linking. */
/**
* The main executable. It searches a missing `Overrides` rpath before
* `Frameworks`, weakly links a library deleted after linking, and
* delay-links a library, which the linker encodes as `dylib_use_command`.
*/
const buildMainExecutable = async (root, contents, coreBinary) => {
const gone = join(contents, "Frameworks", "libgone.dylib");
await clang(root, {
@@ -147,22 +151,33 @@ const buildMainExecutable = async (root, contents, coreBinary) => {
output: gone,
flags: ["-dynamiclib", "-install_name", "@rpath/libgone.dylib"],
});
const delayed = join(contents, "Frameworks", "libdelay.dylib");
await clang(root, {
name: "delay",
source: `int delay_value(void) { return 3; }\n`,
output: delayed,
flags: ["-dynamiclib", "-install_name", "@rpath/libdelay.dylib"],
});
const executable = join(contents, "MacOS", "MacFixture");
await clang(root, {
name: "main",
source: `#include <stdio.h>
const char *core_chain(void);
int delay_value(void);
extern int gone_value(void) __attribute__((weak_import));
int main(void) {
if (delay_value() != 3) return 1;
printf("chain=%s weak=%s\\n", core_chain(), gone_value ? "present" : "absent");
return 0;
}
`,
output: executable,
flags: [
"-Wl,-rpath,@executable_path/../Overrides",
"-Wl,-rpath,@executable_path/../Frameworks",
coreBinary,
`-Wl,-weak_library,${gone}`,
`-Wl,-delay_library,${delayed}`,
],
});
await rm(gone);
@@ -191,7 +206,12 @@ const buildNestedBundles = async (root, contents, coreBinary) => {
int main(void) { return core_chain()[0] == 'b'; }
`,
output: join(service, "MacOS", "Svc"),
flags: ["-Wl,-rpath,@executable_path/../../../../Frameworks", coreBinary],
// An absolute rpath outside the bundle precedes the bundled framework.
flags: [
"-Wl,-rpath,/opt/rea-fixture-absent/lib",
"-Wl,-rpath,@executable_path/../../../../Frameworks",
coreBinary,
],
});
await write(
join(service, "Info.plist"),
+209
View File
@@ -0,0 +1,209 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { realpath } from "node:fs/promises";
import { join, relative } from "node:path";
import { promisify } from "node:util";
import {
artifactCliEvidence,
artifactMcpResult,
withArtifactMcp,
} from "./artifact-e2e.mjs";
const exec = promisify(execFile);
const MAIN = "Contents/MacOS/MacFixture";
const SERVICE = "Contents/XPCServices/Svc.xpc/Contents/MacOS/Svc";
const CORE = "Contents/Frameworks/Core.framework/Versions/A/Core";
const DEPENDENCY_COMMANDS = new Set([
"LC_LOAD_DYLIB",
"LC_LOAD_WEAK_DYLIB",
"LC_REEXPORT_DYLIB",
"LC_LAZY_LOAD_DYLIB",
"LC_LOAD_UPWARD_DYLIB",
]);
/** Dependencies, rpaths and install name of one slice as Apple's otool reports them. */
const otoolLoadCommands = async (path, architecture) => {
const { stdout } = await exec(
"/usr/bin/xcrun",
["otool", "-arch", architecture, "-l", path],
{ maxBuffer: 64 * 1024 * 1024 },
);
const result = { dependencies: [], rpaths: [], install_name: null };
for (const block of stdout.split(/\nLoad command \d+\n/u)) {
const command = /^\s*cmd (\S+)/mu.exec(block)?.[1];
const name = /^\s*name (.+?) \(offset \d+\)$/mu.exec(block)?.[1];
if (command === "LC_RPATH")
result.rpaths.push(/^\s*path (.+?) \(offset \d+\)$/mu.exec(block)?.[1]);
else if (command === "LC_ID_DYLIB") result.install_name = name;
else if (DEPENDENCY_COMMANDS.has(command))
result.dependencies.push(`${command} ${name}`);
}
return result;
};
/** In-bundle images dyld actually loaded, in load order. */
const loadedImages = async (app, executable) => {
const { stderr } = await exec(join(app, executable), [], {
env: { PATH: "/usr/bin:/bin", DYLD_PRINT_LIBRARIES: "1" },
});
const appRoot = await realpath(app);
const loaded = [];
for (const line of stderr.split("\n")) {
const path = /^dyld\[\d+\]: <[0-9A-F-]+> (.+)$/u.exec(line)?.[1];
if (path === undefined) continue;
// Shared-cache libraries are reported by path but are not files on disk.
const real = await realpath(path).catch(() => undefined);
if (real?.startsWith(`${appRoot}/`) === true)
loaded.push(relative(appRoot, real));
}
return loaded;
};
/** Images a process root loads according to the trace, in first-load order. */
const predictedImages = (trace, root) => {
const loaded = [root];
for (const edge of trace.edges)
if (
edge.root === root &&
["resolved", "conditional"].includes(edge.resolution.status) &&
!loaded.includes(edge.resolution.image)
)
loaded.push(edge.resolution.image);
return loaded;
};
const findEdge = (trace, root, loader, installName) => {
const edge = trace.edges.find(
(candidate) =>
candidate.root === root &&
candidate.loader === loader &&
candidate.install_name === installName,
);
assert.ok(edge, `missing edge ${root}: ${loader} -> ${installName}`);
return edge;
};
const verifyResolutionShapes = (trace) => {
const core = findEdge(
trace,
MAIN,
MAIN,
"@rpath/Core.framework/Versions/A/Core",
);
assert.deepEqual(
core.candidates.map(({ source, outcome }) => [source, outcome]),
[
["rpath", "absent"],
["rpath", "resolved"],
],
);
assert.deepEqual(core.resolution, { status: "resolved", image: CORE });
const weak = findEdge(trace, MAIN, MAIN, "@rpath/libgone.dylib");
assert.equal(weak.weak, true);
assert.equal(weak.resolution.status, "unresolved");
const delayed = findEdge(trace, MAIN, MAIN, "@rpath/libdelay.dylib");
assert.equal(delayed.encoding, "dylib_use_command");
assert.equal(delayed.delayed_init, true);
const chain = findEdge(trace, MAIN, CORE, "@rpath/libchain.dylib");
assert.equal(
chain.resolution.image,
"Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib",
"the loading image's own LC_RPATH must be searched first",
);
const service = findEdge(
trace,
SERVICE,
SERVICE,
"@rpath/Core.framework/Versions/A/Core",
);
assert.equal(service.candidates[0]?.outcome, "outside-target");
assert.deepEqual(service.resolution, { status: "conditional", image: CORE });
const system = trace.edges.filter(({ install_name: name }) =>
name.startsWith("/usr/lib/"),
);
assert.ok(system.length > 0);
assert.ok(
system.every(({ resolution }) => resolution.status === "undetermined"),
);
const kinds = trace.findings.map(({ kind, image }) => `${kind}:${image}`);
assert.ok(kinds.includes(`weak-load-unresolved:${MAIN}`));
assert.ok(kinds.includes(`earlier-rpath-candidate-absent:${MAIN}`));
};
const verifyParserConformance = async (app, trace) => {
let slices = 0;
for (const image of trace.images)
for (const slice of image.slices) {
const expected = await otoolLoadCommands(
join(app, image.path),
slice.architecture,
);
const edges = trace.edges.filter(
({ loader, architecture, root }) =>
loader === image.path &&
architecture === slice.architecture &&
root ===
trace.edges.find(
(edge) =>
edge.loader === image.path &&
edge.architecture === slice.architecture,
)?.root,
);
assert.deepEqual(
{
dependencies: edges.map(
({ command, install_name: name }) => `${command} ${name}`,
),
rpaths: slice.rpaths,
install_name: slice.install_name,
},
expected,
`${image.path} (${slice.architecture}) differs from otool -l`,
);
slices += 1;
}
return slices;
};
/** Verify trace_dylib_resolution against otool and the real dyld on the fixture app. */
export async function verifyDylibResolution(app) {
const trace = (await artifactCliEvidence("trace-dylib-resolution", app))
.normalized_result;
await withArtifactMcp(app, async (client) => {
assert.deepEqual(
await artifactMcpResult(client, "trace_dylib_resolution"),
trace,
);
const invalid = await client.callTool({
name: "trace_dylib_resolution",
arguments: { roots: ["../outside"] },
});
assert.equal(invalid.isError, true);
});
assert.equal(trace.coverage.status, "complete");
assert.deepEqual(trace.roots.map(({ image }) => image).sort(), [
"Contents/Frameworks/Core.framework/Versions/A/XPCServices/FwSvc.xpc/Contents/MacOS/FwSvc",
"Contents/Helpers/rea-tool",
"Contents/Library/LaunchServices/com.example.rea.helper",
"Contents/Library/LoginItems/Login.app/Contents/MacOS/Login",
MAIN,
"Contents/PlugIns/Ext.appex/Contents/MacOS/Ext",
SERVICE,
]);
verifyResolutionShapes(trace);
const slices = await verifyParserConformance(app, trace);
for (const root of [MAIN, SERVICE])
assert.deepEqual(
await loadedImages(app, root),
predictedImages(trace, root),
`${root} load order differs from DYLD_PRINT_LIBRARIES`,
);
return {
roots: trace.roots.length,
edges: trace.edges.length,
otool_slices: slices,
dyld_runtime_roots: 2,
};
}
+3
View File
@@ -14,6 +14,7 @@ import {
buildMacosBundleFixture,
preflightMacosBundleFixture,
} from "./lib/macos-bundle-fixture.mjs";
import { verifyDylibResolution } from "./lib/macos-dylib-e2e.mjs";
const exec = promisify(execFile);
@@ -150,6 +151,7 @@ try {
assert.deepEqual(anatomies[2], anatomies[0], "DMG anatomy differs");
const { stdout: attached } = await exec("/usr/bin/hdiutil", ["info"]);
assert.ok(!attached.includes(dmg), "DMG remained attached after inventory");
const dylibs = await verifyDylibResolution(app);
process.stdout.write(
`${JSON.stringify({
@@ -160,6 +162,7 @@ try {
containers: containers.map(({ format }) => format),
bundles: EXPECTED_BUNDLES.length,
dmg_detached: true,
dylib_resolution: dylibs,
})}\n`,
);
} finally {