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
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user