diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts index 1c27fd3592..b7da4abcd1 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts @@ -659,3 +659,34 @@ it.each(["Toolbar", "MissingToolbar"])( expect(result.resource_key_coverage.status).toBe("complete"); }, ); + +it("preserves every own JSON metadata field through catalog inspection and page projection", async () => { + const bundle = await createTestTempDirectory("rea-asset-metadata-members-"); + const catalog = Buffer.from("inert catalog for the output-decoding boundary"); + await writeFile(join(bundle, "Assets.car"), catalog); + // Authored producer text exercises the accepted metadata contract; it is not + // evidence that native assetutil emits these additional field names. + const output = + '[{"Name":"Toolbar","RenditionName":"Toolbar@2x","__proto__":{"Name":"metadata, not an inherited asset name"},"constructor":"retained constructor metadata","toString":false,"Unknown":{"__proto__":{"value":0}},"Empty":"","Zero":0,"Absent":null}]'; + const result = await analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + environment: {}, + runAssetUtil: async (path) => { + expect(path).toBe(join(bundle, "Assets.car")); + return output; + }, + }); + const expected: unknown = JSON.parse(output); + expect(result.records.map(({ metadata }) => metadata)).toEqual(expected); + expect(result.records[0]).toMatchObject({ + asset_name: "Toolbar", + rendition_name: "Toolbar@2x", + catalog_path: "Assets.car", + catalog_sha256: createHash("sha256").update(catalog).digest("hex"), + }); + const metadata = result.records[0]?.metadata; + if (metadata === undefined) throw new Error("missing catalog metadata"); + expect(Object.hasOwn(metadata, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(metadata)).toBe(Object.prototype); +}); diff --git a/src/domain/apple/appleAssetCatalog.ts b/src/domain/apple/appleAssetCatalog.ts index b4811eec3c..9c70549d08 100644 --- a/src/domain/apple/appleAssetCatalog.ts +++ b/src/domain/apple/appleAssetCatalog.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { bundleResourceBoundResultSchema } from "./bundleResourceRoot.js"; -import { jsonValueSchema } from "../jsonValue.js"; +import { jsonObjectSchema, jsonValueSchema } from "../jsonValue.js"; import { AnalysisOutputError } from "../analysisErrorCore.js"; import { artifactInventoryCoverageSchema, @@ -51,7 +51,7 @@ const recordSchema = z.strictObject({ kind: z.enum(["catalog", "rendition"]), asset_name: z.string().nullable(), rendition_name: z.string().nullable(), - metadata: z.record(z.string(), jsonValueSchema), + metadata: jsonObjectSchema, }); const resourceKeyMatchSchema = z.strictObject({ @@ -116,7 +116,7 @@ export const parseAppleAssetCatalogRecords = ( "assetutil output must be a JSON array", ); return value.map((record, index) => { - const parsed = z.record(z.string(), jsonValueSchema).safeParse(record); + const parsed = jsonObjectSchema.safeParse(record); if (!parsed.success) throw new AnalysisOutputError( "inspect_asset_catalog", diff --git a/tests/boundary/filesystem/keyedArchiveScalarStructure.test.ts b/tests/boundary/filesystem/keyedArchiveScalarStructure.test.ts index f7e6c5adc0..b1a8c58803 100644 --- a/tests/boundary/filesystem/keyedArchiveScalarStructure.test.ts +++ b/tests/boundary/filesystem/keyedArchiveScalarStructure.test.ts @@ -43,7 +43,7 @@ it("keeps literal wrapper-shaped dictionaries as real root maps", () => { } }); -it("retains own binary dictionary facts when a prototype key changes the decoder prototype", () => { +it("retains an own binary dictionary prototype key without changing the decoded prototype", () => { const object = Object.fromEntries([ ["__proto__", { inherited: "not an own fact" }], ["payload", "retained"], @@ -63,8 +63,14 @@ it("retains own binary dictionary facts when a prototype key changes the decoder kind: "dictionary", value: { payload: "retained" }, }); - expect(graph.objects[1]?.value).toEqual({ payload: "retained" }); - expect(graph.limitations).toContainEqual( + const value = graph.objects[1]?.value; + expect(value).toEqual(object); + if (value === null || typeof value !== "object") + throw new Error("Expected a decoded dictionary"); + expect(Object.hasOwn(value, "__proto__")).toBe(true); + expect(Object.hasOwn(value, "inherited")).toBe(false); + expect(Object.getPrototypeOf(value)).toBe(Object.prototype); + expect(graph.limitations).not.toContainEqual( expect.stringContaining("__proto__"), ); });