From fe297e56e804130dd49cc0a95fcf2d115c183ae3 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:54:32 +0800 Subject: [PATCH] fix(apple): classify empty dyld settings by consumer semantics (#1110) --- docs/apple-application-analysis.md | 8 + docs/testing.md | 6 + scripts/verify/apple/dyld-environment.mjs | 223 ++++++++++++++++++ scripts/verify/apple/macos-bundle.mjs | 3 + src/domain/apple/dyldEnvironment.ts | 23 +- src/domain/apple/dylibResolution.test.ts | 127 ++++++++++ tests/boundary/cli/dylibResolutionCli.test.ts | 43 ++++ tests/boundary/mcp/dylibResolutionMcp.test.ts | 52 ++++ 8 files changed, 474 insertions(+), 11 deletions(-) create mode 100644 scripts/verify/apple/dyld-environment.mjs diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index 2218252ef2..115becf5a9 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -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`). diff --git a/docs/testing.md b/docs/testing.md index 961100c980..8fc433de5a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/scripts/verify/apple/dyld-environment.mjs b/scripts/verify/apple/dyld-environment.mjs new file mode 100644 index 0000000000..d15f4e9b41 --- /dev/null +++ b/scripts/verify/apple/dyld-environment.mjs @@ -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, + }; +} diff --git a/scripts/verify/apple/macos-bundle.mjs b/scripts/verify/apple/macos-bundle.mjs index 972c8a7c4a..4108f19888 100644 --- a/scripts/verify/apple/macos-bundle.mjs +++ b/scripts/verify/apple/macos-bundle.mjs @@ -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 { diff --git a/src/domain/apple/dyldEnvironment.ts b/src/domain/apple/dyldEnvironment.ts index f0bf6cfad6..a2f109b0ab 100644 --- a/src/domain/apple/dyldEnvironment.ts +++ b/src/domain/apple/dyldEnvironment.ts @@ -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 | 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; } diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts index f79583d885..1b262970ad 100644 --- a/src/domain/apple/dylibResolution.test.ts +++ b/src/domain/apple/dylibResolution.test.ts @@ -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"); + } + }, +); diff --git a/tests/boundary/cli/dylibResolutionCli.test.ts b/tests/boundary/cli/dylibResolutionCli.test.ts index a087c746f1..aad4e9f093 100644 --- a/tests/boundary/cli/dylibResolutionCli.test.ts +++ b/tests/boundary/cli/dylibResolutionCli.test.ts @@ -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"); + } + }, +); diff --git a/tests/boundary/mcp/dylibResolutionMcp.test.ts b/tests/boundary/mcp/dylibResolutionMcp.test.ts index a5d542d78d..06e48e08ff 100644 --- a/tests/boundary/mcp/dylibResolutionMcp.test.ts +++ b/tests/boundary/mcp/dylibResolutionMcp.test.ts @@ -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"); + }); + }, +);