From 90062c092228dea9d33fadc0d7e1b968105b8b2c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:04:29 +0800 Subject: [PATCH] fix(apple): trace legacy macOS dylibs with implicit platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply dyld’s implicit macOS compatibility rule only for a root executable with observed macOS platform and a supported candidate CPU type. Retain the absent platform fields as raw facts and expose the derivation on the candidate. Traverse the selected dylib and report missing required children; preserve unknown platform coverage and the later known fallback graph in other contexts. Validation: 144 source/reader tests, 14 compiled CLI tests, 18 schema tests, full check:ci and docs:check passed. Four source-built app variants passed CLI and SDK-validated stdio MCP checks with unchanged fixture digests. No fixture executable or Apple dyld comparison was run. Independent review resolved a library-root authority gap before integration. Sixteen cold/warm task-cost observations are retained locally with sampling limits; the catalog adds 66 bytes. Actual model tokens and maximum-sized inputs were not measured; no speedup is claimed. Closes #5696 --- docs/apple-application-analysis.md | 29 ++- .../apple/MachoLoadCommandReader.test.ts | 106 ++++++++++ src/domain/apple/dylibResolution.test.ts | 118 +++++++++-- src/domain/apple/dylibResolution.ts | 195 ++++++++++++------ 4 files changed, 358 insertions(+), 90 deletions(-) diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index 926aae0d41..1ea57eb270 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -216,17 +216,26 @@ parser coverage, such as big-endian images), `architecture-missing`, `outside-target`, `escapes-target`, or `undetermined`. Slices follow dyld's graded architectures, so an `x86_64h` process also loads `x86_64`. The edge's `resolution` is one of: -| Status | Meaning | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. | -| `conditional` | An image inside the root resolves, but an earlier candidate outside the root (for example `/usr/lib/swift`) would win if it exists on the running system. | -| `unresolved` | Every candidate is definitively absent or unusable inside the root. | -| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. | +| Status | Meaning | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. | +| `conditional` | An in-root image may load, but an earlier outside candidate, unmodeled environment input, or unknown platform compatibility can change which image dyld loads. | +| `unresolved` | Every candidate is definitively absent or unusable inside the root. | +| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. | -A `conditional` image is still traced, because it is the image the analyzed -root supplies. Every edge below it has `loader_conditional: true`, and a -request that reuses it by install name is `conditional` too. Findings on those -edges say that they apply only if the conditional image loads. +A `conditional` image is still traced, because it is an in-root candidate +that may load. Every edge below it has `loader_conditional: true`, and a +request that reuses it by install name is `conditional` too. An in-root dylib +with no platform load command remains unknown unless the root is an executable +with observed macOS platform and the candidate’s CPU type is x86_64, i386, or arm64. In that macOS context, +[Apple's dyld](https://github.com/apple-oss-distributions/dyld/blob/fd8d0c4d52320ebf64db34f3cb280310d905c5ae/mach_o/UnsafeHeader.cpp#L1137-L1145) supplies an implicit macOS platform to legacy dylibs on those CPU +types. The candidate records `platform_basis: "macos-dyld-implicit"`; the +reported slice fields `platform: null` and `platforms: []` remain the parsed +facts. For other process contexts the platform remains unknown, coverage is +partial, and the candidate is not treated as incompatible. If a later known +candidate resolves, it remains the traced image with conditional resolution; +the trace does not claim the unknown candidate's dependency graph. Findings on +conditional edges say that they apply only if the conditional image loads. Absolute install names that name a path beneath the analyzed root are checked within that root, with symlinks and `..` resolved by the same bounded tree diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts index 94dd4891c1..c2008e599c 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.test.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts @@ -142,6 +142,112 @@ it("uses legacy simulator and device platform facts when resolving dylibs", asyn ); }); +it.each([false, true] as const)( + "applies macOS dyld's implicit platform to a platform-less dylib and traces its required child present=%s", + async (childPresent) => { + const root = await createTestTempDirectory("rea-dylib-platform-unknown-"); + const executablePath = "Contents/MacOS/probe"; + const dylibPath = "Contents/Frameworks/libA.dylib"; + const fallbackPath = "Contents/Fallbacks/libA.dylib"; + const childPath = "Contents/Frameworks/libMissing.dylib"; + const fallbackChildPath = "Contents/Fallbacks/libFallbackOnly.dylib"; + const executable = machoImage({ + cpu: CPU.arm64, + commands: [ + buildVersionCommand(1), + rpathCommand("@executable_path/../Frameworks"), + rpathCommand("@executable_path/../Fallbacks"), + dylibCommand(LC.LOAD_DYLIB, "@rpath/libA.dylib"), + ], + }); + const dylib = machoImage({ + cpu: CPU.arm64, + fileType: FILE_TYPE.dylib, + commands: [ + rpathCommand("@loader_path"), + dylibCommand(LC.LOAD_DYLIB, "@rpath/libMissing.dylib"), + ], + }); + const child = machoImage({ + cpu: CPU.arm64, + fileType: FILE_TYPE.dylib, + commands: [buildVersionCommand(1)], + }); + const fallback = machoImage({ + cpu: CPU.arm64, + fileType: FILE_TYPE.dylib, + commands: [ + rpathCommand("@loader_path"), + dylibCommand(LC.LOAD_DYLIB, "@rpath/libFallbackOnly.dylib"), + ], + }); + const fallbackChild = machoImage({ + cpu: CPU.arm64, + fileType: FILE_TYPE.dylib, + commands: [buildVersionCommand(1)], + }); + await mkdir(join(root, "Contents/MacOS"), { recursive: true }); + await mkdir(join(root, "Contents/Frameworks"), { recursive: true }); + await mkdir(join(root, "Contents/Fallbacks"), { recursive: true }); + await writeFile(join(root, executablePath), executable); + await writeFile(join(root, dylibPath), dylib); + await writeFile(join(root, fallbackPath), fallback); + await writeFile(join(root, fallbackChildPath), fallbackChild); + if (childPresent) await writeFile(join(root, childPath), child); + + const trace = await traceDylibResolution({ + rootPath: root, + targetPath: join(root, executablePath), + targetSha256: createHash("sha256").update(executable).digest("hex"), + enumerateRoots: false, + parameters: { roots: [executablePath] }, + }); + + expect( + trace.images.find(({ path }) => path === executablePath)?.slices[0], + ).toMatchObject({ platform: 1, platforms: [1] }); + expect( + trace.images.find(({ path }) => path === dylibPath)?.slices[0], + ).toMatchObject({ platform: null, platforms: [] }); + expect(trace.edges[0]).toMatchObject({ + loader: executablePath, + install_name: "@rpath/libA.dylib", + candidates: [ + { + outcome: "resolved", + resolved_path: dylibPath, + platform_basis: "macos-dyld-implicit", + }, + ], + resolution: { status: "resolved", image: dylibPath }, + }); + expect(trace.edges[1]).toMatchObject({ + loader: dylibPath, + install_name: "@rpath/libMissing.dylib", + resolution: childPresent + ? { status: "resolved", image: childPath } + : { status: "unresolved", image: null }, + loader_conditional: false, + }); + expect(trace.images.map(({ path }) => path)).toEqual( + childPresent + ? [executablePath, childPath, dylibPath].sort() + : [executablePath, dylibPath].sort(), + ); + expect(trace.images.map(({ path }) => path)).not.toContain(fallbackPath); + expect(trace.images.map(({ path }) => path)).not.toContain( + fallbackChildPath, + ); + expect(trace.coverage.status).toBe("complete"); + expect(trace.limitations).toContainEqual( + expect.stringContaining(dylibPath), + ); + expect(trace.findings.map(({ kind }) => kind)).toEqual( + childPresent ? [] : ["required-load-unresolved"], + ); + }, +); + describe("absolute dyld candidate paths at the filesystem reader boundary", () => { it.each(["install-name", "rpath"] as const)( "resolves an absolute in-root %s while preserving its source path", diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts index dd4c18df28..577ce5d9a5 100644 --- a/src/domain/apple/dylibResolution.test.ts +++ b/src/domain/apple/dylibResolution.test.ts @@ -441,26 +441,6 @@ describe("dyld resolution outcomes: checking candidate format and compatibility" }); }); - it("preserves unknown platform compatibility before a later known candidate", async () => { - const trace = await traceDylibLoading( - memoryView({ - [MAIN]: executable({ - rpaths: ["@executable_path/a", "@executable_path/b"], - dependencies: [dependency("@rpath/lib.dylib")], - }), - "Contents/MacOS/a/lib.dylib": parsed(slice({ platform: null })), - "Contents/MacOS/b/lib.dylib": parsed(slice()), - }), - { roots: [MAIN] }, - ); - const edge = edgeFor(trace, MAIN, "@rpath/lib.dylib"); - expect(edge.candidates.map(({ outcome }) => outcome)).toEqual([ - "undetermined", - "resolved", - ]); - expect(edge.resolution.status).toBe("conditional"); - }); - it("applies dyld Catalyst and zippered platform compatibility", async () => { const catalystProcess = await traceDylibLoading( memoryView({ @@ -506,6 +486,104 @@ describe("dyld resolution outcomes: checking candidate format and compatibility" }); }); +describe("platform-less dylib compatibility", () => { + it("keeps an unknown candidate unresolved and traces the later known fallback", async () => { + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + platform: 6, + platforms: [6], + rpaths: ["@executable_path/a", "@executable_path/b"], + dependencies: [dependency("@rpath/lib.dylib")], + }), + "Contents/MacOS/a/lib.dylib": parsed(slice({ platform: null })), + "Contents/MacOS/b/lib.dylib": parsed( + slice({ + platform: 6, + platforms: [6], + dependencies: [dependency("@loader_path/libChild.dylib")], + }), + ), + "Contents/MacOS/b/libChild.dylib": parsed( + slice({ platform: 6, platforms: [6] }), + ), + }), + { roots: [MAIN] }, + ); + const edge = edgeFor(trace, MAIN, "@rpath/lib.dylib"); + expect(edge.candidates.map(({ outcome }) => outcome)).toEqual([ + "undetermined", + "resolved", + ]); + expect(edge.resolution).toEqual({ + status: "conditional", + image: "Contents/MacOS/b/lib.dylib", + }); + expect(edge.candidates[0]).not.toHaveProperty("platform_basis"); + expect(trace.edges.map(({ loader }) => loader)).toContain( + "Contents/MacOS/b/lib.dylib", + ); + expect(trace.images.map(({ path }) => path)).toContain( + "Contents/MacOS/b/libChild.dylib", + ); + expect(trace.coverage.status).toBe("partial"); + expect(trace.limitations).toContainEqual( + expect.stringContaining("Contents/MacOS/a/lib.dylib"), + ); + }); + + it("does not infer a platform-less image's platform when process platform is unknown", async () => { + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + platform: null, + platforms: [], + rpaths: ["@executable_path/a"], + dependencies: [dependency("@rpath/lib.dylib")], + }), + "Contents/MacOS/a/lib.dylib": parsed(slice({ platform: null })), + }), + { roots: [MAIN] }, + ); + const edge = edgeFor(trace, MAIN, "@rpath/lib.dylib"); + expect(edge.candidates[0]).toMatchObject({ + outcome: "undetermined", + resolved_path: "Contents/MacOS/a/lib.dylib", + }); + expect(edge.candidates[0]).not.toHaveProperty("platform_basis"); + expect(edge.resolution).toEqual({ status: "undetermined", image: null }); + expect(trace.coverage.status).toBe("partial"); + }); + + it("does not treat a selected dylib root as an executable process context", async () => { + const library = "Contents/Frameworks/libRoot.dylib"; + const child = "Contents/Frameworks/libChild.dylib"; + const trace = await traceDylibLoading( + memoryView({ + [library]: parsed( + slice({ + file_type: "dylib", + file_type_code: 6, + platform: 1, + platforms: [1], + dependencies: [dependency("@loader_path/libChild.dylib")], + }), + ), + [child]: parsed(slice({ platform: null })), + }), + { roots: [library] }, + ); + const edge = edgeFor(trace, library, "@loader_path/libChild.dylib"); + expect(edge.candidates[0]).toMatchObject({ + outcome: "undetermined", + resolved_path: child, + }); + expect(edge.candidates[0]).not.toHaveProperty("platform_basis"); + expect(edge.resolution).toEqual({ status: "undetermined", image: null }); + expect(trace.coverage.status).toBe("partial"); + }); +}); + describe("dyld load order", () => { it("loads each image once per process and reuses matching install names", async () => { const a = "Contents/Frameworks/libA.dylib"; diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts index 1adfdc79fc..3fdf5a518c 100644 --- a/src/domain/apple/dylibResolution.ts +++ b/src/domain/apple/dylibResolution.ts @@ -51,6 +51,13 @@ const dependencyCommandSchema = z.enum([ "LC_LOAD_UPWARD_DYLIB", ]); +const IMPLICIT_MACOS_PLATFORM = 1; +const MACOS_DYLD_IMPLICIT_PLATFORM_CPU_TYPES = new Set([ + 7, // CPU_TYPE_I386 + 0x01000007, // CPU_TYPE_X86_64 + 0x0100000c, // CPU_TYPE_ARM64, including arm64 subtypes +]); + const dependencySchema = z.strictObject({ command: dependencyCommandSchema, encoding: z.enum([ @@ -145,6 +152,7 @@ const candidateSchema = z.strictObject({ "undetermined", ]), resolved_path: z.string().nullable(), + platform_basis: z.enum(["macos-dyld-implicit"]).optional(), }); const edgeSchema = z.strictObject({ @@ -227,7 +235,7 @@ interface LoadedImage { readonly slice: MachoSlice; /** Images from the process root to this image; the rpath stack, outermost first. */ readonly chain: readonly string[]; - /** Some edge on the chain was conditional: an unknown earlier candidate may load instead. */ + /** Some edge on the chain has unresolved candidate or platform facts. */ readonly conditional: boolean; } @@ -240,6 +248,8 @@ interface ProcessContext { >; readonly searchOverrides: readonly DyldSearchOverride[]; readonly environmentRoots: Set; + readonly unknownPlatformCandidates: Set; + readonly implicitMacOSPlatformCandidates: Set; readonly platforms: readonly number[]; readonly executable: string | null; readonly loaded: Map; @@ -346,47 +356,75 @@ const evaluateCandidate = async ( // 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, - slice.platforms, - slice.file_type_code, - context.processIdentity.architecture, - ); + const platformResult = platformLoadability(context, slice); outcome = - platformResult === "compatible" + platformResult.outcome === "compatible" ? "resolved" - : platformResult === "incompatible" + : platformResult.outcome === "incompatible" ? "platform-mismatch" : "undetermined"; + if (outcome === "undetermined") + context.unknownPlatformCandidates.add(lookup.path); + if (platformResult.basis !== undefined) { + context.implicitMacOSPlatformCandidates.add(lookup.path); + return { + ...base, + outcome, + resolved_path: lookup.path, + platform_basis: platformResult.basis, + }; + } } } return { ...base, outcome, resolved_path: lookup.path }; }; type PlatformLoadability = "compatible" | "incompatible" | "unknown"; +type PlatformLoadabilityResult = { + readonly outcome: PlatformLoadability; + readonly basis?: Candidate["platform_basis"]; +}; /** Apply dyld's documented cross-platform load cases and retain uncertain host cases. */ const platformLoadability = ( - processPlatforms: readonly number[], - imagePlatforms: readonly number[], - fileType: number, - architecture: string, -): PlatformLoadability => { - if (processPlatforms.length === 0 || imagePlatforms.length === 0) - return "unknown"; + context: Pick, + image: MachoSlice, +): PlatformLoadabilityResult => { + const { platforms: processPlatforms } = context; + const { file_type_code: fileType, platforms: imagePlatforms } = image; + let effectiveImagePlatforms = imagePlatforms; + let basis: Candidate["platform_basis"]; + if (imagePlatforms.length === 0) { + // macOS dyld supplies an implicit macOS platform to old dylibs on these + // CPU types. Keep the parsed platform facts untouched; this is only the + // compatibility interpretation used for an explicitly macOS process. + if ( + context.executable !== null && + processPlatforms.length === 1 && + processPlatforms[0] === IMPLICIT_MACOS_PLATFORM && + MACOS_DYLD_IMPLICIT_PLATFORM_CPU_TYPES.has(image.cpu_type) + ) { + effectiveImagePlatforms = [IMPLICIT_MACOS_PLATFORM]; + basis = "macos-dyld-implicit"; + } else return { outcome: "unknown" }; + } + if (processPlatforms.length === 0) return { outcome: "unknown" }; const outcomes = processPlatforms.map((processPlatform) => imageLoadabilityForPlatform( processPlatform, - imagePlatforms, + effectiveImagePlatforms, fileType, - architecture, + image.architecture, ), ); if (outcomes.every((outcome) => outcome === "compatible")) - return "compatible"; + return { outcome: "compatible", ...(basis === undefined ? {} : { basis }) }; if (outcomes.every((outcome) => outcome === "incompatible")) - return "incompatible"; - return "unknown"; + return { + outcome: "incompatible", + ...(basis === undefined ? {} : { basis }), + }; + return { outcome: "unknown", ...(basis === undefined ? {} : { basis }) }; }; const imageLoadabilityForPlatform = ( @@ -572,7 +610,7 @@ const traceProcess = async ( context.loaded.set(image, { slice, chain: [...current.chain, image], - // dyld may load an unknown earlier candidate instead of this fallback. + // A modeled earlier search uncertainty can make this image conditional. conditional: current.conditional || edge.resolution.status === "conditional", }); @@ -589,6 +627,69 @@ const traceProcess = async ( return edges; }; +const projectTraceResult = (input: { + readonly images: ReadonlyMap; + readonly roots: DylibTrace["roots"]; + readonly rootSlices: readonly TracedRootSlice[]; + readonly edges: readonly Edge[]; + readonly withoutArchitecture: readonly string[]; + readonly environmentRoots: ReadonlySet; + readonly unknownPlatformCandidates: ReadonlySet; + readonly implicitMacOSPlatformCandidates: ReadonlySet; +}): DylibTrace => { + const reported = [...input.images.entries()] + .filter(([, facts]) => facts.status !== "not-mach-o") + .sort(([left], [right]) => compare(left, right)); + const unparsed = reported + .filter(([, facts]) => facts.status !== "parsed") + .map(([path]) => path); + return { + roots: input.roots, + images: reported.map(([path, facts]) => ({ + path, + parse_status: + facts.status === "not-mach-o" ? "unsupported" : facts.status, + reason: + facts.status === "parsed" || facts.status === "not-mach-o" + ? null + : facts.reason, + slices: + facts.status === "parsed" ? facts.slices.map(withoutDependencies) : [], + })), + edges: [...input.edges], + findings: deriveFindings(input.edges, input.rootSlices), + coverage: { + status: + unparsed.length === 0 && + input.withoutArchitecture.length === 0 && + input.environmentRoots.size === 0 && + input.unknownPlatformCandidates.size === 0 + ? "complete" + : "partial", + unparsed_images: unparsed, + roots_without_architecture: [...input.withoutArchitecture], + }, + limitations: [ + ...DYLIB_RESOLUTION_LIMITATIONS, + ...(input.environmentRoots.size === 0 + ? [] + : [ + `Roots ${[...input.environmentRoots].join(", ")} request applicable image-selection inputs through LC_DYLD_ENVIRONMENT that are not modeled; affected dependencies or inserted images remain uncertain.`, + ]), + ...(input.unknownPlatformCandidates.size === 0 + ? [] + : [ + `In-root dylib candidates ${[...input.unknownPlatformCandidates].sort(compare).join(", ")} have unknown platform compatibility and are not treated as incompatible. Their dependent graphs may be incomplete.`, + ]), + ...(input.implicitMacOSPlatformCandidates.size === 0 + ? [] + : [ + `In-root dylib candidates ${[...input.implicitMacOSPlatformCandidates].sort(compare).join(", ")} have no observed platform load command; macOS compatibility was derived from dyld's implicit-platform rule for an executable root with explicit macOS platform and a supported CPU type. Reported platform facts remain unchanged.`, + ]), + ], + }; +}; + /** Trace dyld load paths from each process root through the analyzed root. */ export const traceDylibLoading = async ( view: DylibTreeView, @@ -608,6 +709,8 @@ export const traceDylibLoading = async ( const edges: Edge[] = []; const withoutArchitecture: string[] = []; const environmentRoots = new Set(); + const unknownPlatformCandidates = new Set(); + const implicitMacOSPlatformCandidates = new Set(); for (const root of request.roots) { const facts = await view.image(root); images.set(root, facts); @@ -647,6 +750,8 @@ export const traceDylibLoading = async ( }, searchOverrides, environmentRoots, + unknownPlatformCandidates, + implicitMacOSPlatformCandidates, platforms: slice.platforms, executable, loaded: new Map(), @@ -659,46 +764,16 @@ export const traceDylibLoading = async ( ); } } - const reported = [...images.entries()] - .filter(([, facts]) => facts.status !== "not-mach-o") - .sort(([left], [right]) => compare(left, right)); - const unparsed = reported - .filter(([, facts]) => facts.status !== "parsed") - .map(([path]) => path); - return { + return projectTraceResult({ + images, roots, - images: reported.map(([path, facts]) => ({ - path, - parse_status: - facts.status === "not-mach-o" ? "unsupported" : facts.status, - reason: - facts.status === "parsed" || facts.status === "not-mach-o" - ? null - : facts.reason, - slices: - facts.status === "parsed" ? facts.slices.map(withoutDependencies) : [], - })), + rootSlices, edges, - findings: deriveFindings(edges, rootSlices), - coverage: { - status: - unparsed.length === 0 && - withoutArchitecture.length === 0 && - environmentRoots.size === 0 - ? "complete" - : "partial", - unparsed_images: unparsed, - roots_without_architecture: withoutArchitecture, - }, - 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.`, - ]), - ], - }; + withoutArchitecture, + environmentRoots, + unknownPlatformCandidates, + implicitMacOSPlatformCandidates, + }); }; const withoutDependencies = (