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, FindingCategory, FindingRisk, ImageScanResult, MetadataFinding, SafeShareFindingSummary, SafeShareReport, SanitizedAsset, } 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 createSafeShareReport( files: readonly ImageScanResult[], assets: readonly SanitizedAsset[], generatedAt = new Date().toISOString(), ): SafeShareReport { const fileIds = new Map( files.map((file, index) => [ file.id, `file-${String(index + 1).padStart(3, "0")}`, ]), ); return { schemaVersion: 1, profile: "safe-share", generatedAt, application: { name: "Privacy Tools", version: APP_VERSION }, files: files.map((file, index) => ({ fileId: fileIds.get(file.id) ?? `file-${String(index + 1).padStart(3, "0")}`, detectedKind: file.identity.detectedKind, detectedType: file.identity.detectedType, typeMatch: file.identity.typeMatch, dimensions: file.width !== undefined && file.height !== undefined ? { width: file.width, height: file.height } : undefined, animated: file.animated, multiImage: file.multiImage, coverage: { projectScanner: file.coverage.projectScanner, secondaryScanner: file.coverage.secondaryScanner, noteCount: file.coverage.notes.length, }, findings: summarizeFindings(file.findings), warningCount: file.warnings.length, })), sanitizations: assets.map((asset, index) => ({ fileId: fileIds.get(asset.report.sourceId) ?? "unknown-file", outputName: genericNameForAsset(asset, index), outputType: asset.report.outputType, status: asset.report.status, orientationNormalized: asset.report.orientationNormalized, removed: summarizeFindings(asset.report.removed), preserved: summarizeFindings(asset.report.preserved), generated: summarizeFindings(asset.report.generated), unsupported: [...asset.report.unsupported], incomplete: [...asset.report.incomplete], })), warnings: [ "This safe-share profile omits source filenames, timestamps, hashes, exact byte sizes, metadata values and offsets.", "Counts, dimensions and format details can still identify unusual files; review before sharing.", "A successful re-scan is not an anonymity guarantee.", ], }; } export function serializeReport(report: object): 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(), reportProfile: "detailed" | "safe-share" = "detailed", ): 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 [index, asset] of assets.entries()) { const name = uniqueName( reportProfile === "safe-share" ? genericNameForAsset(asset, index) : 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( reportProfile === "safe-share" ? createSafeShareReport(files, assets, generatedAt) : 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 summarizeFindings( findings: readonly MetadataFinding[], ): SafeShareFindingSummary { const byRisk: Record = { sensitive: 0, context: 0, technical: 0, }; const byCategory: Partial> = {}; for (const finding of findings) { byRisk[finding.risk] += 1; byCategory[finding.category] = (byCategory[finding.category] ?? 0) + 1; } return { total: findings.length, byRisk, byCategory }; } function genericNameForAsset(asset: SanitizedAsset, index: number): string { const extension = asset.report.outputType === "image/jpeg" ? "jpg" : asset.report.outputType === "image/webp" ? "webp" : "png"; return `image-${String(index + 1).padStart(3, "0")}.clean.${extension}`; } 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; }