diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts index eae3a410e..cda0cb5fc 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts @@ -1,9 +1,10 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { ArtifactReaderFailure } from "../ArtifactReader.js"; import { analyzeAppleAssetCatalogs, collectInterfaceBuilderResourceKeys, @@ -68,7 +69,49 @@ describe("Apple asset catalog application workflow", () => { truncated: false, }); expect(result.limitations).toContain( - "No compiled Assets.car catalog was found in the app bundle; any resource keys are reported as unmatched.", + "No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.", + ); + }); + + it("does not report absence when the only catalog entry is a symlink", async () => { + const bundle = await createTestTempDirectory("rea-asset-catalog-link-"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + await writeFile(join(bundle, "outside.car"), "catalog"); + await symlink("../../outside.car", join(resources, "Assets.car")); + const rejected = await analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + runAssetUtil: () => Promise.resolve("[]"), + }).catch((cause: unknown) => cause); + expect(rejected).toBeInstanceOf(ArtifactReaderFailure); + expect(rejected).toMatchObject({ + reason: "path", + message: expect.stringContaining( + "Contents/Resources/Assets.car (symlink)", + ), + }); + }); + + it("discloses skipped catalog entries beside inspected catalogs", async () => { + const bundle = await createTestTempDirectory("rea-asset-catalog-mixed-"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(join(resources, "Nested"), { recursive: true }); + await writeFile(join(resources, "Assets.car"), "catalog"); + await symlink("../Assets.car", join(resources, "Nested", "Assets.car")); + const result = await analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + runAssetUtil: () => Promise.resolve("[]"), + }); + expect(result.catalogs.map(({ path }) => path)).toEqual([ + "Contents/Resources/Assets.car", + ]); + expect(result.limitations).toContainEqual( + expect.stringContaining("Contents/Resources/Nested/Assets.car (symlink)"), + ); + expect(result.limitations).not.toContainEqual( + expect.stringContaining("No compiled Assets.car catalog"), ); }); }); diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts index 48833764e..65de9ab25 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts @@ -48,10 +48,15 @@ export const analyzeAppleAssetCatalogs = async (input: { sha256: string; records: ReturnType; }[] = []; + const uninspected: string[] = []; let totalMetadataBytes = 0; try { for await (const entry of reader.entries(input.signal)) { - if (entry.kind !== "file" || !entry.path.endsWith("Assets.car")) continue; + if (!entry.path.endsWith("Assets.car")) continue; + if (entry.kind !== "file") { + uninspected.push(`${entry.path} (${entry.kind})`); + continue; + } const inspected = await inspectAssetCatalogEntry({ reader, entry, @@ -69,6 +74,13 @@ export const analyzeAppleAssetCatalogs = async (input: { } finally { await reader.close(); } + // Without any readable catalog, unmatched resource keys would overstate an + // absence that a skipped catalog entry may contradict. + if (catalogs.length === 0 && uninspected.length > 0) + throw new ArtifactReaderFailure( + "path", + `Asset catalog entries are not regular files and were not inspected: ${uninspected.join(", ")}`, + ); const interfaceBuilder = await analyzeInterfaceBuilderBundle({ bundlePath: input.bundlePath, targetSha256: input.targetSha256, @@ -92,9 +104,14 @@ export const analyzeAppleAssetCatalogs = async (input: { // a missing host tool. ...(catalogs.length === 0 ? [ - "No compiled Assets.car catalog was found in the app bundle; any resource keys are reported as unmatched.", + "No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.", ] : []), + ...(uninspected.length === 0 + ? [] + : [ + `Asset catalog entries that are not regular files were not inspected, so resource keys reported as unmatched may resolve there: ${uninspected.join(", ")}`, + ]), "Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred.", ...(interfaceBuilder.graph.truncated ? [