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:
1 parent
84c89d1107
commit
1502cfbc8c
6 files changed
+312
-19
No files matched your search
@@ -228,9 +228,13 @@ root supplies. Every edge below it has `loader_conditional: true`, and a
|
||||
request that reuses it by install name is `conditional` too. Findings on those
|
||||
edges say that they apply only if the conditional image loads.
|
||||
|
||||
Absolute install names such as `/usr/lib/libSystem.B.dylib` are
|
||||
`outside-target`. On macOS 11 and later most of them live in the dyld shared
|
||||
cache rather than on disk, so REA does not check them against the host.
|
||||
Absolute install names that name a path beneath the analyzed root are checked
|
||||
within that root, with symlinks and `..` resolved by the same bounded tree
|
||||
resolver. Absolute paths outside the root, such as
|
||||
`/usr/lib/libSystem.B.dylib`, are `outside-target`; on macOS 11 and later most
|
||||
system libraries live in the dyld shared cache rather than on disk, so REA does
|
||||
not check them against the host. Mach-O path strings are not converted from
|
||||
`file:` URLs or looked up through unrelated host paths.
|
||||
|
||||
`findings` are derived, not observed:
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user