diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index 2ac7139335..6fcc4b746a 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -77,6 +77,12 @@ including when empty, so Apple projection identifiers change. | `.app` directory | `.`, the inventoried bundle itself | | Directory, ZIP, or DMG containing apps | Each outermost `.app` followed by `Contents/` | +Root and layout discovery use known directories and ancestors implied by deeper +inventory paths. A leaf file or symlink named `*.app`, `Contents`, or `Versions` +does not establish that directory. Directory and authenticated-file identity are +selected before applying anatomy case preferences, so a file cannot shadow a +folded directory name or a directory shadow a metadata/signature file. + ZIP archives made with `ditto -c -k --keepParent` report `.app`. A DMG reports `.dmg//.app`; DMG child inventory needs the native macOS mounting adapter. When a DMG has only its root identity, the projection is diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index 4678930385..27e7775adc 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -294,6 +294,91 @@ describe("macOS application roots and bundle roles", () => { }); }); +describe("Apple bundle directory identity", () => { + it.each([ + ["Contents", "file"], + ["Contents", "symlink"], + ["Versions", "file"], + ["Versions", "symlink"], + ] as const)("does not derive a layout from a %s %s", (name, kind) => { + const root = "Payload/Mobile.app"; + const result = project( + inventoryEvidence("ipa", "Mobile.ipa", [ + { path: `${root}/Info.plist`, format: "plist" }, + { path: `${root}/${name}`, kind }, + ]), + ); + expect(result.platforms).toEqual(["ios"]); + expect(result.bundles).toEqual([ + expect.objectContaining({ + path: root, + layout: "shallow", + info_plist_path: `${root}/Info.plist`, + }), + ]); + }); + + it.each([ + ["Contents", "macos-deep"], + ["Versions", "versioned-framework"], + ] as const)( + "derives a layout from an explicit empty %s directory", + (name, layout) => { + const root = "Payload/Mobile.app"; + const result = project( + inventoryEvidence("ipa", "Mobile.ipa", [ + { path: `${root}/${name}`, kind: "directory" }, + ]), + ); + expect(result.bundles[0]).toMatchObject({ path: root, layout }); + }, + ); + + it.each([ + ["Contents", "contents/Info.plist", "macos-deep"], + ["Versions", "versions/A/Resources/Info.plist", "versioned-framework"], + ] as const)( + "keeps a real %s directory distinct from a same-named file", + (name, plist, layout) => { + const root = "Payload/Mobile.app"; + const result = project( + inventoryEvidence("ipa", "Mobile.ipa", [ + { path: `${root}/${name}` }, + { path: `${root}/${plist}`, format: "plist" }, + ]), + ); + expect(result.bundles[0]).toMatchObject({ + path: root, + layout, + info_plist_path: `${root}/${plist}`, + }); + }, + ); + + it("keeps files from shadowing anatomy directories inside bundles", () => { + const framework = "Contents/Frameworks/Core.framework"; + const result = project( + inventoryEvidence("directory", "Fixture.app", [ + { path: "Contents/Info.plist" }, + { path: "Contents/MacOS" }, + macho("Contents/macos/Fixture"), + { path: "Contents/_CodeSignature" }, + { path: "Contents/_codesignature/CodeResources" }, + { path: `${framework}/Versions/A/Resources` }, + { path: `${framework}/Versions/A/resources/Info.plist` }, + ]), + ); + expect(result.bundles[0]).toMatchObject({ + executable_candidates: ["Contents/macos/Fixture"], + signing_paths: ["Contents/_codesignature/CodeResources"], + }); + expect(result.bundles[1]).toMatchObject({ + path: framework, + info_plist_path: `${framework}/Versions/A/resources/Info.plist`, + }); + }); +}); + describe("Apple bundle layouts and containers", () => { it("follows versioned frameworks without resolving Versions/Current", () => { const framework = "Fixture.app/Contents/Frameworks/Core.framework"; @@ -782,3 +867,41 @@ it.each(["file", "slice"] as const)( }); }, ); + +it("selects metadata and signature files before applying anatomy case preferences", () => { + const result = project( + inventoryEvidence("directory", "Fixture.app", [ + { path: "Contents/Info.plist", kind: "directory" }, + { path: "Contents/info.plist", format: "plist" }, + { path: "Contents/_CodeSignature/CodeResources", kind: "directory" }, + { path: "Contents/_CodeSignature/coderesources" }, + { path: "Contents/embedded.mobileprovision", kind: "directory" }, + { path: "Contents/Embedded.mobileprovision" }, + ]), + ); + expect(result.bundles[0]).toMatchObject({ + layout: "macos-deep", + info_plist_path: "Contents/info.plist", + signing_paths: [ + "Contents/Embedded.mobileprovision", + "Contents/_CodeSignature/coderesources", + ], + }); +}); + +it.each([ + ["Payload/Fake.app", "file"], + ["Payload/Fake.app", "symlink"], + ["Fake.app/Contents", "file"], + ["Fake.app/Contents", "symlink"], +] as const)( + "does not invent an application root from a %s %s", + (path, kind) => { + const result = project( + inventoryEvidence("zip", "Fixture.zip", [{ path, kind }]), + ); + expect(result.application_roots).toEqual([]); + expect(result.bundles).toEqual([]); + expect(result.platforms).toEqual([]); + }, +); diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index 79ddf9e94c..da3168aad9 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -4,8 +4,7 @@ import { anatomyNameIs, anatomyTailMatches, appleBundleSchema, - applicationRoots, - detectBundles, + projectAppleBundleAnatomy, isMachOFormat, isSidecar, isWithin, @@ -123,7 +122,7 @@ export const projectAppleApplication = ( const inventoried = entries.flatMap(({ component }) => component === undefined ? [] : [component], ); - const roots = applicationRoots(entries, { + const { roots, bundles } = projectAppleBundleAnatomy(entries, { name: evidence[0]?.subject?.name ?? "", format: rootFormat, }); @@ -142,7 +141,6 @@ export const projectAppleApplication = ( roots, rootFormat, ); - const bundles = detectBundles(entries, roots); const classified = classifyComponents(entries, all, roots); const runtimeFamilies = identifyRuntimeFamilies(all); const bridgeProjection = identifyBridgeCandidates( diff --git a/src/domain/apple/appleBundleAnatomy.ts b/src/domain/apple/appleBundleAnatomy.ts index d532ed11f6..ec25af55b3 100644 --- a/src/domain/apple/appleBundleAnatomy.ts +++ b/src/domain/apple/appleBundleAnatomy.ts @@ -65,7 +65,7 @@ export const anatomyNameIs = (actual: string, expected: string): boolean => /** Directory entry that folds to one vocabulary word. The exact spelling wins. */ export const anatomyChild = ( - names: ReadonlySet | undefined, + names: Iterable | undefined, expected: string, ): string | null => { if (names === undefined) return null; @@ -100,12 +100,22 @@ export const anatomyTailMatches = ( }); }; +/** Interpret bundle roots and anatomy from the same directory and file indexes. */ +export const projectAppleBundleAnatomy = ( + entries: readonly Entry[], + subject: { readonly name: string; readonly format: string }, +) => { + const tree = indexTree(entries); + const roots = applicationRoots(tree, subject); + return { roots, bundles: detectBundles(tree, roots) }; +}; + /** * Outermost iOS (`Payload/X.app`) and macOS (`X.app/Contents/…`) applications. * An inventoried `.app` directory is its own root, `.`. */ -export const applicationRoots = ( - entries: readonly Entry[], +const applicationRoots = ( + tree: Tree, subject: { readonly name: string; readonly format: string }, ): string[] => { // A selected `.app` directory is the application, even when its inventory @@ -113,8 +123,7 @@ export const applicationRoots = ( if (subject.format === "directory" && /\.app$/iu.test(subject.name)) return ["."]; const found = new Set(); - for (const { path } of entries) { - if (isSidecar(path)) continue; + for (const path of directoryPaths(tree)) { const segments = path.split("/"); const app = segments[1]; if ( @@ -140,26 +149,15 @@ export const applicationRoots = ( .sort(compare); }; -export const detectBundles = ( - entries: readonly Entry[], - roots: readonly string[], -): Bundle[] => { +const detectBundles = (tree: Tree, roots: readonly string[]): Bundle[] => { const paths = new Set(roots); - for (const { path, kind } of entries) { - if (isSidecar(path)) continue; - const root = roots.find((candidate) => isWithin(path, candidate)); - if (root === undefined) continue; - const segments = path.split("/"); - const start = root === "." ? 0 : root.split("/").length; - for (let index = start; index < segments.length; index++) { - const segment = segments[index] ?? ""; - const leaf = index === segments.length - 1; - // A symlink or file named like a bundle has no observed contents. - if (BUNDLE_EXTENSION.test(segment) && (!leaf || kind === "directory")) - paths.add(segments.slice(0, index + 1).join("/")); - } + for (const path of directoryPaths(tree)) { + if ( + BUNDLE_EXTENSION.test(path.split("/").at(-1) ?? "") && + roots.some((root) => isWithin(path, root)) + ) + paths.add(path); } - const tree = indexTree(entries); return [...paths].sort(compare).map((path) => { const layout = bundleLayout(path, tree); return { @@ -175,35 +173,41 @@ export const detectBundles = ( }); }; -/** Directory children (including implied ancestors) and files, indexed once. */ +/** Keep directory identity separate from authenticated file content before name selection. */ interface Tree { - readonly children: ReadonlyMap>; - readonly files: ReadonlyMap; - /** Paths observed as directories, or implied by a deeper path. */ - readonly directories: ReadonlySet; + readonly directories: ReadonlyMap>; + readonly files: ReadonlyMap>; } const indexTree = (entries: readonly Entry[]): Tree => { - const children = new Map>(); - const files = new Map(); - const directories = new Set(); + const directories = new Map>(); + const files = new Map>(); 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) { - const names = children.get(parent) ?? new Set(); - names.add(segment); - children.set(parent, names); + for (const [index, segment] of segments.entries()) { + const leaf = index === segments.length - 1; + if (!leaf || kind === "directory") { + const names = directories.get(parent) ?? new Set(); + names.add(segment); + directories.set(parent, names); + } else if (kind === "file" && component !== undefined) { + const children = files.get(parent) ?? new Map(); + children.set(segment, component); + files.set(parent, children); + } parent = joinPath(parent, segment); - if (parent !== path) directories.add(parent); } } - return { children, files, directories }; + return { directories, files }; }; +function* directoryPaths(tree: Tree): Iterable { + for (const [parent, names] of tree.directories) + for (const name of names) yield joinPath(parent, name); +} + const joinPath = (directory: string, name: string): string => directory === "" ? name : `${directory}/${name}`; @@ -223,9 +227,10 @@ const enclosingBundle = ( }; const bundleLayout = (bundle: string, tree: Tree): Bundle["layout"] => { - const names = tree.children.get(directoryOf(bundle)); - if (anatomyChild(names, "Contents") !== null) return "macos-deep"; - if (anatomyChild(names, "Versions") !== null) return "versioned-framework"; + const directory = directoryOf(bundle); + if (namedDirectory(tree, directory, "Contents") !== null) return "macos-deep"; + if (namedDirectory(tree, directory, "Versions") !== null) + return "versioned-framework"; return "shallow"; }; @@ -255,11 +260,10 @@ const bundleRole = (bundle: string): Bundle["role"] => { }; /** Files directly inside one directory; nested bundles are deeper paths. */ -const directFiles = (directory: string, tree: Tree): string[] => - [...(tree.children.get(directory) ?? [])] - .map((name) => joinPath(directory, name)) - .filter((path) => tree.files.has(path)) - .sort(compare); +const directFiles = (directory: string, tree: Tree): Component[] => + [...(tree.files.get(directory)?.values() ?? [])].sort((left, right) => + compare(left.path, right.path), + ); /** Content directories; `Versions/Current` is a symlink outside the inventory. */ const contentDirectories = ( @@ -269,28 +273,36 @@ const contentDirectories = ( ): string[] => { const directory = directoryOf(bundle); if (layout === "macos-deep") { - const contents = namedChild(tree, directory, "Contents"); + const contents = namedDirectory(tree, directory, "Contents"); return contents === null ? [] : [contents]; } if (layout === "shallow") return [directory]; - const versions = namedChild(tree, directory, "Versions"); + const versions = namedDirectory(tree, directory, "Versions"); if (versions === null) return []; // Only real version directories count: Current is a symlink, and files // such as .DS_Store are not versions. - return [...(tree.children.get(versions) ?? [])] + return [...(tree.directories.get(versions) ?? [])] .filter((version) => !anatomyNameIs(version, "Current")) .map((version) => joinPath(versions, version)) - .filter((version) => tree.directories.has(version)) .sort(compare); }; -/** Child path using the archive's spelling of one anatomy name. */ -const namedChild = ( +/** Select applicable entry kinds before applying Apple's case preference. */ +const namedDirectory = ( tree: Tree, directory: string, expected: string, ): string | null => { - const name = anatomyChild(tree.children.get(directory), expected); + const name = anatomyChild(tree.directories.get(directory), expected); + return name === null ? null : joinPath(directory, name); +}; + +const namedFile = ( + tree: Tree, + directory: string, + expected: string, +): string | null => { + const name = anatomyChild(tree.files.get(directory)?.keys(), expected); return name === null ? null : joinPath(directory, name); }; @@ -307,11 +319,10 @@ const infoPlistPath = ( if (directory === undefined) return null; const parent = layout === "versioned-framework" - ? namedChild(tree, directory, "Resources") + ? namedDirectory(tree, directory, "Resources") : directory; if (parent === null) return null; - const info = namedChild(tree, parent, "Info.plist"); - return info !== null && tree.files.has(info) ? info : null; + return namedFile(tree, parent, "Info.plist"); }; const executableCandidates = ( @@ -323,15 +334,14 @@ const executableCandidates = ( .flatMap((directory) => { const executableDirectory = layout === "macos-deep" - ? namedChild(tree, directory, "MacOS") + ? namedDirectory(tree, directory, "MacOS") : directory; return executableDirectory === null ? [] : directFiles(executableDirectory, tree); }) - .filter((path) => - MACH_O_FORMATS.includes(tree.files.get(path)?.format ?? ""), - ); + .filter(({ format }) => isMachOFormat(format)) + .map(({ path }) => path); const signingPaths = ( bundle: string, @@ -340,16 +350,14 @@ const signingPaths = ( ): string[] => contentDirectories(bundle, layout, tree) .flatMap((directory) => { - const signature = namedChild(tree, directory, "_CodeSignature"); + const signature = namedDirectory(tree, directory, "_CodeSignature"); return [ - signature === null - ? null - : namedChild(tree, signature, "CodeResources"), - namedChild(tree, directory, "embedded.provisionprofile"), - namedChild(tree, directory, "embedded.mobileprovision"), + signature === null ? null : namedFile(tree, signature, "CodeResources"), + namedFile(tree, directory, "embedded.provisionprofile"), + namedFile(tree, directory, "embedded.mobileprovision"), ]; }) - .filter((path): path is string => path !== null && tree.files.has(path)) + .filter((path): path is string => path !== null) .sort(compare); /** Platforms of application roots whose layout was observed, not assumed. */ diff --git a/tests/boundary/cli/appleBundleDirectoryCli.test.ts b/tests/boundary/cli/appleBundleDirectoryCli.test.ts new file mode 100644 index 0000000000..1857a5f78d --- /dev/null +++ b/tests/boundary/cli/appleBundleDirectoryCli.test.ts @@ -0,0 +1,131 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect } from "vitest"; + +import { appleApplicationProjectionResultSchema } from "../../../src/domain/apple/appleApplication.js"; +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const rootPath = "Payload/Fixture.app"; +cliTest.for([ + { + name: "regular Contents resource", + resource: "Contents", + layout: "shallow", + plist: "Info.plist", + platforms: ["ios"], + }, + { + name: "regular Versions resource", + resource: "Versions", + layout: "shallow", + plist: "Info.plist", + platforms: ["ios"], + }, + { + name: "implied Contents directory", + resource: "Resource", + layout: "macos-deep", + plist: "Contents/Info.plist", + platforms: ["macos"], + }, + { + name: "file shadowing a folded directory", + resource: "Contents", + layout: "macos-deep", + plist: "contents/Info.plist", + platforms: ["macos"], + }, +])( + "Apple CLI respects directory identity with $name", + async (fixture, { cli }) => { + const root = await createTestTempDirectory("rea-apple-directory-cli-"); + const path = join(root, "Fixture.ipa"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add( + `${rootPath}/${fixture.resource}`, + new TextReader("ordinary resource"), + ); + await writer.add( + `${rootPath}/${fixture.plist}`, + new TextReader(""), + ); + await writeFile(path, await writer.close()); + const inspected = await cli.run({ + arguments: ["inspect-artifact", path, "--json"], + }); + expect(inspected.exitCode, inspected.stderr).toBe(0); + const evidence = parseEvidence(inspected.json); + const inventory = artifactInspectionResultSchema.parse( + evidence.normalized_result, + ).substeps[0]?.evidence; + expect(inventory?.normalized_result.occurrences).toContainEqual( + expect.objectContaining({ + logical_path: `${rootPath}/${fixture.resource}`, + entry_kind: "file", + }), + ); + const input = join(root, "projection.json"); + await writeFile(input, JSON.stringify({ inventory_evidence: [evidence] })); + const output = await cli.run({ + arguments: ["project-apple-application-graph", input, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const projection = appleApplicationProjectionResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(projection.bundles).toEqual([ + expect.objectContaining({ + path: rootPath, + layout: fixture.layout, + info_plist_path: `${rootPath}/${fixture.plist}`, + }), + ]); + expect(projection.platforms).toEqual(fixture.platforms); + expect(projection.coverage).toEqual({ + status: "complete-within-inventory", + inventory_complete: true, + }); + }, +); + +cliTest( + "Apple CLI does not invent an application from a leaf Contents file", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apple-false-root-cli-"); + const path = join(root, "Fixture.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("Fake.app/Contents", new TextReader("ordinary resource")); + await writeFile(path, await writer.close()); + const inspected = await cli.run({ + arguments: ["inspect-artifact", path, "--json"], + }); + expect(inspected.exitCode, inspected.stderr).toBe(0); + const source = parseEvidence(inspected.json); + const inventory = artifactInspectionResultSchema.parse( + source.normalized_result, + ).substeps[0]?.evidence; + expect(inventory?.normalized_result.occurrences).toContainEqual( + expect.objectContaining({ + logical_path: "Fake.app/Contents", + entry_kind: "file", + }), + ); + const input = join(root, "projection.json"); + await writeFile(input, JSON.stringify({ inventory_evidence: [source] })); + const output = await cli.run({ + arguments: ["project-apple-application-graph", input, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const projection = appleApplicationProjectionResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(projection.application_roots).toEqual([]); + expect(projection.bundles).toEqual([]); + expect(projection.platforms).toEqual([]); + }, +);