import { crc32 } from "@add-ideas/toolbox-helpers"; import { FindingCollector } from "./findings"; import { inflateZlibBounded } from "./inflate"; import type { MetadataBlock, PrivacyLimits } from "./model"; import type { FormatScan } from "./jpeg"; import { scanTiff } from "./tiff"; import { decodeMetadataText, scanXmp } from "./xmp"; export function scanPng( bytes: Uint8Array, collector: FindingCollector, limits: Readonly, ): FormatScan { const blocks: MetadataBlock[] = []; let offset = 8; let width: number | undefined; let height: number | undefined; let orientation: number | undefined; let animated = false; let complete = true; let chunks = 0; let sawEnd = false; let sawHeader = false; let sawImageData = false; while (offset + 12 <= bytes.byteLength) { const length = readU32be(bytes, offset); const type = ascii(bytes, offset + 4, 4); const dataOffset = offset + 8; const end = dataOffset + length; if (!/^[A-Za-z]{4}$/u.test(type) || end + 4 > bytes.byteLength) { collector.warn(`PNG chunk at byte ${offset} is malformed or truncated.`); complete = false; break; } chunks += 1; if (chunks > limits.maxMetadataBlocks) { collector.warn(`PNG chunk count exceeded ${limits.maxMetadataBlocks}.`); complete = false; break; } const data = bytes.subarray(dataOffset, end); const expectedCrc = readU32be(bytes, end); const crcInput = bytes.subarray(offset + 4, end); if (crc32(crcInput, limits.maxFileBytes) !== expectedCrc) { collector.warn(`PNG ${type} chunk at byte ${offset} has an invalid CRC.`); complete = false; } if (chunks === 1 && type !== "IHDR") { collector.warn("PNG IHDR is not the first chunk."); complete = false; } if (type === "IHDR") { if (sawHeader || chunks !== 1 || length !== 13) { collector.warn("PNG IHDR is duplicated, misplaced, or malformed."); complete = false; } else { width = readU32be(data, 0); height = readU32be(data, 4); } sawHeader = true; } else if (type === "IDAT") { sawImageData = true; } else if (type === "IEND") { if (length !== 0) { collector.warn("PNG IEND chunk is not empty."); complete = false; } sawEnd = true; offset = end + 4; break; } else if (type === "acTL" || type === "fcTL" || type === "fdAT") { animated = true; blocks.push({ kind: type, offset: dataOffset, length }); collector.add({ category: "technical", risk: "context", source: "PNG", label: "APNG animation", value: `${type} chunk (${length} bytes)`, offset: dataOffset, length, }); } else if (isMetadataChunk(type)) { if (length > limits.maxMetadataBlockBytes) { collector.warn(`PNG ${type} metadata exceeds the block limit.`); complete = false; } else { blocks.push({ kind: type, offset: dataOffset, length }); const result = scanPngMetadata( type, data, dataOffset, collector, limits, ); if (result.orientation !== undefined) orientation = result.orientation; complete &&= result.complete; } } else if (type[1] === type[1]?.toLowerCase()) { blocks.push({ kind: type, offset: dataOffset, length }); collector.add({ category: type === "caBX" ? "provenance" : "unknown", risk: "context", source: "PNG", label: type === "caBX" ? "C2PA provenance chunk" : `Private chunk ${type}`, value: `${length} bytes`, offset: dataOffset, length, }); } offset = end + 4; } if (!sawEnd) { collector.warn("PNG IEND chunk was not found."); complete = false; } else if (offset < bytes.byteLength) { const trailing = bytes.byteLength - offset; blocks.push({ kind: "trailing-data", offset, length: trailing }); collector.add({ category: "unknown", risk: "sensitive", source: "PNG", label: "Trailing data", value: `${trailing} bytes after IEND`, offset, length: trailing, }); } if (!sawHeader) { collector.warn("PNG IHDR chunk was not found."); complete = false; } if (!sawImageData) { collector.warn("PNG IDAT image data was not found."); complete = false; } return { width, height, orientation, animated, multiImage: false, complete, blocks, }; } function scanPngMetadata( type: string, data: Uint8Array, offset: number, collector: FindingCollector, limits: Readonly, ): { orientation?: number; complete: boolean } { try { if (type === "eXIf") { const result = scanTiff(data, "PNG eXIf", offset, collector, limits); return { orientation: result.orientation, complete: result.complete }; } if (type === "tEXt") { const separator = data.indexOf(0); const keyword = decodeLatin( data.subarray(0, separator < 0 ? data.length : separator), ); const value = separator < 0 ? "" : decodeLatin(data.subarray(separator + 1)); addPngText(keyword, value, type, offset, data.byteLength, collector); } else if (type === "zTXt") { const separator = data.indexOf(0); if (separator < 0 || data[separator + 1] !== 0) throw new SyntaxError("invalid zTXt header"); const keyword = decodeLatin(data.subarray(0, separator)); const inflated = inflateZlibBounded( data.subarray(separator + 2), limits.maxInflatedMetadataBytes, ); addPngText( keyword, decodeLatin(inflated), type, offset, data.byteLength, collector, ); } else if (type === "iTXt") { const parsed = parseInternationalText( data, limits.maxInflatedMetadataBytes, ); addPngText( parsed.keyword, parsed.text, type, offset, data.byteLength, collector, ); } else if (type === "iCCP") { const separator = data.indexOf(0); if (separator < 0 || data[separator + 1] !== 0) throw new SyntaxError("invalid iCCP header"); const profile = inflateZlibBounded( data.subarray(separator + 2), limits.maxInflatedMetadataBytes, ); collector.add({ category: "colour-profile", risk: "technical", source: "PNG iCCP", label: "ICC profile", value: `${decodeLatin(data.subarray(0, separator)) || "unnamed"}; ${profile.byteLength} bytes inflated`, offset, length: data.byteLength, }); } else if (type === "pHYs") { collector.add({ category: "technical", risk: "technical", source: "PNG pHYs", label: "Pixel density", value: `${readU32be(data, 0)}×${readU32be(data, 4)} per ${data[8] === 1 ? "metre" : "unknown unit"}`, offset, length: data.byteLength, }); } else if (type === "tIME" && data.byteLength === 7) { collector.add({ category: "timestamp", risk: "sensitive", source: "PNG tIME", label: "Last modification time", value: `${readU16be(data, 0)}-${pad(data[2])}-${pad(data[3])} ${pad(data[4])}:${pad(data[5])}:${pad(data[6])} UTC-like fields`, offset, length: data.byteLength, }); } else if (type === "caBX") { collector.add({ category: "provenance", risk: "context", source: "PNG caBX", label: "C2PA provenance data", value: `${data.byteLength} bytes; re-encoding will remove or invalidate this provenance`, offset, length: data.byteLength, }); } return { complete: true }; } catch (error) { collector.warn( `PNG ${type} metadata could not be fully read: ${error instanceof Error ? error.message : "unknown error"}.`, ); return { complete: false }; } } function addPngText( keyword: string, value: string, type: string, offset: number, length: number, collector: FindingCollector, ): void { if (/xmp/iu.test(keyword) || /<\?xpacket|= data.byteLength) throw new SyntaxError("invalid iTXt header"); const compressed = data[first + 1] === 1; if ((data[first + 1] !== 0 && !compressed) || data[first + 2] !== 0) throw new SyntaxError("unsupported iTXt compression"); const languageEnd = data.indexOf(0, first + 3); if (languageEnd < 0) throw new SyntaxError("truncated iTXt language tag"); const translatedEnd = data.indexOf(0, languageEnd + 1); if (translatedEnd < 0) throw new SyntaxError("truncated iTXt translated keyword"); const payload = data.subarray(translatedEnd + 1); return { keyword: decodeLatin(data.subarray(0, first)), text: decodeMetadataText( compressed ? inflateZlibBounded(payload, maximumInflatedBytes) : payload, ), }; } function isMetadataChunk(type: string): boolean { return [ "tEXt", "zTXt", "iTXt", "eXIf", "iCCP", "pHYs", "tIME", "caBX", ].includes(type); } function readU32be(bytes: Uint8Array, offset: number): number { return ( (bytes[offset] ?? 0) * 0x1000000 + ((bytes[offset + 1] ?? 0) << 16) + ((bytes[offset + 2] ?? 0) << 8) + (bytes[offset + 3] ?? 0) ); } function readU16be(bytes: Uint8Array, offset: number): number { return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0); } function ascii(bytes: Uint8Array, offset: number, length: number): string { let value = ""; for ( let index = 0; index < length && offset + index < bytes.length; index += 1 ) value += String.fromCharCode(bytes[offset + index] ?? 0); return value; } function decodeLatin(bytes: Uint8Array): string { return new TextDecoder("latin1", { fatal: false }).decode(bytes); } function pad(value: number | undefined): string { return String(value ?? 0).padStart(2, "0"); }