import { digestHex, sanitizeDownloadFilename, } from "@add-ideas/toolbox-helpers"; import type { ImageScanResult, MetadataFinding, PixelComparison, SanitizedAsset, SanitizationReport, } from "./model"; import { scanImageBytes } from "./scanner"; export interface SanitizeOptions { jpegQuality?: number; webpQuality?: number; signal?: AbortSignal; /** A caller-selected, non-identifying output name. The detected extension is enforced. */ outputName?: string; } export async function sanitizeStaticImage( file: File, source: ImageScanResult, options: SanitizeOptions = {}, ): Promise { if (!source.cleanable) throw new TypeError( `${source.name} is not a supported static clean-copy input.`, ); if (source.coverage.projectScanner !== "complete") throw new TypeError("The source project scan is not complete."); if (file.size !== source.size || file.name !== source.name) throw new TypeError( "The selected source file no longer matches its scan result.", ); throwIfAborted(options.signal); await verifySourceHash(file, source); throwIfAborted(options.signal); const mime = outputMime(source.identity.detectedKind); const decoded = await decodeDrawable(file, source, options.signal); const canvas = document.createElement("canvas"); canvas.width = decoded.outputWidth; canvas.height = decoded.outputHeight; const context = canvas.getContext("2d", { alpha: true, colorSpace: "srgb", willReadFrequently: false, }); if (!context) throw new Error("The browser did not provide a 2D canvas."); context.save(); applyOrientationTransform( context, decoded.orientationToApply, decoded.rawWidth, decoded.rawHeight, ); context.drawImage(decoded.drawable, 0, 0); context.restore(); decoded.close(); throwIfAborted(options.signal); const sourceSample = await sampleDigest(canvas); const quality = mime === "image/jpeg" ? boundedQuality(options.jpegQuality ?? 0.92) : mime === "image/webp" ? boundedQuality(options.webpQuality ?? 0.92) : undefined; const blob = await canvasToBlob(canvas, mime, quality); if (blob.type !== mime) throw new Error( `This browser encoded ${blob.type || "an unknown format"} instead of ${mime}.`, ); throwIfAborted(options.signal); const outputBytes = await blob.arrayBuffer(); const outputName = options.outputName ? enforceOutputExtension(options.outputName, source.identity.detectedKind) : cleanOutputName(source.safeName, source.identity.detectedKind); const outputScan = await scanImageBytes({ id: `${source.id}-clean`, name: outputName, claimedType: mime, size: outputBytes.byteLength, lastModified: 0, bytes: outputBytes, }); const outputSample = await sampleBlobDigest(blob); const pixelComparison: PixelComparison = { method: "oriented-256px-sample", sourceDigest: sourceSample, outputDigest: outputSample, identical: sourceSample === outputSample, note: mime === "image/png" ? "The decoded, orientation-normalized sample should normally be identical." : "JPEG and lossy WebP encoding may change decoded sample pixels even when the visible image is preserved.", }; const report = buildSanitizationReport( source, outputScan, outputName, mime, blob.size, pixelComparison, ); return { blob, report }; } export function buildSanitizationReport( source: ImageScanResult, output: ImageScanResult, outputName: string, outputType: string, outputBytes: number, pixelComparison: PixelComparison, ): SanitizationReport { const outputKeys = new Set(output.findings.map(findingSignature)); const sourceKeys = new Set(source.findings.map(findingSignature)); const removed = source.findings.filter( (finding) => !outputKeys.has(findingSignature(finding)), ); const preserved = source.findings.filter((finding) => outputKeys.has(findingSignature(finding)), ); const generated = output.findings.filter( (finding) => !sourceKeys.has(findingSignature(finding)), ); const unsupported: string[] = []; const incomplete: string[] = []; if (source.coverage.projectScanner !== "complete") incomplete.push( "The source project scanner did not reach complete coverage.", ); if (source.coverage.secondaryScanner !== "complete") incomplete.push( `The source secondary scanner status was ${source.coverage.secondaryScanner}.`, ); if (output.coverage.projectScanner !== "complete") incomplete.push( "The output project scanner did not reach complete coverage.", ); if (output.coverage.secondaryScanner !== "complete") incomplete.push( `The output secondary scanner status was ${output.coverage.secondaryScanner}.`, ); if (source.findings.some((finding) => finding.category === "provenance")) unsupported.push( "Source provenance/signature data was removed or invalidated; authenticity cannot be carried through pixel re-encoding.", ); const dimensionsMatch = source.width === undefined || source.height === undefined || (output.width === expectedWidth(source) && output.height === expectedHeight(source)); if (!dimensionsMatch) incomplete.push( "Output dimensions do not match the expected oriented dimensions.", ); const orientationNormalized = (output.orientation === undefined || output.orientation === 1) && dimensionsMatch; if (!orientationNormalized) incomplete.push("Output orientation was not normalized to pixel order."); if (output.identity.typeMatch !== "match") incomplete.push("Output type, filename, or detected bytes do not agree."); if (outputType === "image/png" && !pixelComparison.identical) incomplete.push( "Lossless PNG output did not preserve the decoded pixel sample.", ); const unsafeOutput = output.findings.filter( (finding) => finding.risk === "sensitive" || finding.category === "comment" || finding.category === "provenance", ); const unexpectedBlocks = findUnexpectedOutputBlocks(output); if (unexpectedBlocks.length > 0) incomplete.push( `Output contains unexpected metadata blocks: ${unexpectedBlocks.join(", ")}.`, ); const failed = output.coverage.projectScanner !== "complete" || unsafeOutput.length > 0 || output.animated || output.multiImage || !orientationNormalized || output.identity.typeMatch !== "match" || (outputType === "image/png" && !pixelComparison.identical); const warning = incomplete.length > 0 || output.coverage.secondaryScanner !== "complete" || unsupported.length > 0; const status = failed ? "failed" : warning ? "warning" : "verified"; return { sourceId: source.id, sourceName: source.name, outputName, outputType, sourceSha256: source.sha256, outputSha256: output.sha256, sourceBytes: source.size, outputBytes, sourceDimensions: source.width !== undefined && source.height !== undefined ? { width: source.width, height: source.height } : undefined, outputDimensions: output.width !== undefined && output.height !== undefined ? { width: output.width, height: output.height } : undefined, orientationNormalized, removed, preserved, generated, unsupported, incomplete, outputScan: output, pixelComparison, status, summary: status === "verified" ? "The pixel re-encode completed and both bounded output scanners found no sensitive metadata." : status === "warning" ? "The pixel re-encode completed, but one or more verification limits require review." : "The output did not pass the mandatory metadata verification gate.", disclaimer: "This report is not an anonymity guarantee. Visible faces or text, steganography, invisible watermarks, reverse-image matching, sidecars, filesystem records, and cloud copies are outside this tool's checks.", }; } async function verifySourceHash( file: File, source: ImageScanResult, ): Promise { const bytes = new Uint8Array(await file.arrayBuffer()); const currentHash = await digestHex(bytes, "SHA-256", source.size); if (currentHash !== source.sha256) throw new TypeError( "The selected source bytes no longer match their scan hash.", ); } function findUnexpectedOutputBlocks(output: ImageScanResult): string[] { const allowed = output.identity.detectedKind === "jpeg" ? new Set(["APP0", "APP2"]) : output.identity.detectedKind === "png" ? new Set(["iCCP", "pHYs"]) : output.identity.detectedKind === "webp" ? new Set(["ICCP"]) : new Set(); return [...new Set(output.blocks.map((block) => block.kind))].filter( (kind) => !allowed.has(kind), ); } interface DecodedDrawable { drawable: CanvasImageSource; rawWidth: number; rawHeight: number; outputWidth: number; outputHeight: number; orientationToApply: number; close(): void; } async function decodeDrawable( file: File, source: ImageScanResult, signal?: AbortSignal, ): Promise { if ("createImageBitmap" in globalThis) { const bitmap = await createImageBitmap(file, { imageOrientation: "none" }); throwIfAborted(signal); const orientation = normalizeOrientation(source.orientation); const scannerWidth = source.width ?? bitmap.width; const scannerHeight = source.height ?? bitmap.height; const browserAlreadyOriented = swapsAxes(orientation) && bitmap.width === scannerHeight && bitmap.height === scannerWidth; const orientationToApply = browserAlreadyOriented ? 1 : orientation; return { drawable: bitmap, rawWidth: bitmap.width, rawHeight: bitmap.height, outputWidth: swapsAxes(orientationToApply) ? bitmap.height : bitmap.width, outputHeight: swapsAxes(orientationToApply) ? bitmap.width : bitmap.height, orientationToApply, close: () => bitmap.close(), }; } const url = URL.createObjectURL(file); try { const image = new Image(); image.decoding = "async"; image.src = url; await image.decode(); throwIfAborted(signal); return { drawable: image, rawWidth: image.naturalWidth, rawHeight: image.naturalHeight, outputWidth: image.naturalWidth, outputHeight: image.naturalHeight, orientationToApply: 1, close: () => undefined, }; } finally { URL.revokeObjectURL(url); } } function applyOrientationTransform( context: CanvasRenderingContext2D, orientation: number, width: number, height: number, ): void { if (orientation === 2) context.transform(-1, 0, 0, 1, width, 0); else if (orientation === 3) context.transform(-1, 0, 0, -1, width, height); else if (orientation === 4) context.transform(1, 0, 0, -1, 0, height); else if (orientation === 5) context.transform(0, 1, 1, 0, 0, 0); else if (orientation === 6) context.transform(0, 1, -1, 0, height, 0); else if (orientation === 7) context.transform(0, -1, -1, 0, height, width); else if (orientation === 8) context.transform(0, -1, 1, 0, 0, width); } async function sampleDigest(canvas: HTMLCanvasElement): Promise { const maximum = 256; const scale = Math.min(1, maximum / canvas.width, maximum / canvas.height); const width = Math.max(1, Math.round(canvas.width * scale)); const height = Math.max(1, Math.round(canvas.height * scale)); const sample = document.createElement("canvas"); sample.width = width; sample.height = height; const context = sample.getContext("2d", { willReadFrequently: true }); if (!context) throw new Error("The browser could not create a verification canvas."); context.drawImage(canvas, 0, 0, width, height); const pixels = context.getImageData(0, 0, width, height).data; return digestHex(pixels, "SHA-256", maximum * maximum * 4); } async function sampleBlobDigest(blob: Blob): Promise { const bitmap = await createImageBitmap(blob, { imageOrientation: "none" }); try { const canvas = document.createElement("canvas"); canvas.width = bitmap.width; canvas.height = bitmap.height; const context = canvas.getContext("2d"); if (!context) throw new Error("The browser could not decode output pixels."); context.drawImage(bitmap, 0, 0); return sampleDigest(canvas); } finally { bitmap.close(); } } function canvasToBlob( canvas: HTMLCanvasElement, type: string, quality?: number, ): Promise { return new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (blob) resolve(blob); else reject(new Error(`The browser could not encode ${type}.`)); }, type, quality, ); }); } function outputMime(kind: ImageScanResult["identity"]["detectedKind"]): string { if (kind === "jpeg") return "image/jpeg"; if (kind === "png") return "image/png"; if (kind === "webp") return "image/webp"; throw new TypeError(`No clean-copy encoder is available for ${kind}.`); } function cleanOutputName( input: string, kind: ImageScanResult["identity"]["detectedKind"], ): string { const extension = kind === "jpeg" ? "jpg" : kind; const withoutExtension = input.replace(/\.[^.]*$/u, "") || "image"; return sanitizeDownloadFilename(`${withoutExtension}.clean.${extension}`); } export function genericOutputName( index: number, kind: ImageScanResult["identity"]["detectedKind"], ): string { if (!Number.isSafeInteger(index) || index < 0) throw new RangeError( "Generic output index must be a non-negative integer.", ); const extension = kind === "jpeg" ? "jpg" : kind; if (!new Set(["jpg", "png", "webp"]).has(extension)) throw new TypeError(`No generic clean-copy name is available for ${kind}.`); return `image-${String(index + 1).padStart(3, "0")}.clean.${extension}`; } function enforceOutputExtension( input: string, kind: ImageScanResult["identity"]["detectedKind"], ): string { const extension = kind === "jpeg" ? "jpg" : kind; const safe = sanitizeDownloadFilename(input, `image.clean.${extension}`); const stem = safe.replace(/\.[^.]*$/u, "") || "image.clean"; return `${stem}.${extension}`; } function findingSignature(finding: MetadataFinding): string { const label = finding.label .toLowerCase() .replace(/[^a-z0-9]+/gu, " ") .trim(); return `${finding.category}\u0000${label}\u0000${finding.value}`; } function normalizeOrientation(value: number | undefined): number { return value !== undefined && value >= 1 && value <= 8 ? value : 1; } function swapsAxes(orientation: number): boolean { return orientation >= 5 && orientation <= 8; } function expectedWidth(scan: ImageScanResult): number | undefined { if (scan.width === undefined || scan.height === undefined) return undefined; return swapsAxes(normalizeOrientation(scan.orientation)) ? scan.height : scan.width; } function expectedHeight(scan: ImageScanResult): number | undefined { if (scan.width === undefined || scan.height === undefined) return undefined; return swapsAxes(normalizeOrientation(scan.orientation)) ? scan.width : scan.height; } function boundedQuality(value: number): number { if (!Number.isFinite(value) || value < 0.5 || value > 1) throw new RangeError("Image quality must be between 0.5 and 1."); return value; } function throwIfAborted(signal?: AbortSignal): void { if (signal?.aborted) throw new DOMException("Operation cancelled", "AbortError"); }