From a079af297bb454417dfd3d89b0037397554ad83e Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:52:18 +0000 Subject: [PATCH] fix(apple): keep skipped catalog entries from reading as absence A symlink or directory named Assets.car is skipped by the non-following directory reader. When no regular catalog remains, fail with the skipped paths instead of reporting an empty inventory. When catalogs were read, list the skipped entries in the limitations. The absence limitation now says that symlinks are not followed. Co-Authored-By: Claude Opus 5.5 --- .../apple/AppleAssetCatalogAnalysis.test.ts | 47 ++++++++++++++++++- .../apple/AppleAssetCatalogAnalysis.ts | 21 ++++++++- 2 files changed, 64 insertions(+), 4 deletions(-) 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 ? [