fix(apple): classify empty dyld settings by consumer semantics (#1110)

This commit is contained in:
morluto authored and GitHub committed 2026-10-09 01:54:32 +08:00
1 parent 1e63144390
commit fe297e56e8
8 files changed
+474 -11

No files matched your search

+8
View File
@@ -186,6 +186,14 @@ cache rather than on disk, so REA does not check them against the host.
other non-executable roots do not alter resolution or coverage. Applicable
executable image-selection overrides are not modeled.
Empty dyld settings have different consumers. Modern dyld appends a slash and
image name to ordinary and fallback directory entries, so an empty entry can
search `/child.dylib` or `/Foo.framework/Foo`. REA retains uncertainty for those
unchecked paths; an empty fallback does not preempt an ordinary-search hit.
Empty versioned-directory lists do not scan a directory, and empty suffixes
only repeat the original candidate, so they do not add search uncertainty.
Reported command values are retained in all cases.
`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
images dyld actually loads (`DYLD_PRINT_LIBRARIES`).
+6
View File
@@ -113,6 +113,12 @@ covers `trace-dylib-resolution`:
- for the main executable and an XPC service, a predicted load order equal to
the images dyld actually loads under `DYLD_PRINT_LIBRARIES`.
The lane also compiles executable/library pairs with empty embedded directory,
versioned-path, and suffix settings. It checks present and removed dependencies
through CLI and MCP, and compares actual `DYLD_PRINT_SEARCHING` diagnostics for
root-level candidates. These cases distinguish an empty search directory from
an empty versioned scan or a suffix that only repeats the original path.
It runs in macOS CI.
Apple artifact verifiers live in `scripts/verify/apple/`, with the macOS bundle
+223
View File
@@ -0,0 +1,223 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { promisify } from "node:util";
import {
artifactCli,
artifactMcpResult,
withArtifactMcp,
} from "../../lib/artifact-e2e.mjs";
const exec = promisify(execFile);
const SEARCH_CASES = [
{
setting: "DYLD_FALLBACK_LIBRARY_PATH=",
image: "library",
found: "resolved",
missing: "undetermined",
foundCoverage: "partial",
missingCoverage: "partial",
rootSearch: true,
},
{
setting: "DYLD_FALLBACK_LIBRARY_PATH=:",
image: "library",
found: "resolved",
missing: "undetermined",
foundCoverage: "partial",
missingCoverage: "partial",
rootSearch: true,
},
{
setting: "DYLD_LIBRARY_PATH=",
image: "library",
found: "conditional",
missing: "undetermined",
foundCoverage: "partial",
missingCoverage: "partial",
rootSearch: true,
},
{
setting: "DYLD_FALLBACK_FRAMEWORK_PATH=",
image: "framework",
found: "resolved",
missing: "undetermined",
foundCoverage: "complete",
missingCoverage: "partial",
rootSearch: true,
},
{
setting: "DYLD_FRAMEWORK_PATH=",
image: "framework",
found: "conditional",
missing: "undetermined",
foundCoverage: "partial",
missingCoverage: "partial",
rootSearch: true,
},
{
setting: "DYLD_VERSIONED_LIBRARY_PATH=",
image: "library",
found: "resolved",
missing: "unresolved",
foundCoverage: "complete",
missingCoverage: "complete",
rootSearch: false,
},
{
setting: "DYLD_VERSIONED_LIBRARY_PATH=:",
image: "library",
found: "resolved",
missing: "unresolved",
foundCoverage: "complete",
missingCoverage: "complete",
rootSearch: false,
},
{
setting: "DYLD_VERSIONED_FRAMEWORK_PATH=",
image: "framework",
found: "resolved",
missing: "unresolved",
foundCoverage: "complete",
missingCoverage: "complete",
rootSearch: false,
},
{
setting: "DYLD_IMAGE_SUFFIX=:",
image: "library",
found: "resolved",
missing: "unresolved",
foundCoverage: "complete",
missingCoverage: "complete",
rootSearch: false,
},
{
setting: "DYLD_IMAGE_SUFFIX=:_debug:",
image: "library",
found: "conditional",
missing: "undetermined",
foundCoverage: "partial",
missingCoverage: "partial",
rootSearch: false,
},
];
const verifyTrace = async (program, expected) => {
const { setting, installName, status, coverage } = expected;
const trace = await artifactCli("trace-dylib-resolution", program);
const edge = trace.edges.find(
({ install_name }) => install_name === installName,
);
assert.equal(edge?.resolution.status, status, setting);
assert.equal(trace.coverage.status, coverage, setting);
assert.deepEqual(
trace.images.find(({ path }) => path === "program")?.slices[0]
?.dyld_environment,
[setting],
);
await withArtifactMcp(program, async (client) => {
assert.deepEqual(
await artifactMcpResult(client, "trace_dylib_resolution"),
trace,
);
});
};
const missingRuntime = async (program) => {
try {
await exec(program, [], {
env: { PATH: "/usr/bin:/bin", DYLD_PRINT_SEARCHING: "1" },
});
} catch (cause) {
assert.equal(typeof cause?.stderr, "string");
assert.ok(cause.stderr.includes("Library not loaded:"), cause.stderr);
return cause.stderr;
}
assert.fail(
"The required dependency was removed, but the executable launched",
);
};
const verifyCase = async (directory, source, sample) => {
const {
setting,
installName,
found,
missing,
foundCoverage,
missingCoverage,
rootSearch,
} = sample;
const library = join(directory, installName.slice("@loader_path/".length));
const program = join(directory, "program");
await mkdir(dirname(library), { recursive: true });
await exec("/usr/bin/xcrun", [
"clang",
"-dynamiclib",
source.library,
"-o",
library,
`-Wl,-install_name,${installName}`,
]);
await exec("/usr/bin/xcrun", [
"clang",
source.main,
library,
"-o",
program,
`-Wl,-dyld_env,${setting}`,
]);
await exec(program, [], { env: { PATH: "/usr/bin:/bin" } });
await verifyTrace(program, {
setting,
installName,
status: found,
coverage: foundCoverage,
});
await rm(library);
const searches = await missingRuntime(program);
const rootCandidate = installName.replace("@loader_path/", "/");
assert.equal(searches.includes(`"${rootCandidate}"`), rootSearch, searches);
await verifyTrace(program, {
setting,
installName,
status: missing,
coverage: missingCoverage,
});
};
/** Check empty embedded settings against real dyld search diagnostics and CLI/MCP results. */
export async function verifyDyldEnvironment(root) {
const directory = join(root, "dyld-environment");
await mkdir(directory);
const source = {
main: join(directory, "main.c"),
library: join(directory, "child.c"),
};
await writeFile(
source.main,
"int value(void); int main(void) { return value() == 7 ? 0 : 1; }",
);
await writeFile(source.library, "int value(void) { return 7; }");
// A per-workspace name avoids collisions with real files in filesystem root.
const name = `rea-${basename(root)}-child`;
const libraryName = `@loader_path/${name}.dylib`;
const frameworkName = `@loader_path/${name}.framework/${name}`;
for (const [index, { image, ...sample }] of SEARCH_CASES.entries()) {
await verifyCase(join(directory, String(index)), source, {
...sample,
installName: image === "framework" ? frameworkName : libraryName,
});
}
return {
settings: SEARCH_CASES.length,
dependency_states: ["present", "missing"],
cli: true,
stdio_mcp: true,
real_dyld: true,
};
}
+3
View File
@@ -14,6 +14,7 @@ import {
buildMacosBundleFixture,
preflightMacosBundleFixture,
} from "../../fixtures/apple/macos-bundle.mjs";
import { verifyDyldEnvironment } from "./dyld-environment.mjs";
import { verifyDylibResolution } from "./macos-dylib-e2e.mjs";
const exec = promisify(execFile);
@@ -152,6 +153,7 @@ try {
const { stdout: attached } = await exec("/usr/bin/hdiutil", ["info"]);
assert.ok(!attached.includes(dmg), "DMG remained attached after inventory");
const dylibs = await verifyDylibResolution(app);
const dyldEnvironment = await verifyDyldEnvironment(root);
process.stdout.write(
`${JSON.stringify({
@@ -163,6 +165,7 @@ try {
bundles: EXPECTED_BUNDLES.length,
dmg_detached: true,
dylib_resolution: dylibs,
dyld_environment: dyldEnvironment,
})}\n`,
);
} finally {
+12 -11
View File
@@ -17,7 +17,7 @@ export const embeddedDyldOverrides = (
if (delimiter <= 0) continue;
const variable = setting.slice(0, delimiter);
const value = setting.slice(delimiter + 1);
const effect = overrideEffect(variable);
const effect = overrideEffect(variable, value);
if (effect === undefined) continue;
// ROOT_PATH is accepted only for simulator processes. Missing or unknown
// platform metadata cannot establish that dyld ignores it.
@@ -28,13 +28,6 @@ export const embeddedDyldOverrides = (
!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;
@@ -42,8 +35,11 @@ export const embeddedDyldOverrides = (
const overrideEffect = (
variable: string,
value: string,
): Pick<DyldSearchOverride, "phase" | "scope"> | undefined => {
switch (variable) {
// Empty directory components still produce /leaf candidates in dyld.
// In particular, an empty fallback value is not a no-op.
case "DYLD_LIBRARY_PATH":
return { phase: "override", scope: "library" };
case "DYLD_FRAMEWORK_PATH":
@@ -56,12 +52,17 @@ const overrideEffect = (
// 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" };
// Versioned paths scan directories; suffixes create path variants.
// Empty directories cannot be scanned and empty suffixes repeat a path.
return /[^:]/u.test(value)
? { phase: "override", scope: "all" }
: undefined;
case "DYLD_INSERT_LIBRARIES":
return value === "" ? undefined : { phase: "override", scope: "all" };
case "DYLD_ROOT_PATH":
case "DYLD_OVERLAY_PATH":
return { phase: "override", scope: "prefix" };
return value === "" ? undefined : { phase: "override", scope: "prefix" };
default:
return undefined;
}
+127
View File
@@ -1105,3 +1105,130 @@ it("keeps inserted-library uncertainty scoped to the executable's process", asyn
expect(limitation).toContain(MAIN);
expect(limitation).not.toContain(library);
});
it.each([
[
"DYLD_LIBRARY_PATH=",
"@loader_path/child.dylib",
"conditional",
"undetermined",
],
[
"DYLD_LIBRARY_PATH=: ",
"@loader_path/child.dylib",
"conditional",
"undetermined",
],
[
"DYLD_FRAMEWORK_PATH=",
"@loader_path/Foo.framework/Foo",
"conditional",
"undetermined",
],
[
"DYLD_FALLBACK_LIBRARY_PATH=",
"@loader_path/child.dylib",
"resolved",
"undetermined",
],
[
"DYLD_FALLBACK_LIBRARY_PATH=:",
"@loader_path/child.dylib",
"resolved",
"undetermined",
],
[
"DYLD_FALLBACK_FRAMEWORK_PATH=",
"@loader_path/Foo.framework/Foo",
"resolved",
"undetermined",
],
[
"DYLD_VERSIONED_LIBRARY_PATH=",
"@loader_path/child.dylib",
"resolved",
"unresolved",
],
[
"DYLD_VERSIONED_LIBRARY_PATH=:",
"@loader_path/child.dylib",
"resolved",
"unresolved",
],
[
"DYLD_VERSIONED_FRAMEWORK_PATH=",
"@loader_path/Foo.framework/Foo",
"resolved",
"unresolved",
],
[
"DYLD_VERSIONED_FRAMEWORK_PATH=::",
"@loader_path/Foo.framework/Foo",
"resolved",
"unresolved",
],
[
"DYLD_VERSIONED_LIBRARY_PATH= ",
"@loader_path/child.dylib",
"conditional",
"undetermined",
],
["DYLD_IMAGE_SUFFIX=:", "@loader_path/child.dylib", "resolved", "unresolved"],
[
"DYLD_IMAGE_SUFFIX=::",
"@loader_path/Foo.framework/Foo",
"resolved",
"unresolved",
],
[
"DYLD_IMAGE_SUFFIX=:_debug:",
"@loader_path/child.dylib",
"conditional",
"undetermined",
],
[
"DYLD_INSERT_LIBRARIES=",
"@loader_path/child.dylib",
"resolved",
"unresolved",
],
[
"DYLD_INSERT_LIBRARIES=:",
"@loader_path/child.dylib",
"conditional",
"undetermined",
],
] as const)(
"interprets the empty components of %s according to their consumer for %s",
async (setting, installName, foundStatus, missingStatus) => {
const child = installName.replace("@loader_path", "Contents/MacOS");
for (const [exists, status] of [
[true, foundStatus],
[false, missingStatus],
] as const) {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dyld_environment: [setting],
dependencies: [dependency(installName)],
}),
...(exists ? { [child]: parsed(slice()) } : {}),
}),
{ roots: [MAIN] },
);
expect(trace.edges[0]?.resolution.status).toBe(status);
expect(trace.coverage.status).toBe(
status === "conditional" || status === "undetermined"
? "partial"
: "complete",
);
expect(
trace.images.find(({ path }) => path === MAIN)?.slices[0]
?.dyld_environment,
).toEqual([setting]);
expect(
trace.findings.some(({ kind }) => kind === "required-load-unresolved"),
).toBe(status === "unresolved");
}
},
);
@@ -366,3 +366,46 @@ cliTest(
).toContain("entries in this non-executable root are observations");
},
);
cliTest(
"distinguishes empty search directories from empty versioned or suffix lists",
async ({ cli }) => {
for (const [setting, status] of [
["DYLD_FALLBACK_LIBRARY_PATH=", "undetermined"],
["DYLD_VERSIONED_LIBRARY_PATH=", "unresolved"],
["DYLD_IMAGE_SUFFIX=:", "unresolved"],
] as const) {
const root = await createTestTempDirectory("rea-dyld-empty-cli-");
const path = join(root, "program");
await writeFile(
path,
machoImage({
commands: [
buildVersionCommand(1),
dyldEnvironmentCommand(setting),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/missing.dylib"),
],
}),
);
const result = await cli.run({
arguments: ["trace-dylib-resolution", path, "--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(status);
expect(trace.coverage.status).toBe(
status === "undetermined" ? "partial" : "complete",
);
expect(
trace.images.find(({ path }) => path === "program")?.slices[0]
?.dyld_environment,
).toEqual([setting]);
expect(
trace.findings.some(({ kind }) => kind === "required-load-unresolved"),
).toBe(status === "unresolved");
}
},
);
@@ -323,3 +323,55 @@ it("ignores process settings on an explicitly selected library root through MCP"
).toContain("entries in this non-executable root are observations");
});
});
it.each([
["DYLD_FALLBACK_LIBRARY_PATH=", "undetermined"],
["DYLD_VERSIONED_LIBRARY_PATH=", "unresolved"],
["DYLD_IMAGE_SUFFIX=:", "unresolved"],
] as const)(
"retains the missing-dependency semantics of %s through MCP",
async (setting, status) => {
const root = await createTestTempDirectory("rea-dyld-empty-mcp-");
const path = join(root, "program");
await writeFile(
path,
machoImage({
commands: [
buildVersionCommand(1),
dyldEnvironmentCommand(setting),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/missing.dylib"),
],
}),
);
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: {},
});
expect(result.isError, JSON.stringify(result.structuredContent)).not.toBe(
true,
);
const trace = dylibResolutionResultSchema.parse(
structuredResult(result.structuredContent),
);
expect(trace.edges[0]?.resolution.status).toBe(status);
expect(trace.coverage.status).toBe(
status === "undetermined" ? "partial" : "complete",
);
expect(
trace.images.find(({ path }) => path === "program")?.slices[0]
?.dyld_environment,
).toEqual([setting]);
expect(
trace.findings.some(({ kind }) => kind === "required-load-unresolved"),
).toBe(status === "unresolved");
});
},
);