From 52d4ce6ced1c3b6ae7570a5df7f4499cd65a1182 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:42:09 -0700 Subject: [PATCH] fix(apple): derive dyld environment findings from each traced slice (#2733) deriveFindings looked up each traced root's slice again by architecture name. A universal executable with arm64e and arm64e.x1 slices reports both as "arm64e", so both roots resolved to the first slice: a later slice's LC_DYLD_ENVIRONMENT produced no finding, or the first slice's command was reported twice. The per-slice dyld_environment arrays were already correct. traceDylibLoading already has the exact slice when it records a root, so pass it to deriveFindings instead of re-deriving it from the name. The public roots and findings shapes are unchanged. Fixes #2661 Co-authored-by: Claude Opus 5.5 --- src/domain/apple/dylibResolution.ts | 5 +- src/domain/apple/dylibResolutionFindings.ts | 28 +++++----- tests/boundary/cli/dylibResolutionCli.test.ts | 56 +++++++++++++++++++ 3 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts index 799befdb1b..cf10c4b781 100644 --- a/src/domain/apple/dylibResolution.ts +++ b/src/domain/apple/dylibResolution.ts @@ -16,6 +16,7 @@ import { import { DYLIB_RESOLUTION_LIMITATIONS, deriveFindings, + type TracedRootSlice, } from "./dylibResolutionFindings.js"; /** @@ -568,6 +569,7 @@ export const traceDylibLoading = async ( for (const path of request.unclassified ?? []) images.set(path, await view.image(path)); const roots: DylibTrace["roots"][number][] = []; + const rootSlices: TracedRootSlice[] = []; const edges: Edge[] = []; const withoutArchitecture: string[] = []; const environmentRoots = new Set(); @@ -597,6 +599,7 @@ export const traceDylibLoading = async ( ) environmentRoots.add(root); roots.push({ image: root, architecture: slice.architecture }); + rootSlices.push({ image: root, slice }); edges.push( ...(await traceProcess( { @@ -641,7 +644,7 @@ export const traceDylibLoading = async ( facts.status === "parsed" ? facts.slices.map(withoutDependencies) : [], })), edges, - findings: deriveFindings(edges, roots, images), + findings: deriveFindings(edges, rootSlices), coverage: { status: unparsed.length === 0 && diff --git a/src/domain/apple/dylibResolutionFindings.ts b/src/domain/apple/dylibResolutionFindings.ts index ca506c17bd..eac488143a 100644 --- a/src/domain/apple/dylibResolutionFindings.ts +++ b/src/domain/apple/dylibResolutionFindings.ts @@ -1,14 +1,18 @@ import type { DylibEdge as Edge, DylibFinding as Finding, - DylibTrace, - MachoImageFacts, + MachoSlice, } from "./dylibResolution.js"; +/** One traced process root with the exact slice it was traced from. */ +export interface TracedRootSlice { + readonly image: string; + readonly slice: MachoSlice; +} + export const deriveFindings = ( edges: readonly Edge[], - roots: DylibTrace["roots"], - images: ReadonlyMap, + roots: readonly TracedRootSlice[], ): Finding[] => { const findings: Finding[] = []; edges.forEach((edge, index) => { @@ -32,15 +36,11 @@ export const deriveFindings = ( 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 slice = - facts?.status === "parsed" - ? facts.slices.find( - (candidate) => candidate.architecture === architecture, - ) - : undefined; - const environment = slice?.dyld_environment ?? []; + // Slices can share an architecture name (arm64e and arm64e.x1), so each + // finding uses the slice its root was traced from, not a name lookup. + for (const { image, slice } of roots) { + const { architecture } = slice; + const environment = slice.dyld_environment; if (environment.length > 0) findings.push({ kind: "dyld-environment-present", @@ -48,7 +48,7 @@ export const deriveFindings = ( image, basis: "derived", explanation: - slice?.file_type === "execute" + 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.`, }); diff --git a/tests/boundary/cli/dylibResolutionCli.test.ts b/tests/boundary/cli/dylibResolutionCli.test.ts index b74dae71e0..f6482a2f3a 100644 --- a/tests/boundary/cli/dylibResolutionCli.test.ts +++ b/tests/boundary/cli/dylibResolutionCli.test.ts @@ -4,11 +4,13 @@ import { dirname, join } from "node:path"; import { describe, expect } from "vitest"; import { + CPU, FILE_TYPE, LC, buildVersionCommand, dylibCommand, dyldEnvironmentCommand, + fatImage, machoImage, rpathCommand, } from "../../../src/artifacts/apple/MachoImage.fixture.js"; @@ -471,6 +473,60 @@ cliTest( }, ); +cliTest( + "reports each same-named slice's own embedded dyld environment", + async ({ cli }) => { + // arm64e and arm64e.x1 slices both report the architecture name arm64e. + for (const [settings, expected] of [ + [[null, "DYLD_PRINT_RPATHS=1"], ["DYLD_PRINT_RPATHS=1"]], + [ + ["DYLD_PRINT_LIBRARIES=1", "DYLD_PRINT_RPATHS=1"], + ["DYLD_PRINT_LIBRARIES=1", "DYLD_PRINT_RPATHS=1"], + ], + ] as const) { + const app = await fixtureApp(); + await writeFiles(app, { + "Contents/MacOS/App": fatImage( + [CPU.arm64e, CPU.arm64eX1].map((cpu, index) => { + const setting = settings[index]; + return { + cpu, + bytes: machoImage({ + cpu, + commands: + setting === null || setting === undefined + ? [] + : [dyldEnvironmentCommand(setting)], + }), + }; + }), + ), + }); + 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.roots).toHaveLength(2); + const environments = trace.findings + .filter(({ kind }) => kind === "dyld-environment-present") + .map(({ explanation }) => + expected.filter((setting) => explanation.includes(setting)), + ); + expect(environments).toEqual(expected.map((setting) => [setting])); + } + }, +); + cliTest( "retains definitive load findings for an explicitly selected app library root", async ({ cli }) => {