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 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-13 17:42:09 +08:00
1 parent e05d3399f1
commit 52d4ce6ced
3 files changed
+74 -15

No files matched your search

+4 -1
View File
@@ -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<string>();
@@ -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 &&
+14 -14
View File
@@ -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<string, MachoImageFacts>,
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.`,
});
@@ -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 }) => {