Release Privacy Tools 0.1.0

This commit is contained in:
2026-09-01 02:39:44 +02:00
commit bfd6422149
79 changed files with 13485 additions and 0 deletions
+48
View File
@@ -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");
});
});
+40
View File
@@ -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,
);
});
});
+207
View File
@@ -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,
};
}
+236
View File
@@ -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),
};
}