From 55249704c5c83cbf8d20ac7869863cc196da00a6 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:30:47 +0000 Subject: [PATCH] fix(apple): attribute only application-root components in macOS inventories Directory, ZIP, and DMG inventories can hold installers, disk-image extras, and AppleDouble sidecars beside the app. Those components were classified as the application's frameworks, libraries, JavaScript, and metadata, and fed runtime families and bridge candidates. For these formats, components outside every application root are now left out, with a limitation that counts them. IPA projection is unchanged. A selected directory whose name ends in .app is now its own root by identity, so an empty or partial inventory still reports it. iOS is claimed only for shallow roots that have an Info.plist, so an empty root reports no platform. Co-Authored-By: Claude Opus 5.5 --- docs/apple-application-analysis.md | 13 +++++- src/domain/appleApplication.test.ts | 68 +++++++++++++++++++++++++++++ src/domain/appleApplication.ts | 38 ++++++++++++++-- src/domain/appleBundleAnatomy.ts | 19 ++++---- 4 files changed, 123 insertions(+), 15 deletions(-) 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 []; }), ),