Release Privacy Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { pngFixture, tiffFixture } from "../fixtures/images";
|
||||
|
||||
const ORIGIN = "http://127.0.0.1:4173";
|
||||
async function localOnly(page: Page) {
|
||||
const external: string[] = [];
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== ORIGIN) {
|
||||
external.push(url.href);
|
||||
await route.abort();
|
||||
} else await route.continue();
|
||||
});
|
||||
return external;
|
||||
}
|
||||
|
||||
test("runs from a nested path without external requests", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/privacy/");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Privacy Tools" }),
|
||||
).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("serves the release identity and hardened headers", async ({
|
||||
request,
|
||||
}) => {
|
||||
const index = await request.get("/deep/nested/privacy/");
|
||||
expect(index.ok()).toBe(true);
|
||||
expect(index.headers()["content-security-policy"]).toContain(
|
||||
"default-src 'self'",
|
||||
);
|
||||
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
|
||||
const manifest = await request.get("/deep/nested/privacy/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.privacy-tools",
|
||||
version: "0.1.0",
|
||||
entry: "./",
|
||||
});
|
||||
});
|
||||
|
||||
test("inspects and independently verifies a re-encoded PNG without network access", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/privacy/");
|
||||
|
||||
await page.locator('input[type="file"]').setInputFiles([
|
||||
{
|
||||
name: "metadata-fixture.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(pngFixture()),
|
||||
},
|
||||
{
|
||||
name: "inventory-only.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF-1.7\n% fixture"),
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Batch inventory" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Alice PNG").first()).toBeVisible();
|
||||
await expect(page.getByText("PNG Alice").first()).toBeVisible();
|
||||
await expect(page.getByText("inventory-only.pdf").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Re-encode & verify" }).click();
|
||||
await expect(page.getByText("Mandatory output re-scan")).toBeVisible();
|
||||
await expect(page.locator(".verification")).not.toHaveClass(/is-failed/u);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Download re-encoded output" }),
|
||||
).toBeEnabled();
|
||||
|
||||
const imageDownload = page.waitForEvent("download");
|
||||
await page
|
||||
.getByRole("button", { name: "Download re-encoded output" })
|
||||
.click();
|
||||
expect((await imageDownload).suggestedFilename()).toBe(
|
||||
"metadata-fixture.clean.png",
|
||||
);
|
||||
|
||||
const reportDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download JSON report" }).click();
|
||||
expect((await reportDownload).suggestedFilename()).toBe(
|
||||
"privacy-tools-report.json",
|
||||
);
|
||||
const archiveDownload = page.waitForEvent("download");
|
||||
await page
|
||||
.getByRole("button", { name: "Download 1 re-encoded image + report" })
|
||||
.click();
|
||||
expect((await archiveDownload).suggestedFilename()).toBe(
|
||||
"privacy-tools-re-encoded-images.zip",
|
||||
);
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("normalizes EXIF orientation while re-encoding JPEG pixels", async ({
|
||||
page,
|
||||
}) => {
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/privacy/");
|
||||
const encoded = await page.evaluate(async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 2;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Missing test canvas");
|
||||
context.fillStyle = "#ff0000";
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
context.fillStyle = "#0000ff";
|
||||
context.fillRect(1, 0, 1, 1);
|
||||
const blob = await new Promise<Blob>((resolve, reject) =>
|
||||
canvas.toBlob(
|
||||
(value) =>
|
||||
value ? resolve(value) : reject(new Error("JPEG encode failed")),
|
||||
"image/jpeg",
|
||||
0.95,
|
||||
),
|
||||
);
|
||||
return [...new Uint8Array(await blob.arrayBuffer())];
|
||||
});
|
||||
const jpeg = addExifOrientation(Buffer.from(encoded), 6);
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: "oriented.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
buffer: jpeg,
|
||||
});
|
||||
await expect(page.getByText("2 × 1 pixels")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Re-encode & verify" }).click();
|
||||
await expect(page.getByText("Mandatory output re-scan")).toBeVisible();
|
||||
await expect(page.locator(".verification")).not.toHaveClass(/is-failed/u);
|
||||
await expect(page.getByText("Normalized", { exact: true })).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
|
||||
function addExifOrientation(jpeg: Buffer, orientation: number): Buffer {
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from("Exif\0\0", "binary"),
|
||||
Buffer.from(tiffFixture({ orientation })),
|
||||
]);
|
||||
const segment = Buffer.from([
|
||||
0xff,
|
||||
0xe1,
|
||||
((payload.length + 2) >>> 8) & 0xff,
|
||||
(payload.length + 2) & 0xff,
|
||||
]);
|
||||
return Buffer.concat([
|
||||
jpeg.subarray(0, 2),
|
||||
segment,
|
||||
payload,
|
||||
jpeg.subarray(2),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../../src/App";
|
||||
|
||||
describe("Privacy Tools", () => {
|
||||
it("renders the local workbench and standard shell", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("Not found", { status: 404 })),
|
||||
);
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Privacy Tools" }),
|
||||
).toBeVisible();
|
||||
expect(await screen.findByText("Local & ephemeral")).toBeVisible();
|
||||
expect(screen.getAllByText(/No anonymity promise/iu)).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Vendored
+253
@@ -0,0 +1,253 @@
|
||||
import { crc32 } from "@add-ideas/toolbox-helpers";
|
||||
import { zlibSync } from "fflate";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function jpegFixture(options: { trailing?: boolean } = {}): Uint8Array {
|
||||
const xmp = encoder.encode(
|
||||
"http://ns.adobe.com/xap/1.0/\0" +
|
||||
'<?xpacket begin=""><x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/"><rdf:RDF><rdf:Description><dc:creator>Alice Example</dc:creator><exif:GPSLatitude>48.1 N</exif:GPSLatitude><xmp:CreatorTool>Fixture Camera</xmp:CreatorTool><xmpMM:DocumentID>doc-123</xmpMM:DocumentID></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>',
|
||||
);
|
||||
const jfif = concat(
|
||||
encoder.encode("JFIF\0"),
|
||||
bytes(1, 2, 0),
|
||||
u16be(72),
|
||||
u16be(72),
|
||||
bytes(0, 0),
|
||||
);
|
||||
const iptc = concat(
|
||||
iptcDataset(80, "Alice Reporter"),
|
||||
iptcDataset(90, "Berlin"),
|
||||
iptcDataset(120, "Private caption"),
|
||||
);
|
||||
const photoshop = concat(
|
||||
encoder.encode("Photoshop 3.0\0"),
|
||||
encoder.encode("8BIM"),
|
||||
u16be(0x0404),
|
||||
bytes(0, 0),
|
||||
u32be(iptc.byteLength),
|
||||
iptc,
|
||||
iptc.byteLength % 2 ? bytes(0) : bytes(),
|
||||
);
|
||||
const body = concat(
|
||||
bytes(0xff, 0xd8),
|
||||
jpegSegment(0xe0, jfif),
|
||||
jpegSegment(0xe1, concat(encoder.encode("Exif\0\0"), tiffFixture())),
|
||||
jpegSegment(0xe1, xmp),
|
||||
jpegSegment(
|
||||
0xe2,
|
||||
concat(
|
||||
encoder.encode("ICC_PROFILE\0"),
|
||||
bytes(1, 1),
|
||||
encoder.encode("icc"),
|
||||
),
|
||||
),
|
||||
jpegSegment(0xeb, encoder.encode("jumb/c2pa test manifest")),
|
||||
jpegSegment(0xed, photoshop),
|
||||
jpegSegment(0xfe, encoder.encode("private jpeg comment")),
|
||||
jpegSegment(0xc0, bytes(8, 0, 2, 0, 3, 1, 1, 0x11, 0)),
|
||||
bytes(0xff, 0xda, 0xff, 0xd9),
|
||||
);
|
||||
return options.trailing ? concat(body, encoder.encode("hidden")) : body;
|
||||
}
|
||||
|
||||
export function pngFixture(
|
||||
options: {
|
||||
cycleTiff?: boolean;
|
||||
inflatedCommentBytes?: number;
|
||||
animated?: boolean;
|
||||
trailing?: boolean;
|
||||
} = {},
|
||||
): Uint8Array {
|
||||
const width = 2;
|
||||
const height = 1;
|
||||
const ihdr = concat(u32be(width), u32be(height), bytes(8, 6, 0, 0, 0));
|
||||
const xmp = encoder.encode(
|
||||
"XML:com.adobe.xmp\0\0\0\0\0" +
|
||||
'<rdf:Description xmlns:rdf="urn:rdf"><dc:creator xmlns:dc="urn:dc">PNG Alice</dc:creator><xmp:CreatorTool xmlns:xmp="urn:xmp">PNG Fixture</xmp:CreatorTool></rdf:Description>',
|
||||
);
|
||||
const inflated = encoder.encode(
|
||||
"x".repeat(options.inflatedCommentBytes ?? 32),
|
||||
);
|
||||
const scanline = bytes(0, 255, 0, 0, 255, 0, 0, 255, 255);
|
||||
const chunks = [
|
||||
pngChunk("IHDR", ihdr),
|
||||
pngChunk("tEXt", encoder.encode("Author\0Alice PNG")),
|
||||
pngChunk(
|
||||
"zTXt",
|
||||
concat(encoder.encode("Comment\0"), bytes(0), zlibSync(inflated)),
|
||||
),
|
||||
pngChunk("iTXt", xmp),
|
||||
pngChunk("eXIf", tiffFixture({ cycle: options.cycleTiff })),
|
||||
pngChunk(
|
||||
"iCCP",
|
||||
concat(
|
||||
encoder.encode("fixture profile\0"),
|
||||
bytes(0),
|
||||
zlibSync(encoder.encode("ICC profile bytes")),
|
||||
),
|
||||
),
|
||||
pngChunk("vpAg", encoder.encode("private ancillary data")),
|
||||
...(options.animated ? [pngChunk("acTL", concat(u32be(2), u32be(0)))] : []),
|
||||
pngChunk("IDAT", zlibSync(scanline)),
|
||||
pngChunk("IEND", bytes()),
|
||||
];
|
||||
const image = concat(
|
||||
bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
|
||||
...chunks,
|
||||
);
|
||||
return options.trailing
|
||||
? concat(image, encoder.encode("trailing secret"))
|
||||
: image;
|
||||
}
|
||||
|
||||
export function webpFixture(options: { animated?: boolean } = {}): Uint8Array {
|
||||
const vp8x = bytes(options.animated ? 0x02 : 0, 0, 0, 0, 3, 0, 0, 2, 0, 0);
|
||||
const dimensions = 3 | (2 << 14);
|
||||
const vp8l = concat(bytes(0x2f), u32le(dimensions));
|
||||
const xmp = encoder.encode(
|
||||
'<rdf:Description xmlns:rdf="urn:rdf"><dc:creator xmlns:dc="urn:dc">WebP Alice</dc:creator><exif:GPSLongitude xmlns:exif="urn:exif">13.4 E</exif:GPSLongitude></rdf:Description>',
|
||||
);
|
||||
const chunks = concat(
|
||||
riffChunk("VP8X", vp8x),
|
||||
riffChunk("VP8L", vp8l),
|
||||
riffChunk("EXIF", concat(encoder.encode("Exif\0\0"), tiffFixture())),
|
||||
riffChunk("XMP ", xmp),
|
||||
riffChunk("ICCP", encoder.encode("ICC profile bytes")),
|
||||
...(options.animated ? [riffChunk("ANIM", bytes(0, 0, 0, 0, 0, 0))] : []),
|
||||
);
|
||||
return concat(
|
||||
encoder.encode("RIFF"),
|
||||
u32le(chunks.byteLength + 4),
|
||||
encoder.encode("WEBP"),
|
||||
chunks,
|
||||
);
|
||||
}
|
||||
|
||||
export function tiffFixture(
|
||||
options: { cycle?: boolean; orientation?: number } = {},
|
||||
): Uint8Array {
|
||||
const make = encoder.encode("CameraCo\0");
|
||||
const date = encoder.encode("2026:09:01 12:34:56\0");
|
||||
const gpsDate = encoder.encode("2026:09:01\0");
|
||||
const mainOffset = 8;
|
||||
const mainEntries = 4;
|
||||
const mainEnd = mainOffset + 2 + mainEntries * 12;
|
||||
const dataStart = mainEnd + 4;
|
||||
const makeOffset = dataStart;
|
||||
const dateOffset = makeOffset + make.byteLength;
|
||||
const gpsOffset = dateOffset + date.byteLength;
|
||||
const gpsEntries = 2;
|
||||
const gpsEnd = gpsOffset + 2 + gpsEntries * 12;
|
||||
const gpsDateOffset = gpsEnd + 4;
|
||||
const result = new Uint8Array(gpsDateOffset + gpsDate.byteLength);
|
||||
const view = new DataView(result.buffer);
|
||||
result.set(bytes(0x49, 0x49), 0);
|
||||
view.setUint16(2, 42, true);
|
||||
view.setUint32(4, mainOffset, true);
|
||||
view.setUint16(mainOffset, mainEntries, true);
|
||||
writeIfdEntry(view, mainOffset + 2, 0x010f, 2, make.byteLength, makeOffset);
|
||||
writeIfdEntry(view, mainOffset + 14, 0x0112, 3, 1, options.orientation ?? 1);
|
||||
writeIfdEntry(view, mainOffset + 26, 0x0132, 2, date.byteLength, dateOffset);
|
||||
writeIfdEntry(view, mainOffset + 38, 0x8825, 4, 1, gpsOffset);
|
||||
view.setUint32(mainEnd, options.cycle ? mainOffset : 0, true);
|
||||
result.set(make, makeOffset);
|
||||
result.set(date, dateOffset);
|
||||
view.setUint16(gpsOffset, gpsEntries, true);
|
||||
writeIfdEntry(view, gpsOffset + 2, 1, 2, 2, 0x4e);
|
||||
writeIfdEntry(view, gpsOffset + 14, 29, 2, gpsDate.byteLength, gpsDateOffset);
|
||||
view.setUint32(gpsEnd, 0, true);
|
||||
result.set(gpsDate, gpsDateOffset);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function malformedPngLengthFixture(): Uint8Array {
|
||||
const image = pngFixture();
|
||||
const malformed = image.slice();
|
||||
new DataView(malformed.buffer).setUint32(8, 0xfffffff0, false);
|
||||
return malformed;
|
||||
}
|
||||
|
||||
export function toArrayBuffer(value: Uint8Array): ArrayBuffer {
|
||||
return value.slice().buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
function jpegSegment(marker: number, payload: Uint8Array): Uint8Array {
|
||||
return concat(bytes(0xff, marker), u16be(payload.byteLength + 2), payload);
|
||||
}
|
||||
|
||||
function iptcDataset(dataset: number, value: string): Uint8Array {
|
||||
const encoded = encoder.encode(value);
|
||||
return concat(bytes(0x1c, 2, dataset), u16be(encoded.byteLength), encoded);
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Uint8Array): Uint8Array {
|
||||
const typeBytes = encoder.encode(type);
|
||||
const checksumInput = concat(typeBytes, data);
|
||||
return concat(
|
||||
u32be(data.byteLength),
|
||||
checksumInput,
|
||||
u32be(crc32(checksumInput, 16 * 1024 * 1024)),
|
||||
);
|
||||
}
|
||||
|
||||
function riffChunk(type: string, data: Uint8Array): Uint8Array {
|
||||
return concat(
|
||||
encoder.encode(type),
|
||||
u32le(data.byteLength),
|
||||
data,
|
||||
data.byteLength % 2 ? bytes(0) : bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
function writeIfdEntry(
|
||||
view: DataView,
|
||||
offset: number,
|
||||
tag: number,
|
||||
type: number,
|
||||
count: number,
|
||||
value: number,
|
||||
): void {
|
||||
view.setUint16(offset, tag, true);
|
||||
view.setUint16(offset + 2, type, true);
|
||||
view.setUint32(offset + 4, count, true);
|
||||
if (type === 3 && count === 1) view.setUint16(offset + 8, value, true);
|
||||
else view.setUint32(offset + 8, value, true);
|
||||
}
|
||||
|
||||
function concat(...parts: readonly Uint8Array[]): Uint8Array {
|
||||
const size = parts.reduce((total, part) => total + part.byteLength, 0);
|
||||
const result = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function bytes(...values: readonly number[]): Uint8Array {
|
||||
return Uint8Array.from(values);
|
||||
}
|
||||
|
||||
function u16be(value: number): Uint8Array {
|
||||
return bytes((value >>> 8) & 0xff, value & 0xff);
|
||||
}
|
||||
|
||||
function u32be(value: number): Uint8Array {
|
||||
return bytes(
|
||||
(value >>> 24) & 0xff,
|
||||
(value >>> 16) & 0xff,
|
||||
(value >>> 8) & 0xff,
|
||||
value & 0xff,
|
||||
);
|
||||
}
|
||||
|
||||
function u32le(value: number): Uint8Array {
|
||||
return bytes(
|
||||
value & 0xff,
|
||||
(value >>> 8) & 0xff,
|
||||
(value >>> 16) & 0xff,
|
||||
(value >>> 24) & 0xff,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
detectKind,
|
||||
fileExtension,
|
||||
inventoryIdentity,
|
||||
} from "../../src/privacy";
|
||||
import { jpegFixture, pngFixture, webpFixture } from "../fixtures/images";
|
||||
|
||||
describe("file identity", () => {
|
||||
it("detects supported containers from bytes", () => {
|
||||
expect(detectKind(jpegFixture())).toBe("jpeg");
|
||||
expect(detectKind(pngFixture())).toBe("png");
|
||||
expect(detectKind(webpFixture())).toBe("webp");
|
||||
expect(detectKind(Uint8Array.from([0x25, 0x50, 0x44, 0x46, 0x2d]))).toBe(
|
||||
"pdf",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes leaf extensions without trusting paths", () => {
|
||||
expect(fileExtension("folder\\PHOTO.JPEG")).toBe("jpeg");
|
||||
expect(fileExtension(".hidden")).toBe("");
|
||||
expect(fileExtension("no-extension")).toBe("");
|
||||
});
|
||||
|
||||
it("reports claimed and extension mismatches", () => {
|
||||
const identity = inventoryIdentity(
|
||||
"portrait.jpg",
|
||||
"image/jpeg",
|
||||
pngFixture(),
|
||||
);
|
||||
expect(identity).toMatchObject({
|
||||
detectedKind: "png",
|
||||
detectedType: "image/png",
|
||||
typeMatch: "mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer an unknown payload from its extension", () => {
|
||||
const identity = inventoryIdentity(
|
||||
"claim.png",
|
||||
"image/png",
|
||||
Uint8Array.from([1, 2, 3]),
|
||||
);
|
||||
expect(identity.detectedKind).toBe("unknown");
|
||||
expect(identity.typeMatch).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertBatchFiles,
|
||||
PrivacyLimitError,
|
||||
resolveLimits,
|
||||
} from "../../src/privacy";
|
||||
|
||||
describe("privacy resource limits", () => {
|
||||
it("rejects too many, oversized and collectively oversized files", () => {
|
||||
expect(() =>
|
||||
assertBatchFiles([{ size: 1 }, { size: 1 }], {
|
||||
...resolveLimits(),
|
||||
maxFiles: 1,
|
||||
}),
|
||||
).toThrow(PrivacyLimitError);
|
||||
expect(() =>
|
||||
assertBatchFiles([{ size: 11 }], {
|
||||
...resolveLimits(),
|
||||
maxFileBytes: 10,
|
||||
}),
|
||||
).toThrow(PrivacyLimitError);
|
||||
expect(() =>
|
||||
assertBatchFiles([{ size: 6 }, { size: 6 }], {
|
||||
...resolveLimits(),
|
||||
maxFileBytes: 10,
|
||||
maxBatchBytes: 10,
|
||||
}),
|
||||
).toThrow(PrivacyLimitError);
|
||||
});
|
||||
|
||||
it("rejects invalid limit overrides", () => {
|
||||
expect(() => resolveLimits({ maxFiles: 0 })).toThrow(
|
||||
/positive safe integer/iu,
|
||||
);
|
||||
expect(() => resolveLimits({ maxFileBytes: Number.NaN })).toThrow(
|
||||
/positive safe integer/iu,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { unzipSync } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildSanitizationReport,
|
||||
createBatchArchive,
|
||||
createBatchReport,
|
||||
serializeReport,
|
||||
type ImageScanResult,
|
||||
type MetadataFinding,
|
||||
} from "../../src/privacy";
|
||||
|
||||
const sensitive: MetadataFinding = {
|
||||
id: "gps-1",
|
||||
category: "location",
|
||||
risk: "sensitive",
|
||||
source: "EXIF",
|
||||
label: "GPS Latitude",
|
||||
value: "48.1 N",
|
||||
};
|
||||
|
||||
describe("sanitization and batch reports", () => {
|
||||
it("marks a complete sensitive-free output verified and records removals", () => {
|
||||
const source = result({ findings: [sensitive] });
|
||||
const output = result({ id: "clean", name: "clean.png", findings: [] });
|
||||
const report = buildSanitizationReport(
|
||||
source,
|
||||
output,
|
||||
"clean.png",
|
||||
"image/png",
|
||||
20,
|
||||
comparison(true),
|
||||
);
|
||||
expect(report.status).toBe("verified");
|
||||
expect(report.removed).toEqual([sensitive]);
|
||||
expect(report.preserved).toEqual([]);
|
||||
expect(report.orientationNormalized).toBe(true);
|
||||
expect(report.disclaimer).toMatch(/not an anonymity guarantee/iu);
|
||||
});
|
||||
|
||||
it("fails a partial or sensitive output re-scan", () => {
|
||||
const source = result({ findings: [sensitive] });
|
||||
const output = result({
|
||||
id: "clean",
|
||||
findings: [sensitive],
|
||||
coverage: {
|
||||
projectScanner: "partial",
|
||||
secondaryScanner: "complete",
|
||||
notes: [],
|
||||
},
|
||||
});
|
||||
const report = buildSanitizationReport(
|
||||
source,
|
||||
output,
|
||||
"clean.png",
|
||||
"image/png",
|
||||
20,
|
||||
comparison(false),
|
||||
);
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.preserved).toEqual([sensitive]);
|
||||
expect(report.incomplete).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("reports an unknown browser-generated output chunk as a warning", () => {
|
||||
const source = result();
|
||||
const output = result({
|
||||
id: "clean",
|
||||
findings: [
|
||||
{
|
||||
id: "private-1",
|
||||
category: "unknown",
|
||||
risk: "context",
|
||||
source: "PNG",
|
||||
label: "Private chunk deBG",
|
||||
value: "16 bytes",
|
||||
},
|
||||
],
|
||||
blocks: [{ kind: "deBG", offset: 40, length: 16 }],
|
||||
});
|
||||
const report = buildSanitizationReport(
|
||||
source,
|
||||
output,
|
||||
"clean.png",
|
||||
"image/png",
|
||||
20,
|
||||
comparison(true),
|
||||
);
|
||||
expect(report.status).toBe("warning");
|
||||
expect(report.generated).toHaveLength(1);
|
||||
expect(report.incomplete.join(" ")).toContain("deBG");
|
||||
});
|
||||
|
||||
it("fails a changed decoded sample for lossless PNG", () => {
|
||||
const report = buildSanitizationReport(
|
||||
result(),
|
||||
result({ id: "clean" }),
|
||||
"clean.png",
|
||||
"image/png",
|
||||
20,
|
||||
comparison(false),
|
||||
);
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.incomplete.join(" ")).toMatch(/pixel sample/iu);
|
||||
});
|
||||
|
||||
it("produces deterministic, safe JSON and a stored batch ZIP", async () => {
|
||||
const source = result({ name: "../private.png", findings: [sensitive] });
|
||||
const output = result({ id: "clean", name: "private.clean.png" });
|
||||
const report = buildSanitizationReport(
|
||||
source,
|
||||
output,
|
||||
"../same.png",
|
||||
"image/png",
|
||||
3,
|
||||
comparison(true),
|
||||
);
|
||||
const assets = [
|
||||
{ blob: new Blob([Uint8Array.of(1, 2, 3)]), report },
|
||||
{ blob: new Blob([Uint8Array.of(4, 5, 6)]), report },
|
||||
];
|
||||
const generatedAt = "2026-09-01T00:00:00.000Z";
|
||||
const batch = createBatchReport([source], assets, generatedAt);
|
||||
const json = serializeReport(batch);
|
||||
expect(JSON.parse(json)).toMatchObject({ schemaVersion: 1, generatedAt });
|
||||
expect(json).toContain("report may itself contain sensitive");
|
||||
|
||||
const archive = await createBatchArchive([source], assets, generatedAt);
|
||||
const entries = unzipSync(new Uint8Array(await archive.arrayBuffer()));
|
||||
expect(Object.keys(entries).sort()).toEqual([
|
||||
"images/_same-2.png",
|
||||
"images/_same.png",
|
||||
"privacy-tools-report.json",
|
||||
]);
|
||||
expect(
|
||||
JSON.parse(
|
||||
new TextDecoder().decode(entries["privacy-tools-report.json"]),
|
||||
),
|
||||
).toMatchObject({ schemaVersion: 1, generatedAt });
|
||||
});
|
||||
|
||||
it("bounds archive entry count before reading output blobs", async () => {
|
||||
const source = result();
|
||||
const output = result({ id: "clean" });
|
||||
const report = buildSanitizationReport(
|
||||
source,
|
||||
output,
|
||||
"clean.png",
|
||||
"image/png",
|
||||
1,
|
||||
comparison(true),
|
||||
);
|
||||
const asset = { blob: new Blob([Uint8Array.of(0)]), report };
|
||||
await expect(
|
||||
createBatchArchive(
|
||||
[source],
|
||||
Array.from({ length: 101 }, () => asset),
|
||||
),
|
||||
).rejects.toThrow(/Archive image count/iu);
|
||||
});
|
||||
});
|
||||
|
||||
function comparison(identical: boolean) {
|
||||
return {
|
||||
method: "oriented-256px-sample" as const,
|
||||
sourceDigest: "source-sample",
|
||||
outputDigest: identical ? "source-sample" : "output-sample",
|
||||
identical,
|
||||
note: "test comparison",
|
||||
};
|
||||
}
|
||||
|
||||
function result(overrides: Partial<ImageScanResult> = {}): ImageScanResult {
|
||||
return {
|
||||
id: "source",
|
||||
name: "source.png",
|
||||
safeName: "source.png",
|
||||
size: 100,
|
||||
lastModified: 0,
|
||||
sha256: "a".repeat(64),
|
||||
identity: {
|
||||
claimedType: "image/png",
|
||||
extension: "png",
|
||||
detectedKind: "png",
|
||||
detectedType: "image/png",
|
||||
typeMatch: "match",
|
||||
},
|
||||
width: 2,
|
||||
height: 1,
|
||||
orientation: 1,
|
||||
animated: false,
|
||||
multiImage: false,
|
||||
deepSupported: true,
|
||||
cleanable: true,
|
||||
findings: [],
|
||||
blocks: [],
|
||||
warnings: [],
|
||||
coverage: {
|
||||
projectScanner: "complete",
|
||||
secondaryScanner: "complete",
|
||||
notes: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user