diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts index 88af6ce4da..1528df7fe9 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts @@ -64,33 +64,42 @@ describe("Apple asset catalog application workflow", () => { ]); }); - it("reports an app without compiled catalogs as an empty observed inventory", async () => { - const bundle = await createTestTempDirectory("rea-asset-catalog-absent-"); - await mkdir(join(bundle, "Contents", "Resources"), { recursive: true }); - await writeFile(join(bundle, "Contents", "Resources", "icon.icns"), ""); - // Names that merely end in Assets.car are ordinary resources. - await mkdir(join(bundle, "Contents", "Resources", "NotAssets.car")); - await writeFile(join(bundle, "Contents", "Resources", "OldAssets.car"), ""); - const result = await analyzeAppleAssetCatalogs({ - environment: {}, - bundlePath: bundle, - targetSha256: "a".repeat(64), - runAssetUtil: () => { - throw new Error("assetutil must not run without a catalog"); - }, - }); - expect(result).toMatchObject({ - catalogs: [], - total_records: 0, - records: [], - resource_key_matches: [], - next_offset: null, - truncated: false, - }); - expect(result.limitations).toContain( - "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.each([0, 1, 5])( + "reports an app without compiled catalogs as empty at offset %i", + async (offset) => { + const bundle = await createTestTempDirectory("rea-asset-catalog-absent-"); + await mkdir(join(bundle, "Contents", "Resources"), { recursive: true }); + await writeFile(join(bundle, "Contents", "Resources", "icon.icns"), ""); + // Names that merely end in Assets.car are ordinary resources. + await mkdir(join(bundle, "Contents", "Resources", "NotAssets.car")); + await writeFile( + join(bundle, "Contents", "Resources", "OldAssets.car"), + "", + ); + const result = await analyzeAppleAssetCatalogs({ + environment: {}, + bundlePath: bundle, + targetSha256: "a".repeat(64), + page: { offset }, + runAssetUtil: () => { + throw new Error("assetutil must not run without a catalog"); + }, + }); + expect(result).toMatchObject({ + catalogs: [], + total_records: 0, + offset, + limit: 1_000, + records: [], + resource_key_matches: [], + next_offset: null, + truncated: false, + }); + expect(result.limitations).toContain( + "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-"); @@ -137,6 +146,31 @@ describe("Apple asset catalog application workflow", () => { }); }); +it.each([0, 1, 5])( + "keeps an empty assetutil inventory complete at offset %i", + async (offset) => { + const bundle = await createTestTempDirectory("rea-asset-catalog-empty-"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + await writeFile(join(resources, "Assets.car"), "inert catalog fixture"); + const result = await analyzeAppleAssetCatalogs({ + environment: {}, + bundlePath: bundle, + targetSha256: "a".repeat(64), + page: { offset, limit: 2 }, + runAssetUtil: () => Promise.resolve("[]"), + }); + expect(result).toMatchObject({ + total_records: 0, + offset, + limit: 2, + records: [], + next_offset: null, + truncated: false, + }); + }, +); + it.each([1, 2])( "joins NIBArchive keys against %i supplied assetutil renditions", async (count) => { diff --git a/src/domain/apple/appleAssetCatalog.test.ts b/src/domain/apple/appleAssetCatalog.test.ts index 3e2d9d04e7..fdab85399c 100644 --- a/src/domain/apple/appleAssetCatalog.test.ts +++ b/src/domain/apple/appleAssetCatalog.test.ts @@ -104,3 +104,72 @@ describe("compiled Apple asset catalog projection", () => { ); }); }); + +it("reports truncation only when a nonempty inventory has omitted records", () => { + const empty = [{ path: "Assets.car", sha256: "a".repeat(64), records: [] }]; + for (const offset of [0, 1, 5]) { + expect( + projectAppleAssetCatalogPage({ + targetSha256: "c".repeat(64), + catalogs: empty, + offset, + limit: 1, + }), + ).toMatchObject({ + total_records: 0, + records: [], + next_offset: null, + truncated: false, + }); + } + + const records = parseAppleAssetCatalogRecords([ + { Name: "One" }, + { Name: "Two" }, + { Name: "Three" }, + ]); + const catalogs = [{ path: "Assets.car", sha256: "a".repeat(64), records }]; + expect( + projectAppleAssetCatalogPage({ + targetSha256: "c".repeat(64), + catalogs, + offset: 0, + limit: 3, + }), + ).toMatchObject({ + records: records.map((metadata, index) => ({ index, metadata })), + next_offset: null, + truncated: false, + }); + expect( + projectAppleAssetCatalogPage({ + targetSha256: "c".repeat(64), + catalogs, + offset: 0, + limit: 2, + }), + ).toMatchObject({ + records: expect.arrayContaining([ + expect.objectContaining({ index: 0 }), + expect.objectContaining({ index: 1 }), + ]), + next_offset: 2, + truncated: true, + }); + expect( + projectAppleAssetCatalogPage({ + targetSha256: "c".repeat(64), + catalogs, + offset: 1, + limit: 2, + }), + ).toMatchObject({ next_offset: null, truncated: true }); + expect( + projectAppleAssetCatalogPage({ + targetSha256: "c".repeat(64), + catalogs, + offset: 3, + limit: 2, + }), + ).toMatchObject({ records: [], next_offset: null, truncated: true }); +}); diff --git a/src/domain/apple/appleAssetCatalog.ts b/src/domain/apple/appleAssetCatalog.ts index 4d04e14102..1c89957976 100644 --- a/src/domain/apple/appleAssetCatalog.ts +++ b/src/domain/apple/appleAssetCatalog.ts @@ -188,7 +188,7 @@ export const projectAppleAssetCatalogPage = (input: { records, resource_key_matches: resourceKeyMatches, next_offset: next < all.length ? next : null, - truncated: input.offset > 0 || next < all.length, + truncated: records.length !== all.length, limitations: [ "Rendition metadata is reported as emitted by the installed assetutil version; private fields and platform-specific interpretations are not normalized.", "Asset catalog inspection does not decode or extract rendition image bytes.", diff --git a/tests/acceptance/applications/assetCatalogResourceKeys.test.ts b/tests/acceptance/applications/assetCatalogResourceKeys.test.ts index 5df6a1618f..61d2105724 100644 --- a/tests/acceptance/applications/assetCatalogResourceKeys.test.ts +++ b/tests/acceptance/applications/assetCatalogResourceKeys.test.ts @@ -46,7 +46,7 @@ const keyedArchive = (nested: boolean) => }); cliTest.skipIf(process.platform !== "darwin")( - "retains recognized NIBArchive resource keys and provenance through CLI and MCP without a catalog", + "retains resource keys and complete empty inventory through CLI and MCP at a positive offset", async ({ cli }) => { const root = await createTestTempDirectory("rea-asset-resource-keys-"); const { call } = await connectLocalToolsMcp(); @@ -91,13 +91,20 @@ cliTest.skipIf(process.platform !== "darwin")( [field]: "Toolbar", }); const inspected = await cli.run({ - arguments: ["inspect-asset-catalog", app, "--json"], + arguments: ["inspect-asset-catalog", app, "--offset", "1", "--json"], }); expect(inspected.exitCode, inspected.stderr).toBe(0); const result = appleAssetCatalogResultSchema.parse( parseEvidence(inspected.json).normalized_result, ); expect(result.catalogs).toEqual([]); + expect(result).toMatchObject({ + total_records: 0, + offset: 1, + records: [], + next_offset: null, + truncated: false, + }); expect(result.resource_key_matches).toEqual( !recognized ? [] @@ -117,7 +124,7 @@ cliTest.skipIf(process.platform !== "darwin")( ); const opened = await call("open_binary", { path: app }); expect(opened.isError).not.toBe(true); - const mcp = await call("inspect_asset_catalog", {}); + const mcp = await call("inspect_asset_catalog", { offset: 1 }); expect(mcp.isError).not.toBe(true); expect(parseEvidence(mcp.structuredContent).normalized_result).toEqual( result,