import { encodeText, sanitizeDownloadFilename, stableStringify, } from "@add-ideas/toolbox-helpers"; import { zipSync } from "fflate"; import { APP_VERSION } from "../version"; import { assertLimit, DEFAULT_PRIVACY_LIMITS, PrivacyLimitError, } from "./limits"; import type { BatchReport, ImageScanResult, SanitizedAsset, SanitizationReport, } from "./model"; export function createBatchReport( files: readonly ImageScanResult[], assets: readonly SanitizedAsset[], generatedAt = new Date().toISOString(), ): BatchReport { return { schemaVersion: 1, generatedAt, application: { name: "Privacy Tools", version: APP_VERSION }, files: [...files], sanitizations: assets.map((asset) => asset.report), warnings: [ "This report may itself contain sensitive source metadata and filenames; store and share it deliberately.", "A successful re-scan is not an anonymity guarantee; see each sanitization disclaimer.", ], }; } export function serializeReport( report: BatchReport | SanitizationReport, ): string { return stableStringify(report, 2, { maxDepth: 64, maxNodes: 500_000, maxTextChars: 2_000_000, }); } export async function createBatchArchive( files: readonly ImageScanResult[], assets: readonly SanitizedAsset[], generatedAt = new Date().toISOString(), ): Promise { assertLimit( files.length, DEFAULT_PRIVACY_LIMITS.maxFiles, "Report file count", ); assertLimit( assets.length, DEFAULT_PRIVACY_LIMITS.maxFiles, "Archive image count", ); const entries: Record = Object.create(null) as Record< string, Uint8Array >; const names = new Set(); let total = 0; for (const asset of assets) { const name = uniqueName(asset.report.outputName, names); const bytes = new Uint8Array(await asset.blob.arrayBuffer()); total += bytes.byteLength; if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes) throw new PrivacyLimitError( "Batch archive input size", total, DEFAULT_PRIVACY_LIMITS.maxZipBytes, ); entries[`images/${name}`] = bytes; } const report = encodeText( serializeReport(createBatchReport(files, assets, generatedAt)), ); total += report.byteLength; if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes) throw new PrivacyLimitError( "Batch archive input size", total, DEFAULT_PRIVACY_LIMITS.maxZipBytes, ); entries["privacy-tools-report.json"] = report; const archive = zipSync(entries, { level: 0, mtime: new Date("1980-01-01T00:00:00.000Z"), }); if (archive.byteLength > DEFAULT_PRIVACY_LIMITS.maxZipBytes) throw new PrivacyLimitError( "Batch archive output size", archive.byteLength, DEFAULT_PRIVACY_LIMITS.maxZipBytes, ); const ownedArchive = archive.slice().buffer as ArrayBuffer; return new Blob([ownedArchive], { type: "application/zip" }); } function uniqueName(input: string, used: Set): string { const safe = sanitizeDownloadFilename(input, "image.clean"); if (!used.has(safe)) { used.add(safe); return safe; } const dot = safe.lastIndexOf("."); const stem = dot > 0 ? safe.slice(0, dot) : safe; const extension = dot > 0 ? safe.slice(dot) : ""; let counter = 2; while (used.has(`${stem}-${counter}${extension}`)) counter += 1; const candidate = `${stem}-${counter}${extension}`; used.add(candidate); return candidate; }