fix(apple): derive bundle anatomy from directory facts

Build separate directory and authenticated-file indexes before applying
anatomy name preferences. Share those facts across application roots,
nested bundles, layout, metadata, executable candidates and signing paths.
Remove mixed child-name interpretation and downstream membership repairs.

A leaf Contents/Versions resource no longer changes a shallow layout, and
leaf files/symlinks cannot manufacture application roots. Real explicit
and implied directories retain their existing semantics and spelling.

Adapt the directory-identity proposal and source regressions from PR #3505
by @bnkaiteam, extending it to metadata file selection and root admission.

Validation: 57 focused checks, build, check:fast and docs:check pass.
30 tiny completed source projection cost controls preserve ordinary deep
and versioned replies byte for byte. No real-provider/target execution,
heavy profiling or published npm verification.

Fixes #3359
Fixes #4779
This commit is contained in:
morluto committed 2026-10-17 19:53:45 +08:00
1 parent c192c58815
commit 149a0fc06c
5 files changed
+338 -72

No files matched your search

+6
View File
@@ -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 `<Name>.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 `<Name>.app`. A DMG
reports `<image>.dmg/<Volume>/<Name>.app`; DMG child inventory needs the native
macOS mounting adapter. When a DMG has only its root identity, the projection is
+123
View File
@@ -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([]);
},
);
+2 -4
View File
@@ -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(
+76 -68
View File
@@ -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<string> | undefined,
names: Iterable<string> | 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<string>();
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<string>(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<string, ReadonlySet<string>>;
readonly files: ReadonlyMap<string, Component>;
/** Paths observed as directories, or implied by a deeper path. */
readonly directories: ReadonlySet<string>;
readonly directories: ReadonlyMap<string, ReadonlySet<string>>;
readonly files: ReadonlyMap<string, ReadonlyMap<string, Component>>;
}
const indexTree = (entries: readonly Entry[]): Tree => {
const children = new Map<string, Set<string>>();
const files = new Map<string, Component>();
const directories = new Set<string>();
const directories = new Map<string, Set<string>>();
const files = new Map<string, Map<string, Component>>();
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<string>();
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<string>();
names.add(segment);
directories.set(parent, names);
} else if (kind === "file" && component !== undefined) {
const children = files.get(parent) ?? new Map<string, Component>();
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<string> {
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. */
@@ -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("<plist/>"),
);
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([]);
},
);