fix(apple): read flattened framework Info.plist from Resources (#8101)
A macOS framework without Versions/ keeps its binary, _CodeSignature/ and Resources/Info.plist directly in the .framework, as Electron and Gecko apps ship it. Bundle anatomy looked for a shallow bundle's plist only at its root and reported info_plist_path: null under complete coverage. Resolve a shallow bundle's root Info.plist first, then Resources/Info.plist, the same layout a framework version directory uses. Keep the iOS platform rule tied to a root-level Info.plist so a Resources/-only application root is not classified as iOS. Fixes #8100 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bfa082f777
commit
6d19649c0b
4 files changed
+184
-13
No files matched your search
@@ -101,8 +101,8 @@ 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.
|
||||
for shallow application roots that have an `Info.plist` at their root. An
|
||||
application root with neither, such as an empty inventory, has no platform.
|
||||
|
||||
## macOS bundle anatomy
|
||||
|
||||
@@ -149,6 +149,11 @@ directories rather than `Versions/Current`. A framework's `info_plist_path` is
|
||||
because `Versions/Current` decides which one applies) or when its single
|
||||
version directory has no `Resources/Info.plist`.
|
||||
|
||||
A `shallow` bundle's `info_plist_path` is its root `Info.plist`. Without one,
|
||||
it is `Resources/Info.plist`: a flattened macOS framework keeps its binary,
|
||||
`_CodeSignature/`, and `Resources/` directly in the `.framework`, like a single
|
||||
`Versions/<version>/` directory without the `Versions/` wrapper.
|
||||
|
||||
AppleDouble sidecar files (`._*` and `__MACOSX/`) are inventory facts, but they
|
||||
describe neighbouring files. They are excluded from roots, bundle roles, and the
|
||||
anatomy components.
|
||||
|
||||
@@ -549,6 +549,87 @@ describe("Apple bundle layouts and containers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Shallow bundle Info.plist", () => {
|
||||
it("reads a flattened framework's Info.plist from Resources/", () => {
|
||||
const framework = "Contents/Frameworks/Core.framework";
|
||||
const result = project(
|
||||
inventoryEvidence("directory", "Fixture.app", [
|
||||
{ path: "Contents/Info.plist" },
|
||||
macho("Contents/MacOS/Fixture"),
|
||||
macho(`${framework}/Core`),
|
||||
{ path: `${framework}/Resources/Info.plist` },
|
||||
{ path: `${framework}/_CodeSignature/CodeResources` },
|
||||
]),
|
||||
);
|
||||
expect(result.bundles.find(({ path }) => path === framework)).toEqual({
|
||||
path: framework,
|
||||
parent_path: ".",
|
||||
layout: "shallow",
|
||||
role: "framework",
|
||||
role_basis: "path-convention",
|
||||
info_plist_path: `${framework}/Resources/Info.plist`,
|
||||
executable_candidates: [`${framework}/Core`],
|
||||
signing_paths: [`${framework}/_CodeSignature/CodeResources`],
|
||||
});
|
||||
expect(result.platforms).toEqual(["macos"]);
|
||||
expect(result.coverage.status).toBe("complete-within-inventory");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "a root Info.plist over Resources/",
|
||||
entries: [
|
||||
{ path: "Core.framework/Info.plist" },
|
||||
{ path: "Core.framework/Resources/Info.plist" },
|
||||
],
|
||||
plist: "Core.framework/Info.plist",
|
||||
},
|
||||
{
|
||||
name: "a folded resources/info.plist",
|
||||
entries: [{ path: "Core.framework/resources/info.plist" }],
|
||||
plist: "Core.framework/resources/info.plist",
|
||||
},
|
||||
{
|
||||
name: "no plist from a Resources file",
|
||||
entries: [{ path: "Core.framework/Resources" }],
|
||||
plist: null,
|
||||
},
|
||||
{
|
||||
name: "no plist from a nested bundle's Resources/",
|
||||
entries: [{ path: "Core.framework/Data.bundle/Resources/Info.plist" }],
|
||||
plist: null,
|
||||
},
|
||||
])("selects $name in a shallow bundle", ({ entries, plist }) => {
|
||||
const root = "Payload/Fixture.app";
|
||||
const result = project(
|
||||
inventoryEvidence("ipa", "Fixture.ipa", [
|
||||
{ path: `${root}/Info.plist` },
|
||||
...entries.map(({ path }) => ({ path: `${root}/${path}` })),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
result.bundles.find(({ path }) => path === `${root}/Core.framework`)
|
||||
?.info_plist_path,
|
||||
).toBe(plist === null ? null : `${root}/${plist}`);
|
||||
expect(result.platforms).toEqual(["ios"]);
|
||||
});
|
||||
|
||||
it("does not classify a Resources/-only application root as iOS", () => {
|
||||
const root = "Payload/Fixture.app";
|
||||
const result = project(
|
||||
inventoryEvidence("ipa", "Fixture.ipa", [
|
||||
{ path: `${root}/Resources/Info.plist` },
|
||||
]),
|
||||
);
|
||||
expect(result.bundles[0]).toMatchObject({
|
||||
path: root,
|
||||
layout: "shallow",
|
||||
info_plist_path: `${root}/Resources/Info.plist`,
|
||||
});
|
||||
expect(result.platforms).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Apple IPA components", () => {
|
||||
it("excludes AppleDouble sidecars from IPA components", () => {
|
||||
const result = project(
|
||||
|
||||
@@ -212,6 +212,10 @@ const joinPath = (directory: string, name: string): string =>
|
||||
|
||||
const directoryOf = (bundle: string): string => (bundle === "." ? "" : bundle);
|
||||
|
||||
/** Inverse of joinPath: the directory part of an inventory path. */
|
||||
const parentDirectory = (path: string): string =>
|
||||
path.slice(0, Math.max(0, path.lastIndexOf("/")));
|
||||
|
||||
const enclosingBundle = (
|
||||
path: string,
|
||||
bundles: ReadonlySet<string>,
|
||||
@@ -316,12 +320,16 @@ const infoPlistPath = (
|
||||
if (directories.length !== 1) return null;
|
||||
const directory = directories[0];
|
||||
if (directory === undefined) return null;
|
||||
const parent =
|
||||
layout === "versioned-framework"
|
||||
? namedDirectory(tree, directory, "Resources")
|
||||
: directory;
|
||||
if (parent === null) return null;
|
||||
return namedFile(tree, parent, "Info.plist");
|
||||
if (layout === "macos-deep") return namedFile(tree, directory, "Info.plist");
|
||||
// A framework version keeps Info.plist in Resources/ (CoreFoundation's
|
||||
// version-0 layout). A flattened macOS framework is that directory without
|
||||
// the Versions/ wrapper; an iOS-style shallow bundle keeps it at its root,
|
||||
// which takes precedence.
|
||||
const resources = namedDirectory(tree, directory, "Resources");
|
||||
const resourcePlist =
|
||||
resources === null ? null : namedFile(tree, resources, "Info.plist");
|
||||
if (layout === "versioned-framework") return resourcePlist;
|
||||
return namedFile(tree, directory, "Info.plist") ?? resourcePlist;
|
||||
};
|
||||
|
||||
const executableCandidates = (
|
||||
@@ -366,11 +374,17 @@ const signingPaths = (
|
||||
export const platformsOf = (bundles: readonly Bundle[]): ("ios" | "macos")[] =>
|
||||
[
|
||||
...new Set(
|
||||
bundles.flatMap(({ role, layout, info_plist_path: plist }) => {
|
||||
bundles.flatMap(({ path, role, layout, info_plist_path: plist }) => {
|
||||
if (role !== "application") return [];
|
||||
if (layout === "macos-deep") return ["macos" as const];
|
||||
// An empty or partial root has no Contents/ either; require Info.plist.
|
||||
if (layout === "shallow" && plist !== null) return ["ios" as const];
|
||||
// An empty or partial root has no Contents/ either. iOS bundles keep
|
||||
// Info.plist at their root, not in a Resources/ directory.
|
||||
if (
|
||||
layout === "shallow" &&
|
||||
plist !== null &&
|
||||
parentDirectory(plist) === directoryOf(path)
|
||||
)
|
||||
return ["ios" as const];
|
||||
return [];
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, 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 { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
@@ -93,6 +95,75 @@ cliTest.for([
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"Apple CLI and MCP read a flattened framework's Resources/Info.plist",
|
||||
{ timeout: 60_000 },
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-apple-flat-framework-");
|
||||
const app = join(root, "Fixture.app");
|
||||
const framework = "Contents/Frameworks/Core.framework";
|
||||
const machO = thinMach(0xcffaedfe, 0x0100000c);
|
||||
// Electron and Gecko apps ship frameworks without Versions/: the binary,
|
||||
// _CodeSignature/ and Resources/ sit directly in the .framework.
|
||||
const files: [string, string | Uint8Array][] = [
|
||||
[
|
||||
"Contents/Info.plist",
|
||||
'<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>',
|
||||
],
|
||||
["Contents/MacOS/Fixture", machO],
|
||||
[`${framework}/Core`, machO],
|
||||
[`${framework}/Resources/Info.plist`, "<plist><dict/></plist>"],
|
||||
[`${framework}/_CodeSignature/CodeResources`, "<plist><dict/></plist>"],
|
||||
];
|
||||
for (const [relative, content] of files) {
|
||||
await mkdir(dirname(join(app, relative)), { recursive: true });
|
||||
await writeFile(join(app, relative), content);
|
||||
}
|
||||
const environment = { HOME: root, USERPROFILE: root };
|
||||
const inspected = await cli.run({
|
||||
arguments: ["inspect-artifact", app, "--json"],
|
||||
environment,
|
||||
});
|
||||
expect(inspected.exitCode, inspected.stderr).toBe(0);
|
||||
const input = { inventory_evidence: [parseEvidence(inspected.json)] };
|
||||
const output = await cli.run({
|
||||
arguments: [
|
||||
"project-apple-application-graph",
|
||||
JSON.stringify(input),
|
||||
"--json",
|
||||
],
|
||||
environment,
|
||||
});
|
||||
expect(output.exitCode, output.stderr).toBe(0);
|
||||
const evidence = parseEvidence(output.json);
|
||||
const { call } = await connectLocalToolsMcp(environment);
|
||||
const mcp = await call("project_apple_application_graph", input);
|
||||
expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true);
|
||||
expect(parseEvidence(mcp.structuredContent)).toEqual(evidence);
|
||||
const projection = appleApplicationProjectionResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(projection.bundles).toEqual([
|
||||
expect.objectContaining({ path: ".", layout: "macos-deep" }),
|
||||
{
|
||||
path: framework,
|
||||
parent_path: ".",
|
||||
layout: "shallow",
|
||||
role: "framework",
|
||||
role_basis: "path-convention",
|
||||
info_plist_path: `${framework}/Resources/Info.plist`,
|
||||
executable_candidates: [`${framework}/Core`],
|
||||
signing_paths: [`${framework}/_CodeSignature/CodeResources`],
|
||||
},
|
||||
]);
|
||||
expect(projection.platforms).toEqual(["macos"]);
|
||||
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 }) => {
|
||||
|
||||
Reference in new issue
Block a user