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 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-07 10:30:47 +00:00
parent b956bd589f
commit 55249704c5
4 changed files with 123 additions and 15 deletions

View File

@ -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

View File

@ -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"],
});
});
});

View File

@ -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<typeof appleBundleSchema>[];
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.",

View File

@ -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<string>();
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 [];
}),
),