diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index e99504e16..2218252ef 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -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 diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts index 4a76caf22..4250db968 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.test.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts @@ -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"), + }); + }, +); diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index 8b7593a65..6389c5e1b 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -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; }; diff --git a/src/domain/apple/dyldEnvironment.ts b/src/domain/apple/dyldEnvironment.ts new file mode 100644 index 000000000..f0bf6cfad --- /dev/null +++ b/src/domain/apple/dyldEnvironment.ts @@ -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 | 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("@"))), + ); +}; diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts index 4cf8de125..f79583d88 100644 --- a/src/domain/apple/dylibResolution.test.ts +++ b/src/domain/apple/dylibResolution.test.ts @@ -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); +}); diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts index 9566700db..7f268bad7 100644 --- a/src/domain/apple/dylibResolution.ts +++ b/src/domain/apple/dylibResolution.ts @@ -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; readonly platforms: readonly number[]; readonly executable: string | null; readonly loaded: Map; @@ -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(); 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.`, + ]), + ], }; }; diff --git a/src/domain/apple/dylibResolutionFindings.ts b/src/domain/apple/dylibResolutionFindings.ts index 109ad0f7f..e4cc1ddf2 100644 --- a/src/domain/apple/dylibResolutionFindings.ts +++ b/src/domain/apple/dylibResolutionFindings.ts @@ -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.`, }; }; diff --git a/tests/boundary/cli/dylibResolutionCli.test.ts b/tests/boundary/cli/dylibResolutionCli.test.ts index a5f29f1b8..a087c746f 100644 --- a/tests/boundary/cli/dylibResolutionCli.test.ts +++ b/tests/boundary/cli/dylibResolutionCli.test.ts @@ -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"); + }, +); diff --git a/tests/boundary/mcp/dylibResolutionMcp.test.ts b/tests/boundary/mcp/dylibResolutionMcp.test.ts index d2416a1b0..a5d542d78 100644 --- a/tests/boundary/mcp/dylibResolutionMcp.test.ts +++ b/tests/boundary/mcp/dylibResolutionMcp.test.ts @@ -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"); + }); +});