fix(apple): validate dylib candidates and preserve search uncertainty (#1100)

* fix(apple): validate dylib candidates and preserve search uncertainty

* fix(apple): scope embedded dyld uncertainty by search phase

* fix(apple): scope dyld prefixes to eligible paths and platforms

* fix(apple): ignore embedded process settings on library roots
This commit is contained in:
morluto 2026-10-09 01:43:52 +08:00 committed by GitHub
parent 3b2ec69c54
commit 7914e36ab9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 819 additions and 20 deletions

View File

@ -177,10 +177,14 @@ cache rather than on disk, so REA does not check them against the host.
- `required-load-unresolved`, `weak-load-unresolved`, and `lazy-load-unresolved`.
`LC_LAZY_LOAD_DYLIB` dependencies are resolved but not traversed, because dyld
loads them on first use; an unresolved one is not a launch failure.
- `earlier-rpath-candidate-absent`: a Mach-O placed at an earlier search path
would load first. Whether code-signing library validation would reject it is
not evaluated; use `inspect_signature`.
- `dyld-environment-present`: `LC_DYLD_ENVIRONMENT` search paths are not modeled.
- `earlier-rpath-candidate-absent`: a compatible Mach-O placed at an earlier
modeled search path could take precedence, subject to unmodeled search inputs
and code-signing library validation. These findings do not establish which
image dyld will load; use `inspect_signature` for signing metadata.
- `dyld-environment-present`: preserves observed `LC_DYLD_ENVIRONMENT` entries.
Only an executable root supplies process settings; entries on library and
other non-executable roots do not alter resolution or coverage. Applicable
executable image-selection overrides are not modeled.
`verify:macos-bundle` checks the parser against `otool -l` for every traced
image. It also compares the predicted load order of two process roots with the

View File

@ -378,3 +378,15 @@ describe("Mach-O load command reader failures", () => {
});
});
});
it.each([true, false])(
"rejects unconsumed command bytes in wide=%s headers",
async (wide) => {
const bytes = machoImage({ wide, commands: [rpathCommand("ignored")] });
new DataView(bytes.buffer).setUint32(16, 0, true);
expect(await read(bytes)).toMatchObject({
status: "malformed",
reason: expect.stringContaining("sizeofcmds"),
});
},
);

View File

@ -331,6 +331,11 @@ const decodeCommands = (
decodeCommand(command, body, index, slice);
offset += size;
}
if (offset !== bytes.byteLength)
throw new MachoFormatIssue(
"malformed",
`load commands declare ${header.commandCount} commands but sizeofcmds holds ${bytes.byteLength} bytes`,
);
return slice;
};

View File

@ -0,0 +1,103 @@
/** Search phase and image kind affected by one embedded dyld setting. */
export interface DyldSearchOverride {
readonly phase: "override" | "fallback";
readonly scope: "library" | "framework" | "prefix" | "all";
readonly variable: string;
readonly value: string;
}
/** Retain recognized image-selection settings separately from diagnostic observations. */
export const embeddedDyldOverrides = (
environment: readonly string[],
platforms: readonly number[],
): DyldSearchOverride[] => {
const overrides: DyldSearchOverride[] = [];
for (const setting of environment) {
const delimiter = setting.indexOf("=");
if (delimiter <= 0) continue;
const variable = setting.slice(0, delimiter);
const value = setting.slice(delimiter + 1);
const effect = overrideEffect(variable);
if (effect === undefined) continue;
// ROOT_PATH is accepted only for simulator processes. Missing or unknown
// platform metadata cannot establish that dyld ignores it.
if (
variable === "DYLD_ROOT_PATH" &&
platforms.length > 0 &&
platforms.every((platform) => platform >= 1 && platform <= 12) &&
!platforms.some((platform) => [7, 8, 9, 12].includes(platform))
)
continue;
if (
(variable === "DYLD_INSERT_LIBRARIES" ||
variable === "DYLD_IMAGE_SUFFIX" ||
effect.scope === "prefix") &&
value === ""
)
continue;
overrides.push({ variable, value, ...effect });
}
return overrides;
};
const overrideEffect = (
variable: string,
): Pick<DyldSearchOverride, "phase" | "scope"> | undefined => {
switch (variable) {
case "DYLD_LIBRARY_PATH":
return { phase: "override", scope: "library" };
case "DYLD_FRAMEWORK_PATH":
return { phase: "override", scope: "framework" };
case "DYLD_FALLBACK_LIBRARY_PATH":
return { phase: "fallback", scope: "library" };
case "DYLD_FALLBACK_FRAMEWORK_PATH":
return { phase: "fallback", scope: "framework" };
// Versioned paths map discovered images to their reported install names,
// so the directory's kind does not establish the replaced image's kind.
case "DYLD_VERSIONED_LIBRARY_PATH":
case "DYLD_VERSIONED_FRAMEWORK_PATH":
case "DYLD_INSERT_LIBRARIES":
case "DYLD_IMAGE_SUFFIX":
return { phase: "override", scope: "all" };
case "DYLD_ROOT_PATH":
case "DYLD_OVERLAY_PATH":
return { phase: "override", scope: "prefix" };
default:
return undefined;
}
};
const isFrameworkInstallName = (
name: string,
overrides: readonly DyldSearchOverride[],
): boolean => {
const separator = name.lastIndexOf(".framework/");
if (separator < 0) return false;
const framework = name.slice(0, separator).split("/").at(-1);
const leaf = name.slice(name.lastIndexOf("/") + 1);
return (
leaf === framework ||
overrides.some(
({ variable, value }) =>
variable === "DYLD_IMAGE_SUFFIX" && leaf === `${framework}${value}`,
)
);
};
/** Select only settings that can affect this dependency at its reached search phase. */
export const hasApplicableDyldOverrides = (
overrides: readonly DyldSearchOverride[],
installName: string,
ordinarySearchFoundImage: boolean,
): boolean => {
const kind = isFrameworkInstallName(installName, overrides)
? "framework"
: "library";
return overrides.some(
({ phase, scope }) =>
(phase === "override" || !ordinarySearchFoundImage) &&
(scope === "all" ||
scope === kind ||
(scope === "prefix" && !installName.startsWith("@"))),
);
};

View File

@ -160,7 +160,7 @@ describe("dyld path expansion", () => {
}),
[plugin]: parsed(
slice({
file_type: "bundle",
file_type: "dylib",
dependencies: [dependency("@rpath/libshared.dylib")],
}),
),
@ -316,13 +316,15 @@ describe("dyld resolution outcomes: deriving path and weak-load outcomes", () =>
trace.findings.map(({ kind, edge_index: index }) => [kind, index]),
).toEqual([
["earlier-rpath-candidate-absent", 0],
["weak-load-unresolved", 1],
["required-load-unresolved", 2],
["dyld-environment-present", null],
]);
expect(trace.findings[0]?.explanation).toContain(
"Contents/MacOS/../Overrides/libfound.dylib",
);
expect(trace.findings[0]?.explanation).toContain(
"Unmodeled environment overrides",
);
expect(trace.coverage.status).toBe("partial");
});
});
@ -716,3 +718,390 @@ describe("conditional loads", () => {
).toContain(`${VENDOR} loads only conditionally; if it loads,`);
});
});
it("propagates embedded search-path uncertainty through found, missing, lazy and descendant edges", async () => {
const child = "Contents/MacOS/child.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dyld_environment: ["DYLD_LIBRARY_PATH=/tmp"],
dependencies: [
dependency("@executable_path/child.dylib"),
dependency("@executable_path/missing.dylib"),
dependency("@executable_path/lazy.dylib", {
command: "LC_LAZY_LOAD_DYLIB",
}),
],
}),
[child]: parsed(
slice({ dependencies: [dependency("@loader_path/gone.dylib")] }),
),
}),
{ roots: [MAIN] },
);
expect(trace.edges.map(({ resolution }) => resolution.status)).toEqual([
"conditional",
"undetermined",
"undetermined",
"undetermined",
]);
expect(trace.edges.at(-1)?.loader_conditional).toBe(true);
expect(trace.findings.map(({ kind }) => kind)).toEqual([
"dyld-environment-present",
]);
});
it.each([
"DYLD_PRINT_LIBRARIES=1",
"DYLD_PRINT_RPATHS=1",
"DYLD_LIBRARY_PATH_LOG=/tmp",
"DYLD_LIBRARY_PATH",
"DYLD_IMAGE_SUFFIX=",
"DYLD_INSERT_LIBRARIES=",
])(
"retains %s as an observation without adding path uncertainty",
async (setting) => {
const child = "Contents/MacOS/child.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dyld_environment: [setting],
dependencies: [dependency("@executable_path/child.dylib")],
}),
[child]: parsed(slice()),
}),
{ roots: [MAIN] },
);
expect(trace.edges[0]?.resolution.status).toBe("resolved");
expect(trace.coverage.status).toBe("complete");
expect(
trace.images.find(({ path }) => path === MAIN)?.slices[0]
?.dyld_environment,
).toEqual([setting]);
expect(trace.findings.map(({ kind }) => kind)).toContain(
"dyld-environment-present",
);
},
);
it.each(["execute", "bundle", "other"] as const)(
"does not resolve a %s image as a dylib dependency",
async (file_type) => {
const child = "Contents/MacOS/child.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dependencies: [dependency("@executable_path/child.dylib")],
}),
[child]: parsed(slice({ file_type })),
}),
{ roots: [MAIN] },
);
expect(trace.edges[0]).toMatchObject({
candidates: [{ outcome: "not-loadable" }],
resolution: { status: "unresolved", image: null },
});
expect(trace.edges).toHaveLength(1);
},
);
it.each([
"@loader_path/child.dylib",
"@executable_path/child.dylib",
"@rpath/child.dylib",
])(
"keeps a found %s definitive under library fallback paths",
async (install_name) => {
const child = "Contents/MacOS/child.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dyld_environment: ["DYLD_FALLBACK_LIBRARY_PATH=/fallback"],
rpaths: ["@executable_path"],
dependencies: [dependency(install_name)],
}),
[child]: parsed(
slice({
dependencies: [dependency("@loader_path/grandchild.dylib")],
}),
),
"Contents/MacOS/grandchild.dylib": parsed(slice()),
}),
{ roots: [MAIN] },
);
expect(trace.edges.map(({ resolution }) => resolution.status)).toEqual([
"resolved",
"resolved",
]);
expect(
trace.edges.every(({ loader_conditional }) => !loader_conditional),
).toBe(true);
expect(trace.coverage.status).toBe("complete");
},
);
it.each([
[
"DYLD_FRAMEWORK_PATH=/override",
"@loader_path/child.dylib",
"resolved",
"complete",
],
[
"DYLD_FRAMEWORK_PATH=/override",
"@loader_path/Foo.framework/Libraries/child.dylib",
"resolved",
"complete",
],
[
"DYLD_FRAMEWORK_PATH=/override",
"@loader_path/Foo.framework/Bar",
"resolved",
"complete",
],
[
"DYLD_FRAMEWORK_PATH=/override",
"@loader_path/Foo.framework/Foo",
"conditional",
"partial",
],
[
"DYLD_FRAMEWORK_PATH=/override",
"@loader_path/Foo.framework/Versions/A/Foo",
"conditional",
"partial",
],
[
"DYLD_LIBRARY_PATH=/override",
"@loader_path/Foo.framework/Foo",
"resolved",
"complete",
],
[
"DYLD_LIBRARY_PATH=/override",
"@loader_path/Foo.framework/Libraries/child.dylib",
"conditional",
"partial",
],
[
"DYLD_FALLBACK_FRAMEWORK_PATH=/fallback",
"@loader_path/Foo.framework/Foo",
"resolved",
"complete",
],
[
"DYLD_FALLBACK_FRAMEWORK_PATH=/fallback",
"@loader_path/child.dylib",
"resolved",
"complete",
],
] as const)(
"scopes %s to the image kind and reached search phase of %s",
async (setting, install_name, status, coverage) => {
const child = install_name.replace("@loader_path", "Contents/MacOS");
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dyld_environment: [setting],
dependencies: [dependency(install_name)],
}),
[child]: parsed(slice()),
}),
{ roots: [MAIN] },
);
expect(trace.edges[0]?.resolution).toEqual({ status, image: child });
expect(trace.coverage.status).toBe(coverage);
},
);
it.each([
[
"DYLD_FALLBACK_LIBRARY_PATH=/fallback",
"@loader_path/missing.dylib",
"undetermined",
"partial",
],
[
"DYLD_FALLBACK_FRAMEWORK_PATH=/fallback",
"@loader_path/Foo.framework/Foo",
"undetermined",
"partial",
],
[
"DYLD_FALLBACK_FRAMEWORK_PATH=/fallback",
"@loader_path/missing.dylib",
"unresolved",
"complete",
],
[
"DYLD_FALLBACK_LIBRARY_PATH=/fallback",
"@loader_path/Foo.framework/Foo",
"unresolved",
"complete",
],
] as const)(
"retains the applicable fallback uncertainty for %s and %s",
async (setting, install_name, status, coverage) => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dyld_environment: [setting],
dependencies: [dependency(install_name)],
}),
}),
{ roots: [MAIN] },
);
expect(trace.edges[0]?.resolution).toEqual({ status, image: null });
expect(trace.coverage.status).toBe(coverage);
},
);
it.each([
["DYLD_ROOT_PATH=/root", [1], "@loader_path/child.dylib"],
["DYLD_ROOT_PATH=/root", [7], "@loader_path/child.dylib"],
["DYLD_ROOT_PATH=/root", [8], "@executable_path/child.dylib"],
["DYLD_ROOT_PATH=/root", [9], "@rpath/child.dylib"],
["DYLD_ROOT_PATH=/root", [12], "@rpath/child.dylib"],
["DYLD_OVERLAY_PATH=/overlay", [1], "@rpath/child.dylib"],
["DYLD_OVERLAY_PATH=/overlay", [7], "@loader_path/child.dylib"],
["DYLD_ROOT_PATH=", [7], "@loader_path/child.dylib"],
["DYLD_OVERLAY_PATH=", [1], "@loader_path/child.dylib"],
] as const)(
"limits prefix setting %s on platforms %j for %s",
async (setting, platforms, install_name) => {
const child = "Contents/MacOS/child.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
platforms: [...platforms],
dyld_environment: [setting],
rpaths: ["@executable_path"],
dependencies: [dependency(install_name)],
}),
[child]: parsed(slice({ platforms: [...platforms] })),
}),
{ roots: [MAIN] },
);
expect(trace.edges[0]?.resolution.status).toBe("resolved");
expect(trace.coverage.status).toBe("complete");
expect(
trace.images.find(({ path }) => path === MAIN)?.slices[0]
?.dyld_environment,
).toEqual([setting]);
},
);
it.each([
["DYLD_ROOT_PATH=/root", [1], "complete"],
["DYLD_ROOT_PATH=/root", [2], "complete"],
["DYLD_ROOT_PATH=/root", [6], "complete"],
["DYLD_ROOT_PATH=/root", [7], "partial"],
["DYLD_ROOT_PATH=/root", [8], "partial"],
["DYLD_ROOT_PATH=/root", [9], "partial"],
["DYLD_ROOT_PATH=/root", [12], "partial"],
["DYLD_ROOT_PATH=/root", [], "partial"],
["DYLD_ROOT_PATH=/root", [99], "partial"],
["DYLD_OVERLAY_PATH=/overlay", [1], "partial"],
] as const)(
"retains only possible absolute-path prefix uncertainty for %s on %j",
async (setting, platforms, coverage) => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
platforms: [...platforms],
dyld_environment: [setting],
dependencies: [dependency("/usr/lib/child.dylib")],
}),
}),
{ roots: [MAIN] },
);
expect(trace.edges[0]?.resolution.status).toBe("undetermined");
expect(trace.coverage.status).toBe(coverage);
},
);
it.each(["dylib", "bundle", "other"] as const)(
"ignores embedded process settings from a %s root while retaining load findings",
async (file_type) => {
const root = "Contents/MacOS/root";
const child = "Contents/MacOS/child.dylib";
const settings = [
"DYLD_LIBRARY_PATH=/external",
"DYLD_INSERT_LIBRARIES=/external/injected.dylib",
];
const trace = await traceDylibLoading(
memoryView({
[root]: parsed(
slice({
file_type,
dyld_environment: settings,
dependencies: [
dependency("@loader_path/child.dylib"),
dependency("@loader_path/missing.dylib"),
dependency("@loader_path/lazy.dylib", {
command: "LC_LAZY_LOAD_DYLIB",
}),
],
}),
),
[child]: parsed(
slice({
dependencies: [dependency("@loader_path/descendant.dylib")],
}),
),
}),
{ roots: [root] },
);
expect(trace.edges.map(({ resolution }) => resolution.status)).toEqual([
"resolved",
"unresolved",
"unresolved",
"unresolved",
]);
expect(
trace.edges.every(({ loader_conditional }) => !loader_conditional),
).toBe(true);
expect(trace.coverage.status).toBe("complete");
expect(
trace.findings.filter(({ kind }) => kind === "required-load-unresolved"),
).toHaveLength(2);
expect(
trace.findings.find(({ kind }) => kind === "required-load-unresolved")
?.explanation,
).toContain("load this required dependency");
expect(
trace.findings.filter(({ kind }) => kind === "lazy-load-unresolved"),
).toHaveLength(1);
expect(
trace.images.find(({ path }) => path === root)?.slices[0]
?.dyld_environment,
).toEqual(settings);
expect(
trace.findings.find(({ kind }) => kind === "dyld-environment-present")
?.explanation,
).toContain("entries in this non-executable root are observations");
},
);
it("keeps inserted-library uncertainty scoped to the executable's process", async () => {
const library = "Contents/MacOS/library.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dyld_environment: ["DYLD_INSERT_LIBRARIES=/external/injected.dylib"],
}),
[library]: parsed(
slice({
dyld_environment: ["DYLD_INSERT_LIBRARIES=/external/ignored.dylib"],
}),
),
}),
{ roots: [library, MAIN] },
);
expect(trace.coverage.status).toBe("partial");
const limitation = trace.limitations.find((value) =>
value.includes("LC_DYLD_ENVIRONMENT"),
);
expect(limitation).toContain(MAIN);
expect(limitation).not.toContain(library);
});

View File

@ -9,6 +9,11 @@ import {
type Expansion,
} from "./dyldPaths.js";
import { digestSchema } from "../digests.js";
import {
hasApplicableDyldOverrides,
embeddedDyldOverrides,
type DyldSearchOverride,
} from "./dyldEnvironment.js";
import {
DYLIB_RESOLUTION_LIMITATIONS,
deriveFindings,
@ -207,6 +212,8 @@ interface ProcessContext {
readonly view: DylibTreeView;
readonly root: string;
readonly architecture: string;
readonly searchOverrides: readonly DyldSearchOverride[];
readonly environmentRoots: Set<string>;
readonly platforms: readonly number[];
readonly executable: string | null;
readonly loaded: Map<string, LoadedImage>;
@ -290,8 +297,9 @@ const evaluateCandidate = async (
else {
const slice = compatibleSlice(facts.slices, context.architecture);
if (slice === undefined) outcome = "architecture-missing";
else if (![2, 6, 7, 8].includes(slice.file_type_code))
outcome = "not-loadable";
// Dependency commands require a dylib; generic dyld image loadability
// also admits main executables and bundles for different operations.
else if (slice.file_type_code !== 6) outcome = "not-loadable";
else {
const platformResult = platformLoadability(
context.platforms,
@ -425,7 +433,23 @@ const resolveDependency = async (
// dyld stops at the first loadable candidate.
if (candidate.outcome === "resolved") break;
}
const searched = resolution(candidates);
const modeled = resolution(candidates);
const searchUnknown =
loaded === undefined &&
hasApplicableDyldOverrides(
context.searchOverrides,
dependency.install_name,
modeled.image !== null,
);
if (searchUnknown) context.environmentRoots.add(context.root);
// Fallbacks cannot preempt a modeled hit, and framework paths do not
// override ordinary dylibs. Reuse inherits uncertainty from the first load.
const searched: Edge["resolution"] = searchUnknown
? {
...modeled,
status: modeled.image === null ? "undetermined" : "conditional",
}
: modeled;
// Reusing an image that itself loads only conditionally is conditional too.
const resolved: Edge["resolution"] =
loaded !== undefined &&
@ -528,6 +552,7 @@ export const traceDylibLoading = async (
const roots: DylibTrace["roots"][number][] = [];
const edges: Edge[] = [];
const withoutArchitecture: string[] = [];
const environmentRoots = new Set<string>();
for (const root of request.roots) {
const facts = await view.image(root);
images.set(root, facts);
@ -539,6 +564,20 @@ export const traceDylibLoading = async (
);
if (slices.length === 0) withoutArchitecture.push(root);
for (const slice of slices) {
const executable = slice.file_type === "execute" ? root : null;
// Only the main executable supplies the process's embedded environment.
const searchOverrides =
executable === null
? []
: embeddedDyldOverrides(slice.dyld_environment, slice.platforms);
// Inserted libraries introduce unexamined images even without dependencies.
if (
searchOverrides.some(
({ variable, value }) =>
variable === "DYLD_INSERT_LIBRARIES" && value !== "",
)
)
environmentRoots.add(root);
roots.push({ image: root, architecture: slice.architecture });
edges.push(
...(await traceProcess(
@ -546,8 +585,10 @@ export const traceDylibLoading = async (
view,
root,
architecture: slice.architecture,
searchOverrides,
environmentRoots,
platforms: slice.platforms,
executable: slice.file_type === "execute" ? root : null,
executable,
loaded: new Map(),
byInstallName: new Map(),
images,
@ -581,13 +622,22 @@ export const traceDylibLoading = async (
findings: deriveFindings(edges, roots, images),
coverage: {
status:
unparsed.length === 0 && withoutArchitecture.length === 0
unparsed.length === 0 &&
withoutArchitecture.length === 0 &&
environmentRoots.size === 0
? "complete"
: "partial",
unparsed_images: unparsed,
roots_without_architecture: withoutArchitecture,
},
limitations: DYLIB_RESOLUTION_LIMITATIONS,
limitations: [
...DYLIB_RESOLUTION_LIMITATIONS,
...(environmentRoots.size === 0
? []
: [
`Roots ${[...environmentRoots].join(", ")} request applicable image-selection inputs through LC_DYLD_ENVIRONMENT that are not modeled; affected dependencies or inserted images remain uncertain.`,
]),
],
};
};

View File

@ -29,23 +29,28 @@ export const deriveFindings = (
edge_index: index,
image: edge.loader,
basis: "derived",
explanation: `dyld searches ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A Mach-O placed at an earlier path would load first unless code-signing library validation rejects it; library validation is not evaluated here (see inspect_signature).`,
explanation: `The modeled dyld search checks ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A compatible Mach-O placed at an earlier modeled path could take precedence over that fallback, subject to unmodeled search inputs and code-signing library validation; library validation is not evaluated here (see inspect_signature).${edge.loader_conditional ? " This loader loads only conditionally; if it never loads, this search never occurs." : ""}${edge.resolution.status === "conditional" ? " Unmodeled environment overrides or earlier unknown candidates may take precedence; placing an image at this rpath does not establish which image dyld will load." : ""}`,
});
});
for (const { image, architecture } of roots) {
const facts = images.get(image);
const environment =
const slice =
facts?.status === "parsed"
? (facts.slices.find((slice) => slice.architecture === architecture)
?.dyld_environment ?? [])
: [];
? facts.slices.find(
(candidate) => candidate.architecture === architecture,
)
: undefined;
const environment = slice?.dyld_environment ?? [];
if (environment.length > 0)
findings.push({
kind: "dyld-environment-present",
edge_index: null,
image,
basis: "derived",
explanation: `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); search paths they add are not modeled.`,
explanation:
slice?.file_type === "execute"
? `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); any image-selection overrides they request are not modeled. Diagnostic settings alone do not alter resolution.`
: `${image} (${architecture}) contains LC_DYLD_ENVIRONMENT (${environment.join(", ")}). Dyld reads these commands only from the main executable; entries in this non-executable root are observations and do not alter resolution.`,
});
}
return findings;
@ -77,7 +82,7 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
: {
...base,
kind: "required-load-unresolved",
explanation: `${missing}${when}dyld would fail to launch ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`,
explanation: `${missing}${when}dyld would fail to load this required dependency for ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`,
};
};

View File

@ -8,10 +8,12 @@ import {
LC,
buildVersionCommand,
dylibCommand,
dyldEnvironmentCommand,
machoImage,
rpathCommand,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
import { dylibResolutionResultSchema } from "../../../src/domain/apple/dylibResolution.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@ -254,3 +256,113 @@ describe.skipIf(process.getuid?.() === 0)(
);
},
);
cliTest(
"reports embedded path overrides as conditional without changing diagnostic-only resolution",
async ({ cli }) => {
for (const [setting, expected] of [
["DYLD_LIBRARY_PATH=/external", "conditional"],
["DYLD_PRINT_LIBRARIES=1", "resolved"],
["DYLD_FALLBACK_LIBRARY_PATH=/external", "resolved"],
["DYLD_FRAMEWORK_PATH=/external", "resolved"],
["DYLD_ROOT_PATH=/external", "resolved"],
["DYLD_OVERLAY_PATH=/external", "resolved"],
] as const) {
const app = await fixtureApp();
await writeFiles(app, {
"Contents/MacOS/App": machoImage({
commands: [
dyldEnvironmentCommand(setting),
buildVersionCommand(1),
rpathCommand("@executable_path/../Absent"),
rpathCommand("@executable_path/../Frameworks"),
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
],
}),
});
const result = await cli.run({
arguments: [
"trace-dylib-resolution",
app,
"--root",
"Contents/MacOS/App",
"--json",
],
environment: ENVIRONMENT,
});
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
const trace = dylibResolutionResultSchema.parse(
parseEvidence(result.json).normalized_result,
);
expect(trace.edges[0]?.resolution.status).toBe(expected);
expect(trace.coverage.status).toBe(
expected === "conditional" ? "partial" : "complete",
);
expect(
trace.images.find(({ path }) => path === "Contents/MacOS/App")
?.slices[0]?.dyld_environment,
).toEqual([setting]);
if (expected === "conditional")
expect(trace.findings[0]?.explanation).toContain(
"does not establish which image dyld will load",
);
}
},
);
cliTest(
"retains definitive load findings for an explicitly selected app library root",
async ({ cli }) => {
const app = await fixtureApp();
const settings = [
"DYLD_LIBRARY_PATH=/external",
"DYLD_INSERT_LIBRARIES=/external/injected.dylib",
];
await writeFiles(app, {
"Contents/Frameworks/libcore.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [
buildVersionCommand(1),
...settings.map(dyldEnvironmentCommand),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/child.dylib"),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/missing.dylib"),
],
}),
"Contents/Frameworks/child.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
});
const result = await cli.run({
arguments: [
"trace-dylib-resolution",
app,
"--root",
"Contents/Frameworks/libcore.dylib",
"--json",
],
environment: ENVIRONMENT,
});
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
const trace = dylibResolutionResultSchema.parse(
parseEvidence(result.json).normalized_result,
);
expect(trace.edges.map(({ resolution }) => resolution.status)).toEqual([
"resolved",
"unresolved",
]);
expect(trace.coverage.status).toBe("complete");
expect(trace.findings.map(({ kind }) => kind)).toContain(
"required-load-unresolved",
);
expect(
trace.images.find(
({ path }) => path === "Contents/Frameworks/libcore.dylib",
)?.slices[0]?.dyld_environment,
).toEqual(settings);
expect(
trace.findings.find(({ kind }) => kind === "dyld-environment-present")
?.explanation,
).toContain("entries in this non-executable root are observations");
},
);

View File

@ -11,6 +11,7 @@ import {
LC,
buildVersionCommand,
dylibCommand,
dyldEnvironmentCommand,
machoImage,
rpathCommand,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
@ -204,3 +205,121 @@ it.skipIf(process.getuid?.() === 0)(
});
},
);
it.each([
"DYLD_LIBRARY_PATH=/external",
"DYLD_PRINT_RPATHS=1",
"DYLD_FALLBACK_LIBRARY_PATH=/external",
"DYLD_FRAMEWORK_PATH=/external",
"DYLD_ROOT_PATH=/external",
"DYLD_OVERLAY_PATH=/external",
])("preserves %s and its resolution semantics through MCP", async (setting) => {
const root = await createTestTempDirectory("rea-dylib-environment-mcp-");
const program = join(root, "program");
await writeFile(
program,
machoImage({
commands: [
dyldEnvironmentCommand(setting),
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/child.dylib"),
],
}),
);
await writeFile(
join(root, "child.dylib"),
machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
);
await withClient(async (client) => {
const opened = await client.callTool({
name: "open_binary",
arguments: { path: program },
});
expect(opened.isError).not.toBe(true);
const result = await client.callTool({
name: "trace_dylib_resolution",
arguments: {},
});
expect(result.isError, JSON.stringify(result.structuredContent)).not.toBe(
true,
);
const trace = dylibResolutionResultSchema.parse(
structuredResult(result.structuredContent),
);
const overrides = setting.startsWith("DYLD_LIBRARY_PATH=");
expect(trace.edges[0]?.resolution.status).toBe(
overrides ? "conditional" : "resolved",
);
expect(trace.coverage.status).toBe(overrides ? "partial" : "complete");
expect(
trace.images.find(({ path }) => path === "program")?.slices[0]
?.dyld_environment,
).toEqual([setting]);
});
});
it("ignores process settings on an explicitly selected library root through MCP", async () => {
const root = await createTestTempDirectory("rea-dylib-root-mcp-");
const path = join(root, "library.dylib");
const settings = [
"DYLD_LIBRARY_PATH=/external",
"DYLD_INSERT_LIBRARIES=/external/injected.dylib",
];
await writeFile(
path,
machoImage({
fileType: FILE_TYPE.dylib,
commands: [
buildVersionCommand(1),
...settings.map(dyldEnvironmentCommand),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/child.dylib"),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/missing.dylib"),
],
}),
);
await writeFile(
join(root, "child.dylib"),
machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
);
await withClient(async (client) => {
const opened = await client.callTool({
name: "open_binary",
arguments: { path },
});
expect(opened.isError, JSON.stringify(opened.structuredContent)).not.toBe(
true,
);
const result = await client.callTool({
name: "trace_dylib_resolution",
arguments: { roots: ["library.dylib"] },
});
expect(result.isError, JSON.stringify(result.structuredContent)).not.toBe(
true,
);
const trace = dylibResolutionResultSchema.parse(
structuredResult(result.structuredContent),
);
expect(trace.edges.map(({ resolution }) => resolution.status)).toEqual([
"resolved",
"unresolved",
]);
expect(trace.coverage.status).toBe("complete");
expect(trace.findings.map(({ kind }) => kind)).toContain(
"required-load-unresolved",
);
expect(
trace.images.find(({ path }) => path === "library.dylib")?.slices[0]
?.dyld_environment,
).toEqual(settings);
expect(
trace.findings.find(({ kind }) => kind === "dyld-environment-present")
?.explanation,
).toContain("entries in this non-executable root are observations");
});
});