237 lines
7.8 KiB
TypeScript
237 lines
7.8 KiB
TypeScript
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),
|
|
};
|
|
}
|