Files
zemion 38fa45dbaa
Verify / verify (push) Canceled after 0s
Release Privacy Tools 0.2.0
2026-09-02 10:09:14 +02:00

304 lines
8.2 KiB
TypeScript

// @vitest-environment node
import { unzipSync } from "fflate";
import { describe, expect, it } from "vitest";
import {
buildSanitizationReport,
createBatchArchive,
createBatchReport,
createPolicyEvidence,
createSafeShareReport,
genericOutputName,
policyById,
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);
});
it("builds a pseudonymized safe-share report and generic names", () => {
const source = result({
id: "private-id",
name: "Alice-at-home.png",
sha256: "secret-hash",
findings: [sensitive],
coverage: {
projectScanner: "complete",
secondaryScanner: "complete",
notes: ["source metadata value"],
},
});
const output = result({ id: "clean", name: "output.png" });
const report = buildSanitizationReport(
source,
output,
"Alice-at-home.clean.png",
"image/png",
20,
comparison(true),
);
const safe = serializeReport(
createSafeShareReport(
[source],
[{ blob: new Blob(), report }],
"2026-09-01T00:00:00.000Z",
),
);
expect(safe).toContain('"profile": "safe-share"');
expect(safe).toContain("image-001.clean.png");
expect(safe).not.toContain("Alice");
expect(safe).not.toContain("secret-hash");
expect(safe).not.toContain("48.1 N");
expect(safe).not.toContain("source metadata value");
expect(genericOutputName(9, "jpeg")).toBe("image-010.clean.jpg");
});
it("evaluates reusable policies across cleanable and inspect-only formats", () => {
const cleanable = result({ findings: [sensitive] });
const unsupported = result({
id: "pdf",
name: "document.pdf",
identity: {
claimedType: "application/pdf",
extension: "pdf",
detectedKind: "pdf",
detectedType: "application/pdf",
typeMatch: "match",
},
deepSupported: false,
cleanable: false,
findings: [sensitive],
coverage: {
projectScanner: "unsupported",
secondaryScanner: "unsupported",
notes: [],
},
});
const strict = createPolicyEvidence(
[cleanable, unsupported],
[],
policyById("safe-share"),
"2026-09-01T00:00:00.000Z",
);
expect(strict.files[0]).toMatchObject({
decision: "review",
availableOperation: "reencode-available",
findings: { remove: 1 },
});
expect(strict.files[1]).toMatchObject({
decision: "blocked",
availableOperation: "inspect-only",
});
expect(JSON.stringify(strict)).not.toContain("document.pdf");
const selective = createPolicyEvidence(
[
result({
findings: [
sensitive,
{ ...sensitive, id: "author", category: "identity" },
],
}),
],
[],
policyById("location-only"),
);
expect(selective.files[0]?.availableOperation).toBe(
"policy-not-executable",
);
});
});
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,
};
}