import { describe, expect, it } from "vitest"; import { PrivacyLimitError, scanImageBytes } from "../../src/privacy"; import { jpegFixture, malformedPngLengthFixture, pngFixture, toArrayBuffer, webpFixture, } from "../fixtures/images"; describe("bounded image metadata scanner", () => { it("extracts JPEG EXIF, IPTC, XMP, JFIF, ICC and provenance", async () => { const result = await scan("camera.jpg", "image/jpeg", jpegFixture()); expect(result).toMatchObject({ width: 3, height: 2, orientation: 1, animated: false, multiImage: false, deepSupported: true, cleanable: true, }); expect(result.coverage.projectScanner).toBe("complete"); expect(result.findings).toEqual( expect.arrayContaining([ expect.objectContaining({ category: "location" }), expect.objectContaining({ category: "identity" }), expect.objectContaining({ category: "timestamp" }), expect.objectContaining({ category: "device" }), expect.objectContaining({ category: "colour-profile" }), expect.objectContaining({ category: "provenance" }), ]), ); expect(result.blocks.map((block) => block.kind)).toEqual( expect.arrayContaining(["APP0", "APP1", "APP2", "APP11", "APP13", "COM"]), ); }); it("extracts PNG text, compressed text, EXIF, XMP, ICC and private chunks", async () => { const result = await scan("fixture.png", "image/png", pngFixture()); expect(result).toMatchObject({ width: 2, height: 1, orientation: 1, cleanable: true, }); expect(result.coverage.projectScanner).toBe("complete"); expect( result.findings.some((finding) => finding.value.includes("Alice PNG")), ).toBe(true); expect( result.findings.some((finding) => finding.value.includes("PNG Alice")), ).toBe(true); expect(result.blocks.map((block) => block.kind)).toEqual( expect.arrayContaining(["tEXt", "zTXt", "iTXt", "eXIf", "iCCP", "vpAg"]), ); }); it("extracts WebP EXIF, XMP, ICC and extended dimensions", async () => { const result = await scan("fixture.webp", "image/webp", webpFixture()); expect(result).toMatchObject({ width: 4, height: 3, orientation: 1, animated: false, cleanable: true, }); expect(result.coverage.projectScanner).toBe("complete"); expect(result.findings).toEqual( expect.arrayContaining([ expect.objectContaining({ category: "location" }), expect.objectContaining({ category: "identity" }), expect.objectContaining({ category: "colour-profile" }), ]), ); }); it("makes animated images inspect-only", async () => { const png = await scan( "animated.png", "image/png", pngFixture({ animated: true }), ); const webp = await scan( "animated.webp", "image/webp", webpFixture({ animated: true }), ); expect(png.animated).toBe(true); expect(webp.animated).toBe(true); expect(png.cleanable).toBe(false); expect(webp.cleanable).toBe(false); }); it("reports trailing data rather than hiding it", async () => { const jpeg = await scan( "tail.jpg", "image/jpeg", jpegFixture({ trailing: true }), ); const png = await scan( "tail.png", "image/png", pngFixture({ trailing: true }), ); expect( jpeg.findings.some((finding) => finding.label === "Trailing data"), ).toBe(true); expect( png.findings.some((finding) => finding.label === "Trailing data"), ).toBe(true); }); it("stops malformed chunks and TIFF cycles and refuses clean-copy eligibility", async () => { const malformed = await scan( "malformed.png", "image/png", malformedPngLengthFixture(), ); const cyclic = await scan( "cycle.png", "image/png", pngFixture({ cycleTiff: true }), ); expect(malformed.coverage.projectScanner).toBe("partial"); expect(malformed.cleanable).toBe(false); expect(cyclic.coverage.projectScanner).toBe("partial"); expect(cyclic.warnings.join(" ")).toMatch(/cycle/iu); expect(cyclic.cleanable).toBe(false); }); it("bounds compressed metadata and finding values", async () => { const image = pngFixture({ inflatedCommentBytes: 1024 }); const result = await scanImageBytes(input("bomb.png", "image/png", image), { maxInflatedMetadataBytes: 32, maxFindingValueChars: 24, }); expect(result.coverage.projectScanner).toBe("partial"); expect(result.warnings.join(" ")).toMatch(/limit|fully read/iu); expect(result.findings.every((finding) => finding.value.length <= 25)).toBe( true, ); }); it("bounds chunk counts and decoded dimensions", async () => { const image = pngFixture(); const tooManyChunks = await scanImageBytes( input("chunks.png", "image/png", image), { maxMetadataBlocks: 3 }, ); const tooManyPixels = await scanImageBytes( input("pixels.png", "image/png", image), { maxPixels: 1 }, ); expect(tooManyChunks.coverage.projectScanner).toBe("partial"); expect(tooManyChunks.cleanable).toBe(false); expect(tooManyPixels.cleanable).toBe(false); expect(tooManyPixels.warnings.join(" ")).toMatch(/processing limit/iu); }); it("bounds aggregate normalized finding text", async () => { const image = pngFixture(); const result = await scanImageBytes(input("text.png", "image/png", image), { maxFindingTextChars: 100, }); expect(result.coverage.projectScanner).toBe("partial"); expect(result.coverage.notes.join(" ")).toMatch(/text limit/iu); expect(result.cleanable).toBe(false); }); it("treats invalid PNG CRCs as partial coverage", async () => { const image = pngFixture().slice(); image[image.length - 1] = (image[image.length - 1] ?? 0) ^ 0xff; const result = await scan("crc.png", "image/png", image); expect(result.coverage.projectScanner).toBe("partial"); expect(result.warnings.join(" ")).toMatch(/invalid CRC/iu); expect(result.cleanable).toBe(false); }); it("contains malformed HEIF-family input in the secondary adapter", async () => { const malformedHeic = Uint8Array.from([ 0, 0, 0, 16, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0, 0, 0, 0, ]); const result = await scan("broken.heic", "image/heic", malformedHeic); expect(result.identity.detectedKind).toBe("heic"); expect(["failed", "unsupported", "partial"]).toContain( result.coverage.secondaryScanner, ); expect(result.cleanable).toBe(false); }); it("rejects dishonest declared sizes and file limits before parsing", async () => { const image = pngFixture(); await expect( scanImageBytes({ ...input("wrong.png", "image/png", image), size: image.length + 1, }), ).rejects.toThrow(/does not match/iu); await expect( scanImageBytes(input("large.png", "image/png", image), { maxFileBytes: image.length - 1, }), ).rejects.toBeInstanceOf(PrivacyLimitError); }); it("keeps non-image formats at inventory-only coverage", async () => { const pdf = Uint8Array.from([ 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37, ]); const result = await scan("document.pdf", "application/pdf", pdf); expect(result.identity.detectedKind).toBe("pdf"); expect(result.deepSupported).toBe(false); expect(result.cleanable).toBe(false); expect(result.coverage.projectScanner).toBe("unsupported"); }); }); async function scan(name: string, type: string, bytes: Uint8Array) { return scanImageBytes(input(name, type, bytes)); } function input(name: string, type: string, bytes: Uint8Array) { return { id: `fixture-${name}`, name, claimedType: type, size: bytes.byteLength, lastModified: 0, bytes: toArrayBuffer(bytes), }; }