diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index 07ff1d293..bf606f34d 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -96,9 +96,11 @@ The components also list: Symlinks are reported by path in `symlinks`. Their targets are not inventoried, so a versioned framework is described through its real `Versions//` -directories rather than `Versions/Current`. When a framework has zero or several -`Versions/*/Resources/Info.plist` files, its `info_plist_path` is `null` and a -limitation explains why. +directories rather than `Versions/Current`. A framework's `info_plist_path` is +`null`, with a limitation explaining why, when it has more than one real +`Versions//` directory (even if only one of them holds a plist, +because `Versions/Current` decides which one applies) or when its single +version directory has no `Resources/Info.plist`. AppleDouble sidecar files (`._*` and `__MACOSX/`) are inventory facts, but they describe neighbouring files. They are excluded from roots, bundle roles, and the diff --git a/src/domain/appleApplication.test.ts b/src/domain/appleApplication.test.ts index 8e61d8e2d..47c45a746 100644 --- a/src/domain/appleApplication.test.ts +++ b/src/domain/appleApplication.test.ts @@ -309,6 +309,29 @@ describe("Apple bundle layouts and containers", () => { expect( result.bundles.find(({ path }) => path === framework)?.info_plist_path, ).toBeNull(); + // Files beside the only real version are not versions. + const withFiles = project( + inventoryEvidence("zip", "Fixture.zip", [ + macho("Fixture.app/Contents/MacOS/Fixture"), + { path: `${framework}/Versions/A/Resources/Info.plist` }, + { path: `${framework}/Versions/.DS_Store` }, + { path: `${framework}/Versions/Current`, kind: "symlink" }, + ]), + ); + expect( + withFiles.bundles.find(({ path }) => path === framework)?.info_plist_path, + ).toBe(`${framework}/Versions/A/Resources/Info.plist`); + // Only A has a plist, but Versions/Current may select B. + const onlyA = project( + inventoryEvidence("zip", "Fixture.zip", [ + macho("Fixture.app/Contents/MacOS/Fixture"), + { path: `${framework}/Versions/A/Resources/Info.plist` }, + macho(`${framework}/Versions/B/Core`), + ]), + ); + expect( + onlyA.bundles.find(({ path }) => path === framework)?.info_plist_path, + ).toBeNull(); expect(result.limitations).toContainEqual( expect.stringContaining("Versions/Current selects is unknown"), ); @@ -438,3 +461,37 @@ describe("macOS application scope", () => { }); }); }); + +describe("Apple application bridge candidates", () => { + it("pairs scripts and native code only within one application root", () => { + const result = project( + inventoryEvidence("zip", "Suite.zip", [ + macho("First.app/Contents/MacOS/First"), + { + path: "First.app/Contents/Resources/first.js", + format: "javascript-bundle", + }, + macho("Second.app/Contents/MacOS/Second"), + { + path: "Second.app/Contents/Resources/second.js", + format: "javascript-bundle", + }, + ]), + ); + expect(result.application_roots).toEqual(["First.app", "Second.app"]); + expect( + result.bridge_candidates.map( + ({ source_path: source, native_path: target }) => [source, target], + ), + ).toEqual([ + [ + "First.app/Contents/Resources/first.js", + "First.app/Contents/MacOS/First", + ], + [ + "Second.app/Contents/Resources/second.js", + "Second.app/Contents/MacOS/Second", + ], + ]); + }); +}); diff --git a/src/domain/appleApplication.ts b/src/domain/appleApplication.ts index 4650fce72..42bc970b6 100644 --- a/src/domain/appleApplication.ts +++ b/src/domain/appleApplication.ts @@ -149,6 +149,7 @@ export const projectAppleApplication = ( ...classified.executables, ...classified.native_libraries, ]), + roots, ); const uninventoriedImage = rootFormat === "dmg" && inventory.occurrences.length === 1; @@ -305,18 +306,27 @@ const identifyRuntimeFamilies = (all: readonly Component[]) => { return [...families].sort(compare); }; +/** Pair scripts and native code only within the same application root. */ const identifyBridgeCandidates = ( scripts: readonly Component[], native: readonly Component[], -) => ({ - candidates: scripts.flatMap((script) => - native.map((item) => ({ - source_path: script.path, - native_path: item.path, - basis: bridgeBasis(item.path), - })), - ), -}); + roots: readonly string[], +) => { + const owner = (path: string): string | null => + roots.find((root) => isWithin(path, root)) ?? null; + return { + candidates: scripts.flatMap((script) => { + const root = owner(script.path); + return native + .filter(({ path }) => owner(path) === root) + .map((item) => ({ + source_path: script.path, + native_path: item.path, + basis: bridgeBasis(item.path), + })); + }), + }; +}; const bridgeBasis = ( path: string, @@ -360,7 +370,7 @@ const projectionLimitations = (facts: { layout === "versioned-framework" && plist === null, ) ? [ - "A versioned framework has zero or several Versions/*/Resources/Info.plist files; the one Versions/Current selects is unknown because symlink targets are not inventoried.", + "A versioned framework has several version directories or no Versions/*/Resources/Info.plist; the Info.plist that Versions/Current selects is unknown because symlink targets are not inventoried.", ] : []), ...(facts.symlinks diff --git a/src/domain/appleBundleAnatomy.ts b/src/domain/appleBundleAnatomy.ts index a70424908..f98def7c6 100644 --- a/src/domain/appleBundleAnatomy.ts +++ b/src/domain/appleBundleAnatomy.ts @@ -141,14 +141,18 @@ export const detectBundles = ( interface Tree { readonly children: ReadonlyMap>; readonly files: ReadonlyMap; + /** Paths observed as directories, or implied by a deeper path. */ + readonly directories: ReadonlySet; } const indexTree = (entries: readonly Entry[]): Tree => { const children = new Map>(); const files = new Map(); + const directories = new Set(); for (const { path, kind, component } of entries) { if (isSidecar(path)) continue; if (kind === "file" && component !== undefined) files.set(path, component); + if (kind === "directory") directories.add(path); const segments = path.split("/"); let parent = ""; for (const segment of segments) { @@ -156,9 +160,10 @@ const indexTree = (entries: readonly Entry[]): Tree => { names.add(segment); children.set(parent, names); parent = joinPath(parent, segment); + if (parent !== path) directories.add(parent); } } - return { children, files }; + return { children, files, directories }; }; const joinPath = (directory: string, name: string): string => @@ -223,9 +228,12 @@ const contentDirectories = ( if (layout === "macos-deep") return [joinPath(directory, "Contents")]; if (layout === "shallow") return [directory]; const versions = joinPath(directory, "Versions"); + // Only real version directories count: Current is a symlink, and files + // such as .DS_Store are not versions. return [...(tree.children.get(versions) ?? [])] .filter((version) => version !== "Current") .map((version) => joinPath(versions, version)) + .filter((version) => tree.directories.has(version)) .sort(compare); }; @@ -234,7 +242,11 @@ const infoPlistPath = ( layout: Bundle["layout"], tree: Tree, ): string | null => { - const candidates = contentDirectories(bundle, layout, tree) + const directories = contentDirectories(bundle, layout, tree); + // With several real versions, Versions/Current (a symlink outside the + // inventory) decides which plist applies, so the plist stays unknown. + if (directories.length !== 1) return null; + const candidates = directories .map((directory) => joinPath( directory,