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:
parent
3b2ec69c54
commit
7914e36ab9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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("@"))),
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
]),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue