diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index b16c95cec..07ff1d293 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -45,8 +45,17 @@ macOS mounting adapter. When a DMG has only its root identity, the projection is `partial` and says that bundle absence is unknown. Other inventory formats, such as APK or ASAR, are rejected as input errors. -`platforms` lists `macos` for deep `Contents/` application roots and `ios` for -shallow IPA application roots. +An inventoried directory whose name ends in `.app` is its own root even when its +inventory is partial or lacks `Contents/`. For directory, ZIP, and DMG +inventories, components outside every application root are not attributed to +the application. This covers installers, disk-image extras, and AppleDouble +sidecars, which therefore stay out of runtime families and bridge candidates. A +limitation reports how many inventoried entries were left out. IPA projection +still lists every archive component, including `SwiftSupport/`. + +`platforms` lists `macos` for application roots with `Contents/`. It lists `ios` +for shallow application roots that have an `Info.plist`. An application root +with neither, such as an empty inventory, has no platform. ## macOS bundle anatomy diff --git a/src/domain/appleApplication.test.ts b/src/domain/appleApplication.test.ts index bb2490429..8e61d8e2d 100644 --- a/src/domain/appleApplication.test.ts +++ b/src/domain/appleApplication.test.ts @@ -350,9 +350,14 @@ describe("Apple bundle layouts and containers", () => { macho("Payload/Fixture.app/Fixture"), { path: "Payload/Fixture.app/embedded.mobileprovision" }, macho("Payload/Fixture.app/PlugIns/Share.appex/Share"), + macho("SwiftSupport/iphoneos/libswiftCore.dylib"), ]), ); expect(result.platforms).toEqual(["ios"]); + // IPA projection still lists archive components outside Payload/. + expect(result.components.native_libraries.map(({ path }) => path)).toEqual([ + "SwiftSupport/iphoneos/libswiftCore.dylib", + ]); expect( result.bundles.map(({ path, role, layout }) => [path, role, layout]), ).toEqual([ @@ -370,3 +375,66 @@ describe("Apple bundle layouts and containers", () => { ); }); }); + +describe("macOS application scope", () => { + it("attributes only components inside the application roots", () => { + const result = project( + inventoryEvidence("zip", "Distribution.zip", [ + macho("Fixture.app/Contents/MacOS/Fixture"), + macho("Fixture.app/Contents/Frameworks/Core.framework/Versions/A/Core"), + { + path: "Fixture.app/Contents/Resources/app.js", + format: "javascript-bundle", + }, + macho("Extras/Plugin.framework/Versions/A/Plugin"), + macho("Extras/libextra.dylib"), + { path: "Extras/installer.js", format: "javascript-bundle" }, + { path: "Extras/Info.plist" }, + ]), + ); + const paths = (components: readonly { readonly path: string }[]) => + components.map(({ path }) => path); + expect(paths(result.components.frameworks)).toEqual([ + "Fixture.app/Contents/Frameworks/Core.framework/Versions/A/Core", + ]); + expect(paths(result.components.native_libraries)).toEqual([]); + expect(paths(result.components.javascript)).toEqual([ + "Fixture.app/Contents/Resources/app.js", + ]); + expect(paths(result.components.bundle_metadata)).toEqual([]); + expect( + result.bridge_candidates.every( + ({ source_path: source, native_path: target }) => + [source, target].every((path) => path.startsWith("Fixture.app/")), + ), + ).toBe(true); + expect(result.limitations).toContain( + "4 inventoried entries outside every application root are not attributed to the application.", + ); + }); + + it("keeps a selected .app directory as the root when its contents are missing", () => { + const empty = project(inventoryEvidence("directory", "Fixture.app", [])); + expect(empty).toMatchObject({ + application_roots: ["."], + platforms: [], + bundles: [ + { + path: ".", + role: "application", + layout: "shallow", + info_plist_path: null, + }, + ], + }); + const partial = project( + inventoryEvidence("directory", "Fixture.app", [ + { path: "Contents/Resources/Localizable.strings" }, + ]), + ); + expect(partial).toMatchObject({ + application_roots: ["."], + platforms: ["macos"], + }); + }); +}); diff --git a/src/domain/appleApplication.ts b/src/domain/appleApplication.ts index b090a2be4..4650fce72 100644 --- a/src/domain/appleApplication.ts +++ b/src/domain/appleApplication.ts @@ -128,11 +128,18 @@ export const projectAppleApplication = ( }, }; }); - const all = entries.flatMap(({ component }) => + const inventoried = entries.flatMap(({ component }) => component === undefined ? [] : [component], ); - const subjectName = evidence[0]?.subject?.name ?? ""; - const roots = applicationRoots(entries, subjectName); + const roots = applicationRoots(entries, { + name: evidence[0]?.subject?.name ?? "", + format: rootFormat, + }); + const { all, unattributed } = attributeComponents( + inventoried, + roots, + rootFormat, + ); const bundles = detectBundles(entries, roots); const classified = classifyComponents(entries, all, roots); const runtimeFamilies = identifyRuntimeFamilies(all); @@ -153,6 +160,7 @@ export const projectAppleApplication = ( bundles, sidecars: entries.some(({ path }) => isSidecar(path)), symlinks: entries.some(({ kind }) => kind === "symlink"), + unattributed, }); const withoutId = { root_sha256: inventory.manifest.root_sha256, @@ -184,6 +192,24 @@ export const projectAppleApplication = ( }); }; +/** + * IPA projection keeps every archive component, as before. Directory, ZIP, + * and DMG inventories can hold installers and other siblings, so only + * components inside an application root are attributed to it. + */ +const attributeComponents = ( + inventoried: readonly Component[], + roots: readonly string[], + rootFormat: RootFormat, +): { readonly all: Component[]; readonly unattributed: number } => { + if (rootFormat === "ipa") return { all: [...inventoried], unattributed: 0 }; + const visible = inventoried.filter(({ path }) => !isSidecar(path)); + const all = visible.filter(({ path }) => + roots.some((root) => isWithin(path, root)), + ); + return { all, unattributed: visible.length - all.length }; +}; + const appleRootFormat = (format: string): RootFormat => { const root = APPLE_ROOT_FORMATS.find((candidate) => candidate === format); if (root === undefined) @@ -314,6 +340,7 @@ const projectionLimitations = (facts: { readonly bundles: readonly z.infer[]; readonly sidecars: boolean; readonly symlinks: boolean; + readonly unattributed: number; }): string[] => [ ...(!facts.complete ? ["Source inventory pages are incomplete; absence is unknown."] @@ -341,6 +368,11 @@ const projectionLimitations = (facts: { "Symlinks are reported by path only; their targets are not inventoried, so bundles are reported at their real paths.", ] : []), + ...(facts.unattributed > 0 + ? [ + `${facts.unattributed} inventoried entries outside every application root are not attributed to the application.`, + ] + : []), ...(facts.sidecars ? [ "AppleDouble sidecar entries (._* files and __MACOSX/) describe neighbouring files and are excluded from bundle roles.", diff --git a/src/domain/appleBundleAnatomy.ts b/src/domain/appleBundleAnatomy.ts index 5c03d2273..a70424908 100644 --- a/src/domain/appleBundleAnatomy.ts +++ b/src/domain/appleBundleAnatomy.ts @@ -73,8 +73,12 @@ export const isWithin = (path: string, root: string): boolean => */ export const applicationRoots = ( entries: readonly Entry[], - subjectName: string, + subject: { readonly name: string; readonly format: string }, ): string[] => { + // A selected `.app` directory is the application, even when its inventory + // is partial or lacks Contents/; coverage and limitations describe that. + if (subject.format === "directory" && /\.app$/iu.test(subject.name)) + return ["."]; const found = new Set(); for (const { path } of entries) { if (isSidecar(path)) continue; @@ -83,13 +87,6 @@ export const applicationRoots = ( found.add(ios[1]); continue; } - if ( - /\.app$/iu.test(subjectName) && - /^Contents\/(?:Info\.plist|MacOS\/[^/]+)$/u.test(path) - ) { - found.add("."); - continue; - } const segments = path.split("/"); const index = segments.findIndex( (segment, position) => @@ -280,13 +277,15 @@ const signingPaths = ( .filter((path) => tree.files.has(path)) .sort(compare); +/** Platforms of application roots whose layout was observed, not assumed. */ export const platformsOf = (bundles: readonly Bundle[]): ("ios" | "macos")[] => [ ...new Set( - bundles.flatMap(({ role, layout }) => { + bundles.flatMap(({ role, layout, info_plist_path: plist }) => { if (role !== "application") return []; if (layout === "macos-deep") return ["macos" as const]; - if (layout === "shallow") return ["ios" as const]; + // An empty or partial root has no Contents/ either; require Info.plist. + if (layout === "shallow" && plist !== null) return ["ios" as const]; return []; }), ),