fix(apple): resolve absolute dylib paths inside the selected root

Preserve load-command paths as evidence while passing a separate root-relative lookup path to the bounded tree resolver. Apply the same containment rule to absolute install names and rpath bases, including caller-selected root symlink spellings.

Validation: 140 source tests, full check:ci, docs and runtime build passed. Compiler-built literal, rpath and relative fixtures passed CLI/stdio MCP parity, preserved both dependency edges and left all bytes unchanged. External system paths stayed outside-target; relative control was unchanged. Fixtures were not executed, and actual dyld execution was not qualified.

Closes #5637
This commit is contained in:
morluto committed 2026-10-21 07:37:15 +08:00
1 parent 84c89d1107
commit 1502cfbc8c
6 files changed
+312 -19

No files matched your search

+22 -2
View File
@@ -1,6 +1,6 @@
import type { BigIntStats } from "node:fs";
import { lstat, readlink, realpath } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { isAbsolute, join, relative, sep } from "node:path";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { resolveTreePath } from "../../domain/apple/dyldPaths.js";
@@ -36,10 +36,25 @@ class FilesystemTreeView implements DylibTreeView {
constructor(
private readonly root: string,
private readonly sourceRoot: string,
private readonly files: DylibFileAccess,
private readonly signal?: AbortSignal,
) {}
rootRelativePath(absolutePath: string): string | undefined {
const roots = isAbsolute(this.sourceRoot)
? [this.root, this.sourceRoot]
: [this.root];
for (let root of roots) {
while (root.length > 1 && root.endsWith(sep)) root = root.slice(0, -1);
if (absolutePath === root) return "";
const prefix = root === sep ? sep : `${root}${sep}`;
if (absolutePath.startsWith(prefix))
return absolutePath.slice(prefix.length).split(sep).join("/");
}
return undefined;
}
entry(path: string): Promise<DylibTreeEntry | undefined> {
const cached = this.#entries.get(path);
if (cached !== undefined) return cached;
@@ -113,7 +128,12 @@ export const traceDylibResolution = async (options: {
.split(sep)
.join("/");
assertArtifactReadActive(options.signal);
const view = new FilesystemTreeView(root, files, options.signal);
const view = new FilesystemTreeView(
root,
options.rootPath,
files,
options.signal,
);
const { roots: requested, unclassified } =
parsed.data.roots !== undefined
? { roots: parsed.data.roots, unclassified: [] }
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { mkdir, rename, writeFile } from "node:fs/promises";
import { mkdir, realpath, rename, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
@@ -142,6 +142,241 @@ it("uses legacy simulator and device platform facts when resolving dylibs", asyn
);
});
describe("absolute dyld candidate paths at the filesystem reader boundary", () => {
it.each(["install-name", "rpath"] as const)(
"resolves an absolute in-root %s while preserving its source path",
async (kind) => {
const root = await createTestTempDirectory("rea-dylib-absolute-in-root-");
const canonicalRoot = await realpath(root);
const bridgePath = "Frameworks/libBridge.dylib";
const leafPath = "Frameworks/libLeaf.dylib";
const bridgeAbsolute = `${canonicalRoot}/${bridgePath}`;
const leafName = "@loader_path/libLeaf.dylib";
const executable = machoImage({
commands: [
buildVersionCommand(1),
...(kind === "rpath"
? [rpathCommand(`${canonicalRoot}/Frameworks`)]
: []),
dylibCommand(
LC.LOAD_DYLIB,
kind === "rpath" ? "@rpath/libBridge.dylib" : bridgeAbsolute,
),
],
});
const bridge = machoImage({
fileType: FILE_TYPE.dylib,
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, leafName),
],
});
const leaf = machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
});
await mkdir(join(root, "Frameworks"), { recursive: true });
await writeFile(join(root, "App"), executable);
await writeFile(bridgeAbsolute, bridge);
await writeFile(join(root, leafPath), leaf);
const trace = await traceDylibResolution({
rootPath: root,
targetPath: join(root, "App"),
targetSha256: createHash("sha256").update(executable).digest("hex"),
enumerateRoots: false,
parameters: { roots: ["App"] },
});
expect(trace.edges[0]).toMatchObject({
install_name:
kind === "rpath" ? "@rpath/libBridge.dylib" : bridgeAbsolute,
candidates: [
{
path: bridgeAbsolute,
...(kind === "rpath"
? { rpath: `${canonicalRoot}/Frameworks` }
: {}),
outcome: "resolved",
resolved_path: bridgePath,
},
],
resolution: { status: "resolved", image: bridgePath },
});
expect(trace.edges).toContainEqual(
expect.objectContaining({
loader: bridgePath,
install_name: leafName,
resolution: { status: "resolved", image: leafPath },
}),
);
expect(trace.images.map(({ path }) => path)).toEqual([
"App",
bridgePath,
leafPath,
]);
},
);
it("keeps a genuinely external absolute candidate outside the analyzed root", async () => {
const root = await createTestTempDirectory("rea-dylib-absolute-outside-");
const canonicalRoot = await realpath(root);
const executable = machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
dylibCommand(LC.LOAD_DYLIB, `${canonicalRoot}/../outside.dylib`),
dylibCommand(
LC.LOAD_DYLIB,
`file://${canonicalRoot}/Frameworks/libBridge.dylib`,
),
],
});
await writeFile(join(root, "App"), executable);
const trace = await traceDylibResolution({
rootPath: root,
targetPath: join(root, "App"),
targetSha256: createHash("sha256").update(executable).digest("hex"),
enumerateRoots: false,
parameters: { roots: ["App"] },
});
expect(trace.edges[0]).toMatchObject({
candidates: [
{
path: "/usr/lib/libSystem.B.dylib",
outcome: "outside-target",
resolved_path: null,
},
],
resolution: { status: "undetermined", image: null },
});
expect(trace.edges[1]).toMatchObject({
candidates: [{ outcome: "escapes-target", resolved_path: null }],
resolution: { status: "undetermined", image: null },
});
expect(trace.edges[2]).toMatchObject({
install_name: `file://${canonicalRoot}/Frameworks/libBridge.dylib`,
candidates: [
{
path: `file://${canonicalRoot}/Frameworks/libBridge.dylib`,
outcome: "undetermined",
resolved_path: null,
},
],
resolution: { status: "undetermined", image: null },
});
expect(trace.images.map(({ path }) => path)).toEqual(["App"]);
});
});
describe("absolute dyld candidate traversal", () => {
it("maps an absolute candidate spelled through the selected root symlink", async () => {
const root = await createTestTempDirectory(
"rea-dylib-absolute-root-alias-",
);
const aliasRoot = join(root, "bundle-alias");
const bridgePath = "Frameworks/libBridge.dylib";
const bridgeAbsolute = `${aliasRoot}/${bridgePath}`;
const executable = machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, bridgeAbsolute),
],
});
const bridge = machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
});
await symlink(".", aliasRoot);
await mkdir(join(root, "Frameworks"), { recursive: true });
await writeFile(join(root, "App"), executable);
await writeFile(join(root, bridgePath), bridge);
const trace = await traceDylibResolution({
rootPath: aliasRoot,
targetPath: `${aliasRoot}/App`,
targetSha256: createHash("sha256").update(executable).digest("hex"),
enumerateRoots: false,
parameters: { roots: ["App"] },
});
expect(trace.edges[0]).toMatchObject({
install_name: bridgeAbsolute,
candidates: [
{
path: bridgeAbsolute,
outcome: "resolved",
resolved_path: bridgePath,
},
],
resolution: { status: "resolved", image: bridgePath },
});
});
it.skipIf(process.platform === "win32")(
"preserves symlink-then-parent traversal for an absolute in-root path",
async () => {
const root = await createTestTempDirectory("rea-dylib-absolute-symlink-");
const canonicalRoot = await realpath(root);
const bridgePath = "Frameworks/Versions/libBridge.dylib";
const decoyPath = "Frameworks/libBridge.dylib";
const sourcePath = `${canonicalRoot}/Frameworks/Current/../libBridge.dylib`;
const executable = machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, sourcePath),
],
});
const bridge = machoImage({
fileType: FILE_TYPE.dylib,
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libLeaf.dylib"),
],
});
const leaf = machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
});
await mkdir(join(root, "Frameworks/Versions/A"), { recursive: true });
await symlink("Versions/A", join(root, "Frameworks/Current"));
await writeFile(join(root, "App"), executable);
await writeFile(join(root, bridgePath), bridge);
await writeFile(join(root, decoyPath), new Uint8Array([0, 1, 2]));
await writeFile(join(root, "Frameworks/Versions/libLeaf.dylib"), leaf);
const trace = await traceDylibResolution({
rootPath: root,
targetPath: join(root, "App"),
targetSha256: createHash("sha256").update(executable).digest("hex"),
enumerateRoots: false,
parameters: { roots: ["App"] },
});
expect(trace.edges[0]).toMatchObject({
candidates: [
{
path: sourcePath,
outcome: "resolved",
resolved_path: bridgePath,
},
],
resolution: { status: "resolved", image: bridgePath },
});
expect(trace.edges).toContainEqual(
expect.objectContaining({
loader: bridgePath,
resolution: {
status: "resolved",
image: "Frameworks/Versions/libLeaf.dylib",
},
}),
);
},
);
});
it("selects matching arm64e subtype slices when tracing a mixed FAT image", async () => {
const root = await createTestTempDirectory("rea-macho-arm64e-subtypes-");
const executablePath = "Contents/MacOS/App";
+14 -2
View File
@@ -68,7 +68,13 @@ export const joinPath = (directory: string, rest: string): string =>
directory === "" ? rest : rest === "" ? directory : `${directory}/${rest}`;
export type Expansion =
| { readonly scope: "target"; readonly path: string }
| {
readonly scope: "target";
/** Source-facing expansion retained in candidate Evidence. */
readonly path: string;
/** Root-relative lookup identity when `path` is an absolute source path. */
readonly lookupPath?: string;
}
| { readonly scope: "outside"; readonly path: string }
| { readonly scope: "undetermined"; readonly path: string };
@@ -78,6 +84,7 @@ export const expandPrefix = (
context: {
readonly ownerDirectory: string;
readonly executable: string | null;
readonly rootRelativePath: (absolutePath: string) => string | undefined;
},
): Expansion => {
const variable = /^@(executable_path|loader_path)(?:\/(.*))?$/u.exec(raw);
@@ -92,7 +99,12 @@ export const expandPrefix = (
path: joinPath(directoryOf(context.executable), rest),
};
}
if (raw.startsWith("/")) return { scope: "outside", path: raw };
if (raw.startsWith("/")) {
const lookupPath = context.rootRelativePath(raw);
return lookupPath === undefined
? { scope: "outside", path: raw }
: { scope: "target", path: raw, lookupPath };
}
return { scope: "undetermined", path: raw };
};
+1
View File
@@ -80,6 +80,7 @@ const memoryView = (
entries.set(path, { kind: "symlink", target });
}
return {
rootRelativePath: () => undefined,
entry: (path) => Promise.resolve(entries.get(path)),
image: (path) => {
const facts = files[path];
+32 -11
View File
@@ -114,6 +114,8 @@ export type DylibTreeEntry =
/** Read-only, lazily probed view of the analyzed root. Paths are root-relative. */
export interface DylibTreeView {
/** Map an absolute source path only when it is spelled beneath this root. */
rootRelativePath(absolutePath: string): string | undefined;
entry(path: string): Promise<DylibTreeEntry | undefined>;
image(path: string): Promise<MachoImageFacts>;
}
@@ -252,6 +254,24 @@ interface CandidateTemplate {
readonly rpathOwner: string | null;
}
const expandCandidatePath = (
raw: string,
ownerDirectory: string,
context: ProcessContext,
): Expansion =>
expandPrefix(raw, {
ownerDirectory,
executable: context.executable,
rootRelativePath: (absolutePath) =>
context.view.rootRelativePath(absolutePath),
});
const appendRpath = (base: string, rest: string): string => {
if (base === "") return rest;
if (base === "/") return `/${rest}`;
return `${base.replace(/\/+$/u, "")}/${rest}`;
};
const candidateTemplates = (
dependency: MachoDependency,
loader: string,
@@ -266,10 +286,7 @@ const candidateTemplates = (
: "literal";
return [
{
expansion: expandPrefix(name, {
ownerDirectory: directoryOf(loader),
executable: context.executable,
}),
expansion: expandCandidatePath(name, directoryOf(loader), context),
source,
rpath: null,
rpathOwner: null,
@@ -280,16 +297,17 @@ const candidateTemplates = (
const owners = [...(context.loaded.get(loader)?.chain ?? [loader])].reverse();
return owners.flatMap((owner) =>
(context.loaded.get(owner)?.slice.rpaths ?? []).map((rpath) => {
const base = expandPrefix(rpath, {
ownerDirectory: directoryOf(owner),
executable: context.executable,
});
const base = expandCandidatePath(rpath, directoryOf(owner), context);
const lookupPath = base.scope === "target" ? base.lookupPath : undefined;
const expansion: Expansion = {
scope: base.scope,
path:
base.scope === "outside"
? `${base.path.replace(/\/+$/u, "")}/${rest}`
base.scope === "outside" || lookupPath !== undefined
? appendRpath(base.path, rest)
: `${base.path}${base.path === "" ? "" : "/"}${rest}`,
...(lookupPath === undefined
? {}
: { lookupPath: appendRpath(lookupPath, rest) }),
};
return { expansion, source: "rpath" as const, rpath, rpathOwner: owner };
}),
@@ -310,7 +328,10 @@ const evaluateCandidate = async (
return { ...base, outcome: "outside-target", resolved_path: null };
if (template.expansion.scope === "undetermined")
return { ...base, outcome: "undetermined", resolved_path: null };
const lookup = await resolveTreePath(context.view, template.expansion.path);
const lookup = await resolveTreePath(
context.view,
template.expansion.lookupPath ?? template.expansion.path,
);
if (lookup.kind === "escapes")
return { ...base, outcome: "escapes-target", resolved_path: null };
if (lookup.kind !== "file")