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 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-08 13:52:18 +00:00
parent 79e5a3923d
commit a079af297b
2 changed files with 64 additions and 4 deletions

View File

@ -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"),
);
});
});

View File

@ -48,10 +48,15 @@ export const analyzeAppleAssetCatalogs = async (input: {
sha256: string;
records: ReturnType<typeof parseAppleAssetCatalogRecords>;
}[] = [];
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
? [