461 lines
15 KiB
TypeScript
461 lines
15 KiB
TypeScript
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<SanitizedAsset> {
|
|
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<void> {
|
|
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<string>();
|
|
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<DecodedDrawable> {
|
|
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<string> {
|
|
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<string> {
|
|
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<Blob> {
|
|
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");
|
|
}
|