From 3b67b362aa2cd79eac5af57d0965e81a7d0b8c2b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:34:05 +0800 Subject: [PATCH] fix(apple): explain present rejected dylib candidates Derive unresolved findings from stored candidate paths and outcomes while preserving weak, lazy and conditional load consequences. Document not-loadable and platform-mismatch outcomes and later-path resolution. Validation: 126 resolver/CLI/MCP tests, check:ci, docs:check, and seven compiled CLI plus SDK-validated stdio MCP fixture cases. Source-built and mutated inert Mach-O inputs retained identical hashes; no target execution or dyld runtime comparison. Fixes #5747 Fixes #5694 --- docs/apple-application-analysis.md | 12 ++++- src/domain/apple/dylibResolution.test.ts | 3 ++ src/domain/apple/dylibResolutionFindings.ts | 51 ++++++++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index 1ea57eb270..3f718d4cc7 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -213,8 +213,16 @@ Resolution follows dyld: Each edge lists every candidate path tried, with its outcome: `resolved`, `absent`, `not-mach-o`, `malformed`, `unsupported` (recognized Mach-O outside 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: +`not-loadable`, `platform-mismatch`, `outside-target`, `escapes-target`, or +`undetermined`. `not-loadable` means a +matching-architecture Mach-O image was found but it is not a dylib and cannot +satisfy a dylib dependency. `platform-mismatch` means a matching-architecture +dylib was found but its observed platform is incompatible with the process. +These outcomes reject that candidate; dyld search may continue and resolve a +later candidate. Candidate outcomes describe individual search paths, while +`resolution.status` describes the final edge. Slices follow dyld's graded +architectures, so an `x86_64h` process also loads `x86_64`. The edge's +`resolution` is one of: | Status | Meaning | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts index 577ce5d9a5..c143738225 100644 --- a/src/domain/apple/dylibResolution.test.ts +++ b/src/domain/apple/dylibResolution.test.ts @@ -398,6 +398,9 @@ describe("dyld resolution outcomes: checking candidate format and compatibility" ({ outcome }) => outcome, ), ).toEqual(["not-mach-o", "malformed", "architecture-missing"]); + expect(trace.findings[0]?.explanation).toContain( + "No usable candidate for @rpath/lib.dylib exists in the analyzed root; found Contents/MacOS/a/lib.dylib (not a Mach-O image), Contents/MacOS/b/lib.dylib (malformed), Contents/MacOS/c/lib.dylib (missing the process architecture)", + ); expect(trace.coverage).toEqual({ status: "partial", unparsed_images: ["Contents/MacOS/b/lib.dylib"], diff --git a/src/domain/apple/dylibResolutionFindings.ts b/src/domain/apple/dylibResolutionFindings.ts index 7783ab91db..c7f37bb935 100644 --- a/src/domain/apple/dylibResolutionFindings.ts +++ b/src/domain/apple/dylibResolutionFindings.ts @@ -70,7 +70,10 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => { basis: "derived", } as const; const lazy = isLazyLoadCommand(edge.command); - const missing = `No candidate for ${dependencyPhrase(edge, lazy)} exists in the analyzed root`; + const missing = missingCandidateExplanation( + edge, + dependencyPhrase(edge, lazy), + ); // Below a conditional fallback, every consequence applies only if it loads. const when = edge.loader_conditional ? `. ${edge.loader} loads only conditionally; if it loads, ` @@ -94,6 +97,52 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => { }; }; +/** Use the resolver's physical-candidate facts without rechecking source paths. */ +const missingCandidateExplanation = ( + edge: Edge, + dependency: string, +): string => { + const present = edge.candidates.flatMap(({ outcome, resolved_path }) => + resolved_path === null ? [] : [{ outcome, path: resolved_path }], + ); + if (present.length === 0) + return `No candidate for ${dependency} exists in the analyzed root`; + const descriptions = present.map( + ({ outcome, path }) => `${path} (${candidateRejection(outcome)})`, + ); + return `No usable candidate for ${dependency} exists in the analyzed root; found ${descriptions.join(", ")}`; +}; + +/** Explain a found candidate from the resolver's outcome, not by reopening it. */ +const candidateRejection = ( + outcome: Edge["candidates"][number]["outcome"], +): string => { + switch (outcome) { + case "not-mach-o": + return "not a Mach-O image"; + case "malformed": + return "malformed"; + case "architecture-missing": + return "missing the process architecture"; + case "not-loadable": + return "not a loadable dylib"; + case "platform-mismatch": + return "incompatible with the process platform"; + case "unsupported": + return "unsupported by the parser"; + case "resolved": + return "loadable"; + case "absent": + return "absent"; + case "outside-target": + return "outside the analyzed root"; + case "escapes-target": + return "escapes the analyzed root"; + case "undetermined": + return "of undetermined loadability"; + } +}; + /** Name the dependency the way the finding's consequence treats it. */ const dependencyPhrase = (edge: Edge, lazy: boolean): string => { if (lazy && edge.weak)