diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index 0fedfde02b..72fcb1509f 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -685,7 +685,7 @@ describe("Apple application bridge candidates", () => { ), ); const result = project( - inventoryEvidence("zip", "Fixture.zip", [...scripts, ...native]), + inventoryEvidence("ipa", "Fixture.ipa", [...scripts, ...native]), ); expect(result.components.javascript).toHaveLength(129); @@ -970,3 +970,41 @@ it("preserves observed unavailable bundle metadata and signing paths without inv expect(result.platforms).toEqual(["ios"]); expect(result.unavailable_components).toHaveLength(3); }); + +it.each(["zip", "directory", "dmg"] as const)( + "does not apply IPA roots to a %s inventory", + (format) => { + const result = project( + inventoryEvidence(format, `Sample.${format}`, [ + { path: "Payload/App.app/Info.plist", format: "plist" }, + { path: "Extras/Other.app/Info.plist", format: "plist" }, + ]), + ); + expect(result.root_format).toBe(format); + expect(result.application_roots).toEqual([]); + expect(result.platforms).toEqual([]); + expect(result.bundles).toEqual([]); + }, +); + +it.each(["ipa", "zip", "directory", "dmg"] as const)( + "uses only the %s root convention in a mixed inventory", + (format) => { + const source = inventoryEvidence(format, `Mixed.${format}`, [ + { path: "Payload/App.app/Info.plist", format: "plist" }, + { path: "Extras/Other.app/Contents/Info.plist", format: "plist" }, + macho("Extras/Other.app/Contents/MacOS/Other"), + { + path: "Extras/Other.app/Contents/Resources/app.js", + format: "javascript-bundle", + }, + ]); + const result = project(source); + expect(result.application_roots).toEqual([ + format === "ipa" ? "Payload/App.app" : "Extras/Other.app", + ]); + expect(result.platforms).toEqual([format === "ipa" ? "ios" : "macos"]); + expect(result.components.javascript).toHaveLength(1); + expect(result.components.executables).toHaveLength(1); + }, +); diff --git a/src/domain/apple/appleBundleAnatomy.ts b/src/domain/apple/appleBundleAnatomy.ts index 2265938f0d..6be7696292 100644 --- a/src/domain/apple/appleBundleAnatomy.ts +++ b/src/domain/apple/appleBundleAnatomy.ts @@ -123,12 +123,13 @@ const applicationRoots = ( for (const path of directoryPaths(tree)) { const segments = path.split("/"); const app = segments[1]; - if ( - anatomyNameIs(segments[0] ?? "", "Payload") && - app !== undefined && - /\.app$/iu.test(app) - ) { - found.add(`${segments[0]}/${app}`); + if (subject.format === "ipa") { + if ( + anatomyNameIs(segments[0] ?? "", "Payload") && + app !== undefined && + /\.app$/iu.test(app) + ) + found.add(`${segments[0]}/${app}`); continue; } const index = segments.findIndex( diff --git a/tests/boundary/cli/appleContainerRootCli.test.ts b/tests/boundary/cli/appleContainerRootCli.test.ts new file mode 100644 index 0000000000..1426745b8c --- /dev/null +++ b/tests/boundary/cli/appleContainerRootCli.test.ts @@ -0,0 +1,114 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + TextReader, + Uint8ArrayReader, + Uint8ArrayWriter, + ZipWriter, +} from "@zip.js/zip.js"; +import { expect } from "vitest"; +import { appleApplicationProjectionResultSchema } from "../../../src/domain/apple/appleApplication.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for([ + { + name: "flat ZIP", + extension: "zip", + payload: true, + deep: false, + roots: [], + platforms: [], + }, + { + name: "flat IPA", + extension: "ipa", + payload: true, + deep: false, + roots: ["Payload/Main.app"], + platforms: ["ios"], + }, + { + name: "deep ZIP", + extension: "zip", + payload: false, + deep: true, + roots: ["Extras/Helper.app"], + platforms: ["macos"], + }, + { + name: "IPA with unrooted deep helper", + extension: "ipa", + payload: true, + deep: true, + roots: ["Payload/Main.app"], + platforms: ["ios"], + }, +])( + "keeps format-specific Apple roots through CLI and MCP ($name)", + async (fixture, { cli }) => { + const root = await createTestTempDirectory("rea-apple-root-format-"); + const path = join(root, `Sample.${fixture.extension}`); + const writer = new ZipWriter(new Uint8ArrayWriter()); + if (fixture.payload) + await writer.add( + "Payload/Main.app/Info.plist", + new TextReader(""), + ); + await writer.add( + "Extras/Other.app/Info.plist", + new TextReader(""), + ); + if (fixture.deep) { + await writer.add( + "Extras/Helper.app/Contents/Info.plist", + new TextReader(""), + ); + const macho = Buffer.concat([ + Buffer.from("cffaedfe070000010300008002000000", "hex"), + Buffer.alloc(40), + ]); + await writer.add( + "Extras/Helper.app/Contents/MacOS/Helper", + new Uint8ArrayReader(macho), + ); + await writer.add("Shared/other.js", new TextReader("console.log(2)")); + } + const bytes = await writer.close(); + await writeFile(path, bytes); + const inspected = await cli.run({ + arguments: ["inspect-artifact", path, "--json"], + }); + expect(inspected.exitCode, inspected.stderr).toBe(0); + const source = parseEvidence(inspected.json); + const input = { inventory_evidence: [source] }; + const request = join(root, "request.json"); + await writeFile(request, JSON.stringify(input)); + const projected = await cli.run({ + arguments: ["project-apple-application-graph", request, "--json"], + }); + expect(projected.exitCode, projected.stderr).toBe(0); + const cliGraph = appleApplicationProjectionResultSchema.parse( + parseEvidence(projected.json).normalized_result, + ); + expect(cliGraph.root_format).toBe(fixture.extension); + expect(cliGraph.application_roots).toEqual(fixture.roots); + expect(cliGraph.platforms).toEqual(fixture.platforms); + if (fixture.deep && fixture.extension === "ipa") + expect(cliGraph.bridge_candidates).toContainEqual({ + source_path: "Shared/other.js", + native_path: "Extras/Helper.app/Contents/MacOS/Helper", + basis: "javascript-and-native-content", + }); + const call = await cli.sharedMcpCall?.(); + if (call === undefined) throw new Error("Missing production MCP session"); + const reply = await call("project_apple_application_graph", input); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const mcpGraph = appleApplicationProjectionResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + expect(mcpGraph).toEqual(cliGraph); + expect(Buffer.compare(await readFile(path), Buffer.from(bytes))).toBe(0); + }, +);