From e48bb3f062001dc9143aea5b5eed88ee26aec9dd Mon Sep 17 00:00:00 2001 From: maruson08 Date: Tue, 25 Aug 2026 15:31:18 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8[Feat]=20Add=20bounded=20PNG=20chu?= =?UTF-8?q?nk=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/diagnostics.ts | 13 ++ src/png/crc32.ts | 10 ++ src/png/index.ts | 7 + src/png/parser.ts | 384 ++++++++++++++++++++++++++++++++++++++++ src/png/types.ts | 34 ++++ 5 files changed, 448 insertions(+) create mode 100644 src/png/crc32.ts create mode 100644 src/png/index.ts create mode 100644 src/png/parser.ts create mode 100644 src/png/types.ts diff --git a/src/core/diagnostics.ts b/src/core/diagnostics.ts index e2a1143..a634d00 100644 --- a/src/core/diagnostics.ts +++ b/src/core/diagnostics.ts @@ -25,6 +25,19 @@ export type DiagnosticCode = | "WEBP_DUPLICATE_VP8X" | "WEBP_INCONSISTENT_FEATURE_FLAGS" | "WEBP_TRAILING_DATA" + | "PNG_INVALID_SIGNATURE" + | "PNG_TRUNCATED_CHUNK_LENGTH" + | "PNG_TRUNCATED_CHUNK_TYPE" + | "PNG_TRUNCATED_CHUNK_DATA" + | "PNG_MISSING_CRC" + | "PNG_INVALID_CHUNK_TYPE" + | "PNG_CHUNK_LIMIT_EXCEEDED" + | "PNG_INVALID_IEND" + | "PNG_MISSING_IEND" + | "PNG_TRAILING_DATA" + | "PNG_INVALID_CRC" + | "PNG_INVALID_TEXT" + | "PNG_TEXT_LIMIT_EXCEEDED" | "TIFF_TRUNCATED_HEADER" | "TIFF_INVALID_BYTE_ORDER" | "TIFF_INVALID_MAGIC" diff --git a/src/png/crc32.ts b/src/png/crc32.ts new file mode 100644 index 0000000..9093f3a --- /dev/null +++ b/src/png/crc32.ts @@ -0,0 +1,10 @@ +export function pngCrc32(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} diff --git a/src/png/index.ts b/src/png/index.ts new file mode 100644 index 0000000..bee7b8f --- /dev/null +++ b/src/png/index.ts @@ -0,0 +1,7 @@ +export { parsePng } from "./parser.js"; +export type { + PngChunk, + PngChunkKind, + PngMetadataKind, + PngParseResult, +} from "./types.js"; diff --git a/src/png/parser.ts b/src/png/parser.ts new file mode 100644 index 0000000..4500c9b --- /dev/null +++ b/src/png/parser.ts @@ -0,0 +1,384 @@ +import { type ByteReader } from "../core/binary/index.js"; +import type { Diagnostic, DiagnosticCode } from "../core/diagnostics.js"; +import { pngCrc32 } from "./crc32.js"; +import type { + PngChunk, + PngChunkKind, + PngMetadataKind, + PngParseResult, +} from "./types.js"; + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const XMP_KEYWORD = "XML:com.adobe.xmp"; + +function diagnostic( + severity: Diagnostic["severity"], + code: DiagnosticCode, + message: string, + offset?: number, +): Diagnostic { + return offset === undefined + ? { severity, code, message } + : { severity, code, message, offset }; +} + +function failure( + diagnostics: readonly Diagnostic[], + chunks: readonly PngChunk[] = [], + containerLength = 0, +): PngParseResult { + return { + chunks, + complete: false, + sawIend: false, + containerLength, + diagnostics, + }; +} + +function fourCC(reader: ByteReader, offset: number): string { + return String.fromCharCode( + reader.u8(offset), + reader.u8(offset + 1), + reader.u8(offset + 2), + reader.u8(offset + 3), + ); +} + +function isAsciiLetter(value: number): boolean { + return (value >= 0x41 && value <= 0x5a) || (value >= 0x61 && value <= 0x7a); +} + +function classifyChunk( + fourCC: string, + ancillary: boolean, +): { + readonly kind: PngChunkKind; + readonly metadataKind?: PngMetadataKind; +} { + switch (fourCC) { + case "IDAT": + return { kind: "image" }; + case "IHDR": + case "PLTE": + case "IEND": + return { kind: "critical" }; + case "eXIf": + return { kind: "metadata", metadataKind: "exif" }; + case "iCCP": + return { kind: "metadata", metadataKind: "icc" }; + case "tIME": + return { kind: "metadata", metadataKind: "timestamp" }; + case "tEXt": + case "zTXt": + case "iTXt": + return { kind: "metadata", metadataKind: "text" }; + case "gAMA": + case "cHRM": + case "sRGB": + case "sBIT": + case "pHYs": + return { kind: "color" }; + case "acTL": + case "fcTL": + case "fdAT": + return { kind: "animation" }; + default: + return { kind: ancillary ? "unknown" : "critical" }; + } +} + +function readKeyword( + reader: ByteReader, + dataOffset: number, + dataLength: number, + maxStringBytes: number, + diagnostics: Diagnostic[], + fourCC: string, +): { readonly value: string; readonly afterKeyword: number } | undefined { + const keywordLimit = Math.min(maxStringBytes, 79); + const scanLength = Math.min(dataLength, keywordLimit + 1); + for (let index = 0; index < scanLength; index += 1) { + if (reader.u8(dataOffset + index) !== 0) { + continue; + } + if (index === 0) { + diagnostics.push( + diagnostic( + "warning", + "PNG_INVALID_TEXT", + `${fourCC} has an empty text keyword.`, + dataOffset, + ), + ); + return undefined; + } + const characters: number[] = []; + for (let keywordIndex = 0; keywordIndex < index; keywordIndex += 1) { + characters.push(reader.u8(dataOffset + keywordIndex)); + } + return { + value: String.fromCharCode(...characters), + afterKeyword: dataOffset + index + 1, + }; + } + + diagnostics.push( + dataLength > keywordLimit && keywordLimit === maxStringBytes + ? diagnostic( + "warning", + "PNG_TEXT_LIMIT_EXCEEDED", + `${fourCC} keyword exceeds maxStringBytes ${String(maxStringBytes)}.`, + dataOffset, + ) + : diagnostic( + "warning", + "PNG_INVALID_TEXT", + `${fourCC} text keyword is not NUL-terminated.`, + dataOffset, + ), + ); + return undefined; +} + +export function parsePng( + reader: ByteReader, + maxChunks: number, + maxStringBytes: number, +): PngParseResult { + const diagnostics: Diagnostic[] = []; + const chunks: PngChunk[] = []; + if (!reader.matches(0, PNG_SIGNATURE)) { + return failure([ + diagnostic( + "error", + "PNG_INVALID_SIGNATURE", + "PNG input does not contain the complete eight-byte signature.", + 0, + ), + ]); + } + + let offset = 8; + while (offset < reader.length) { + if (chunks.length >= maxChunks) { + diagnostics.push( + diagnostic( + "error", + "PNG_CHUNK_LIMIT_EXCEEDED", + `PNG chunk count exceeds maxChunks ${String(maxChunks)}.`, + offset, + ), + ); + return failure(diagnostics, chunks, offset); + } + const remaining = reader.length - offset; + if (remaining < 4) { + diagnostics.push( + diagnostic( + "error", + "PNG_TRUNCATED_CHUNK_LENGTH", + "PNG input ends within a chunk length field.", + offset, + ), + ); + return failure(diagnostics, chunks, offset); + } + if (remaining < 8) { + diagnostics.push( + diagnostic( + "error", + "PNG_TRUNCATED_CHUNK_TYPE", + "PNG input ends within a chunk type field.", + offset + 4, + ), + ); + return failure(diagnostics, chunks, offset); + } + + const dataLength = reader.u32BE(offset); + const typeOffset = offset + 4; + for (let index = 0; index < 4; index += 1) { + if (!isAsciiLetter(reader.u8(typeOffset + index))) { + diagnostics.push( + diagnostic( + "error", + "PNG_INVALID_CHUNK_TYPE", + "PNG chunk types must contain four ASCII letters.", + typeOffset, + ), + ); + return failure(diagnostics, chunks, offset); + } + } + + const type = fourCC(reader, typeOffset); + const dataOffset = offset + 8; + const available = reader.length - dataOffset; + if (dataLength > available) { + diagnostics.push( + diagnostic( + "error", + "PNG_TRUNCATED_CHUNK_DATA", + `${type} data extends beyond the supplied input.`, + offset, + ), + ); + return failure(diagnostics, chunks, offset); + } + if (available - dataLength < 4) { + diagnostics.push( + diagnostic( + "error", + "PNG_MISSING_CRC", + `${type} is missing its complete CRC field.`, + dataOffset + dataLength, + ), + ); + return failure(diagnostics, chunks, offset); + } + + const crcOffset = dataOffset + dataLength; + const totalLength = 12 + dataLength; + if (!Number.isSafeInteger(totalLength) || totalLength > remaining) { + diagnostics.push( + diagnostic( + "error", + "PNG_TRUNCATED_CHUNK_DATA", + `${type} physical chunk range is invalid.`, + offset, + ), + ); + return failure(diagnostics, chunks, offset); + } + + const ancillary = (reader.u8(typeOffset) & 0x20) !== 0; + const classification = classifyChunk(type, ancillary); + let keyword: string | undefined; + let textCompressed: boolean | undefined; + if (type === "tEXt" || type === "zTXt" || type === "iTXt") { + const parsedKeyword = readKeyword( + reader, + dataOffset, + dataLength, + maxStringBytes, + diagnostics, + type, + ); + keyword = parsedKeyword?.value; + if (type === "zTXt") { + textCompressed = true; + if ( + parsedKeyword !== undefined && + parsedKeyword.afterKeyword >= dataOffset + dataLength + ) { + diagnostics.push( + diagnostic( + "warning", + "PNG_INVALID_TEXT", + "zTXt is missing its compression method byte.", + parsedKeyword.afterKeyword, + ), + ); + } + } else if (type === "iTXt" && parsedKeyword !== undefined) { + if (dataOffset + dataLength - parsedKeyword.afterKeyword < 2) { + diagnostics.push( + diagnostic( + "warning", + "PNG_INVALID_TEXT", + "iTXt is missing compression flag or method bytes.", + parsedKeyword.afterKeyword, + ), + ); + } else { + const flag = reader.u8(parsedKeyword.afterKeyword); + textCompressed = flag === 1; + if (flag > 1) { + diagnostics.push( + diagnostic( + "warning", + "PNG_INVALID_TEXT", + "iTXt compression flag must be zero or one.", + parsedKeyword.afterKeyword, + ), + ); + } + } + } + } + + const expectedCrc = reader.u32BE(crcOffset); + const actualCrc = pngCrc32(reader.slice(typeOffset, 4 + dataLength)); + const crcValid = expectedCrc === actualCrc; + if (!crcValid) { + diagnostics.push( + diagnostic( + "warning", + "PNG_INVALID_CRC", + `${type} CRC does not match its type and data.`, + crcOffset, + ), + ); + } + + const chunk: PngChunk = { + fourCC: type, + offset, + dataOffset, + dataLength, + totalLength, + ancillary, + ...classification, + ...(type === "iTXt" && keyword === XMP_KEYWORD + ? { metadataKind: "xmp" as const } + : {}), + ...(keyword === undefined ? {} : { keyword }), + ...(textCompressed === undefined ? {} : { textCompressed }), + crcValid, + }; + chunks.push(chunk); + offset += totalLength; + + if (type === "IEND") { + if (dataLength !== 0) { + diagnostics.push( + diagnostic( + "error", + "PNG_INVALID_IEND", + "IEND must have an empty data field.", + chunk.dataOffset, + ), + ); + return failure(diagnostics, chunks, offset); + } + if (offset < reader.length) { + diagnostics.push( + diagnostic( + "warning", + "PNG_TRAILING_DATA", + `PNG contains ${String(reader.length - offset)} trailing byte(s) after IEND.`, + offset, + ), + ); + } + return { + chunks, + complete: true, + sawIend: true, + containerLength: offset, + diagnostics, + }; + } + } + + diagnostics.push( + diagnostic( + "error", + "PNG_MISSING_IEND", + "PNG input ends before an IEND chunk.", + reader.length, + ), + ); + return failure(diagnostics, chunks, reader.length); +} diff --git a/src/png/types.ts b/src/png/types.ts new file mode 100644 index 0000000..50ae7a9 --- /dev/null +++ b/src/png/types.ts @@ -0,0 +1,34 @@ +import type { Diagnostic } from "../core/diagnostics.js"; + +export type PngChunkKind = + | "critical" + | "image" + | "metadata" + | "color" + | "animation" + | "ancillary" + | "unknown"; + +export type PngMetadataKind = "exif" | "xmp" | "text" | "icc" | "timestamp"; + +export interface PngChunk { + readonly fourCC: string; + readonly offset: number; + readonly dataOffset: number; + readonly dataLength: number; + readonly totalLength: number; + readonly ancillary: boolean; + readonly kind: PngChunkKind; + readonly metadataKind?: PngMetadataKind; + readonly keyword?: string; + readonly textCompressed?: boolean; + readonly crcValid: boolean; +} + +export interface PngParseResult { + readonly chunks: readonly PngChunk[]; + readonly complete: boolean; + readonly sawIend: boolean; + readonly containerLength: number; + readonly diagnostics: readonly Diagnostic[]; +} From f58d890ecbbb6a8b3ef969be2b7e0a56d3ccbdd7 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Tue, 25 Aug 2026 15:31:27 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8[Feat]=20Add=20PNG=20metadata=20cl?= =?UTF-8?q?eaning=20and=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/errors.ts | 17 ++++- src/core/types.ts | 10 ++- src/index.ts | 3 + src/inspect.ts | 82 ++++++++++++++------ src/png/clean.ts | 178 +++++++++++++++++++++++++++++++++++++++++++ src/png/metadata.ts | 111 +++++++++++++++++++++++++++ src/policy/clean.ts | 4 + src/verify/verify.ts | 53 +++++++++---- 8 files changed, 418 insertions(+), 40 deletions(-) create mode 100644 src/png/clean.ts create mode 100644 src/png/metadata.ts diff --git a/src/core/errors.ts b/src/core/errors.ts index 4164fb8..34ad6b5 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -10,6 +10,7 @@ export type SecureMetadataErrorCode = | "UNSUPPORTED_FORMAT" | "INCOMPLETE_JPEG" | "INCOMPLETE_WEBP" + | "INCOMPLETE_PNG" | "CLEAN_OUTPUT_SIZE_INVALID"; export class SecureMetadataError extends Error { @@ -87,7 +88,7 @@ export class UnsupportedFormatError extends SecureMetadataError { constructor( readonly operation: "cleanMetadata" | "verifyMetadata", - readonly format: "png" | "unknown", + readonly format: "unknown", ) { super( `${operation} does not support ${format} input.`, @@ -123,3 +124,17 @@ export class IncompleteWebPError extends SecureMetadataError { ); } } + +export class IncompletePngError extends SecureMetadataError { + override readonly name: string = "IncompletePngError"; + + constructor( + readonly operation: "cleanMetadata" | "verifyMetadata", + readonly diagnostics: readonly Diagnostic[], + ) { + super( + `${operation} requires a structurally complete PNG ending at IEND.`, + "INCOMPLETE_PNG", + ); + } +} diff --git a/src/core/types.ts b/src/core/types.ts index de73e40..b83a0c2 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -13,6 +13,7 @@ export type MetadataNamespace = | "iptc" | "jpeg-comment" | "png-text" + | "png-time" | "icc" | "container" | "unknown"; @@ -97,6 +98,8 @@ export interface CleaningPolicy { readonly removeXmp?: boolean; readonly removeIptc?: boolean; readonly removeComments?: boolean; + readonly removeTextMetadata?: boolean; + readonly removeTimestamps?: boolean; readonly preserveIcc?: boolean; /** @deprecated Use preserveIcc. */ readonly preserveColorProfiles?: boolean; @@ -112,7 +115,7 @@ export interface MetadataChange { export interface CleanResult { readonly output: Uint8Array; - readonly format: "jpeg" | "webp"; + readonly format: "jpeg" | "webp" | "png"; readonly report: MetadataReport; readonly removed: readonly MetadataChange[]; readonly preserved: readonly MetadataChange[]; @@ -126,13 +129,16 @@ export interface VerificationPolicy { readonly xmp?: VerificationExpectation; readonly iptc?: VerificationExpectation; readonly comments?: VerificationExpectation; + readonly textMetadata?: VerificationExpectation; + readonly timestamps?: VerificationExpectation; readonly icc?: VerificationExpectation; readonly requireNoPrivacyRelevantMetadata?: boolean; readonly limits?: Partial; } export interface VerificationCheck { - readonly namespace: "exif" | "xmp" | "iptc" | "jpeg-comment" | "icc"; + readonly namespace: + "exif" | "xmp" | "iptc" | "jpeg-comment" | "png-text" | "png-time" | "icc"; readonly expected: Exclude; readonly actual: "absent" | "present"; readonly passed: boolean; diff --git a/src/index.ts b/src/index.ts index 452dcb8..b5603b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ export { inspectMetadata } from "./inspect.js"; export { cleanMetadata, DEFAULT_JPEG_CLEANING_POLICY } from "./policy/clean.js"; +export { DEFAULT_PNG_CLEANING_POLICY } from "./png/clean.js"; export { DEFAULT_WEBP_CLEANING_POLICY } from "./webp/clean.js"; export { DEFAULT_JPEG_VERIFICATION_POLICY, + DEFAULT_PNG_VERIFICATION_POLICY, DEFAULT_WEBP_VERIFICATION_POLICY, verifyMetadata, } from "./verify/verify.js"; @@ -10,6 +12,7 @@ export { export { BinaryBoundsError, IncompleteJpegError, + IncompletePngError, IncompleteWebPError, InputLimitExceededError, InvalidParseLimitError, diff --git a/src/inspect.ts b/src/inspect.ts index f22d842..2912b15 100644 --- a/src/inspect.ts +++ b/src/inspect.ts @@ -2,16 +2,40 @@ import { ByteReader, toUint8Array } from "./core/binary/index.js"; import { detectFormat } from "./core/detect-format.js"; import { InputLimitExceededError } from "./core/errors.js"; import { DEFAULT_PARSE_LIMITS, resolveParseLimit } from "./core/limits.js"; +import type { ParseLimits } from "./core/limits.js"; import type { BinaryInput, InspectOptions, MetadataReport, } from "./core/types.js"; +import type { TiffParseLimits } from "./exif/tiff.js"; import { inspectJpegMetadata } from "./jpeg/metadata.js"; import { parseJpeg } from "./jpeg/parser.js"; +import { inspectPngMetadata } from "./png/metadata.js"; +import { parsePng } from "./png/parser.js"; import { inspectWebPMetadata } from "./webp/metadata.js"; import { parseWebP } from "./webp/parser.js"; +function resolveTiffLimits( + limits: Partial | undefined, + enabled: boolean, +): TiffParseLimits { + return { + maxIfdEntries: enabled + ? resolveParseLimit("maxIfdEntries", limits?.maxIfdEntries) + : DEFAULT_PARSE_LIMITS.maxIfdEntries, + maxIfdDepth: enabled + ? resolveParseLimit("maxIfdDepth", limits?.maxIfdDepth) + : DEFAULT_PARSE_LIMITS.maxIfdDepth, + maxMetadataEntries: enabled + ? resolveParseLimit("maxMetadataEntries", limits?.maxMetadataEntries) + : DEFAULT_PARSE_LIMITS.maxMetadataEntries, + maxStringBytes: enabled + ? resolveParseLimit("maxStringBytes", limits?.maxStringBytes) + : DEFAULT_PARSE_LIMITS.maxStringBytes, + }; +} + export function inspectMetadata( input: BinaryInput, options?: InspectOptions, @@ -29,32 +53,18 @@ export function inspectMetadata( const reader = new ByteReader(bytes); const format = detectFormat(reader); if (format === "jpeg") { - const maxSegments = resolveParseLimit( - "maxSegments", - options?.limits?.maxSegments, + const jpeg = parseJpeg( + reader, + resolveParseLimit("maxSegments", options?.limits?.maxSegments), ); - const jpeg = parseJpeg(reader, maxSegments); const hasExif = jpeg.segments.some( ({ metadataKind }) => metadataKind === "exif", ); - const tiffLimits = { - maxIfdEntries: hasExif - ? resolveParseLimit("maxIfdEntries", options?.limits?.maxIfdEntries) - : DEFAULT_PARSE_LIMITS.maxIfdEntries, - maxIfdDepth: hasExif - ? resolveParseLimit("maxIfdDepth", options?.limits?.maxIfdDepth) - : DEFAULT_PARSE_LIMITS.maxIfdDepth, - maxMetadataEntries: hasExif - ? resolveParseLimit( - "maxMetadataEntries", - options?.limits?.maxMetadataEntries, - ) - : DEFAULT_PARSE_LIMITS.maxMetadataEntries, - maxStringBytes: hasExif - ? resolveParseLimit("maxStringBytes", options?.limits?.maxStringBytes) - : DEFAULT_PARSE_LIMITS.maxStringBytes, - }; - const metadata = inspectJpegMetadata(reader, jpeg, tiffLimits); + const metadata = inspectJpegMetadata( + reader, + jpeg, + resolveTiffLimits(options?.limits, hasExif), + ); return { format, size: bytes.byteLength, @@ -83,6 +93,34 @@ export function inspectMetadata( diagnostics: webp.diagnostics, }; } + + if (format === "png") { + const png = parsePng( + reader, + resolveParseLimit("maxChunks", options?.limits?.maxChunks), + resolveParseLimit("maxStringBytes", options?.limits?.maxStringBytes), + ); + const hasExif = png.chunks.some( + ({ metadataKind }) => metadataKind === "exif", + ); + const metadata = inspectPngMetadata( + reader, + png, + resolveTiffLimits(options?.limits, hasExif), + ); + return { + format, + size: bytes.byteLength, + inspectionStatus: !png.complete + ? "container-partial" + : metadata.attemptedExifDecode + ? "metadata-partial" + : "container-inspected", + entries: metadata.entries, + diagnostics: [...png.diagnostics, ...metadata.diagnostics], + }; + } + return { format, size: bytes.byteLength, diff --git a/src/png/clean.ts b/src/png/clean.ts new file mode 100644 index 0000000..e1c7612 --- /dev/null +++ b/src/png/clean.ts @@ -0,0 +1,178 @@ +import { ByteReader } from "../core/binary/index.js"; +import { IncompletePngError, SecureMetadataError } from "../core/errors.js"; +import { resolveParseLimit } from "../core/limits.js"; +import type { + CleaningPolicy, + CleanResult, + MetadataChange, + MetadataNamespace, +} from "../core/types.js"; +import { inspectMetadata } from "../inspect.js"; +import { parsePng } from "./parser.js"; +import type { PngChunk } from "./types.js"; + +export const DEFAULT_PNG_CLEANING_POLICY = Object.freeze({ + removeExif: true, + removeXmp: true, + removeTextMetadata: true, + removeTimestamps: true, + preserveIcc: true, +}); + +interface EffectivePolicy { + readonly removeExif: boolean; + readonly removeXmp: boolean; + readonly removeTextMetadata: boolean; + readonly removeTimestamps: boolean; + readonly preserveIcc: boolean; +} + +function effectivePolicy(policy: CleaningPolicy | undefined): EffectivePolicy { + return { + removeExif: policy?.removeExif ?? true, + removeXmp: policy?.removeXmp ?? true, + removeTextMetadata: policy?.removeTextMetadata ?? true, + removeTimestamps: policy?.removeTimestamps ?? true, + preserveIcc: policy?.preserveIcc ?? policy?.preserveColorProfiles ?? true, + }; +} + +function shouldRemove(chunk: PngChunk, policy: EffectivePolicy): boolean { + switch (chunk.metadataKind) { + case "exif": + return policy.removeExif; + case "xmp": + return policy.removeXmp; + case "text": + return policy.removeTextMetadata; + case "timestamp": + return policy.removeTimestamps; + case "icc": + return !policy.preserveIcc; + default: + return false; + } +} + +function changeFor( + chunk: PngChunk, + action: MetadataChange["action"], +): MetadataChange { + let namespace: MetadataNamespace = "unknown"; + let name = `Unknown ${chunk.fourCC} chunk`; + switch (chunk.metadataKind) { + case "exif": + namespace = "exif"; + name = "PNG EXIF container"; + break; + case "xmp": + namespace = "xmp"; + name = "PNG XMP iTXt container"; + break; + case "text": + namespace = "png-text"; + name = `${chunk.fourCC} metadata`; + break; + case "timestamp": + namespace = "png-time"; + name = "PNG modification time"; + break; + case "icc": + namespace = "icc"; + name = "PNG ICC profile container"; + break; + } + + return { + namespace, + action, + name, + source: { + format: "png", + container: "png-chunk", + offset: chunk.offset, + length: chunk.totalLength, + chunkType: chunk.fourCC, + }, + }; +} + +function outputError(message: string): SecureMetadataError { + return new SecureMetadataError(message, "CLEAN_OUTPUT_SIZE_INVALID"); +} + +export function cleanPng( + bytes: Uint8Array, + policy?: CleaningPolicy, +): CleanResult { + const parsed = parsePng( + new ByteReader(bytes), + resolveParseLimit("maxChunks", policy?.limits?.maxChunks), + resolveParseLimit("maxStringBytes", policy?.limits?.maxStringBytes), + ); + if (!parsed.complete) { + throw new IncompletePngError("cleanMetadata", parsed.diagnostics); + } + + const resolved = effectivePolicy(policy); + const removals = parsed.chunks.filter((chunk) => + shouldRemove(chunk, resolved), + ); + const retained = parsed.chunks.filter( + (chunk) => !shouldRemove(chunk, resolved), + ); + let containerLength = 8; + for (const chunk of retained) { + containerLength += chunk.totalLength; + if ( + !Number.isSafeInteger(containerLength) || + containerLength > parsed.containerLength + ) { + throw outputError("PNG cleaner output container size is invalid."); + } + } + + const trailingLength = bytes.byteLength - parsed.containerLength; + const outputLength = containerLength + trailingLength; + if ( + !Number.isSafeInteger(outputLength) || + outputLength < 8 || + outputLength > bytes.byteLength + ) { + throw outputError("PNG cleaner output size is invalid."); + } + + const output = new Uint8Array(outputLength); + output.set(bytes.subarray(0, 8)); + let outputOffset = 8; + for (const chunk of retained) { + output.set( + bytes.subarray(chunk.offset, chunk.offset + chunk.totalLength), + outputOffset, + ); + outputOffset += chunk.totalLength; + } + output.set(bytes.subarray(parsed.containerLength), outputOffset); + + const report = inspectMetadata( + output, + policy?.limits === undefined ? undefined : { limits: policy.limits }, + ); + if (report.inspectionStatus === "container-partial") { + throw new IncompletePngError("cleanMetadata", report.diagnostics); + } + + return { + output, + format: "png", + report, + removed: removals.map((chunk) => changeFor(chunk, "removed")), + preserved: retained + .filter( + ({ kind, metadataKind }) => + kind === "unknown" || metadataKind !== undefined, + ) + .map((chunk) => changeFor(chunk, "preserved")), + diagnostics: report.diagnostics, + }; +} diff --git a/src/png/metadata.ts b/src/png/metadata.ts new file mode 100644 index 0000000..d461d5b --- /dev/null +++ b/src/png/metadata.ts @@ -0,0 +1,111 @@ +import { type ByteReader } from "../core/binary/index.js"; +import type { Diagnostic } from "../core/diagnostics.js"; +import type { MetadataEntry } from "../core/types.js"; +import { + metadataEntriesFromTiff, + relocateTiffDiagnostics, +} from "../exif/metadata.js"; +import { parseTiff, type TiffParseLimits } from "../exif/tiff.js"; +import type { PngChunk, PngParseResult } from "./types.js"; + +export interface PngMetadataInspection { + readonly entries: readonly MetadataEntry[]; + readonly diagnostics: readonly Diagnostic[]; + readonly attemptedExifDecode: boolean; +} + +function source(chunk: PngChunk): MetadataEntry["source"] { + return { + format: "png", + container: "png-chunk", + offset: chunk.offset, + length: chunk.totalLength, + chunkType: chunk.fourCC, + }; +} + +export function inspectPngMetadata( + reader: ByteReader, + result: PngParseResult, + tiffLimits: TiffParseLimits, +): PngMetadataInspection { + const entries: MetadataEntry[] = []; + const diagnostics: Diagnostic[] = []; + let attemptedExifDecode = false; + + for (const chunk of result.chunks) { + switch (chunk.metadataKind) { + case "exif": { + entries.push({ + id: `png-exif-${String(chunk.offset)}`, + namespace: "exif", + name: "PNG EXIF container", + category: "unknown", + privacy: "potentially-sensitive", + source: source(chunk), + }); + attemptedExifDecode = true; + const tiff = parseTiff( + reader.slice(chunk.dataOffset, chunk.dataLength), + tiffLimits, + ); + entries.push( + ...metadataEntriesFromTiff(tiff, { + format: "png", + baseOffset: chunk.dataOffset, + idPrefix: `png-tiff-${String(chunk.offset)}`, + }), + ); + diagnostics.push( + ...relocateTiffDiagnostics(tiff.diagnostics, chunk.dataOffset), + ); + break; + } + case "xmp": + entries.push({ + id: `png-xmp-${String(chunk.offset)}`, + namespace: "xmp", + name: "PNG XMP iTXt container", + category: "unknown", + privacy: "potentially-sensitive", + source: source(chunk), + }); + break; + case "text": + entries.push({ + id: `png-text-${String(chunk.offset)}`, + namespace: "png-text", + name: + chunk.keyword === undefined + ? `${chunk.fourCC} metadata` + : `${chunk.fourCC} metadata (${chunk.keyword})`, + category: "description", + privacy: "potentially-sensitive", + source: source(chunk), + }); + break; + case "timestamp": + entries.push({ + id: `png-time-${String(chunk.offset)}`, + namespace: "png-time", + name: "PNG modification time", + category: "timestamp", + privacy: "potentially-sensitive", + source: source(chunk), + }); + break; + case "icc": + entries.push({ + id: `png-icc-${String(chunk.offset)}`, + namespace: "icc", + name: "PNG ICC profile container", + category: "color", + privacy: "non-sensitive", + source: source(chunk), + }); + break; + } + } + + return { entries, diagnostics, attemptedExifDecode }; +} diff --git a/src/policy/clean.ts b/src/policy/clean.ts index ada7ce4..1faa06a 100644 --- a/src/policy/clean.ts +++ b/src/policy/clean.ts @@ -17,6 +17,7 @@ import type { import { inspectMetadata } from "../inspect.js"; import { parseJpeg } from "../jpeg/parser.js"; import type { JpegSegment } from "../jpeg/types.js"; +import { cleanPng } from "../png/clean.js"; import { cleanWebP } from "../webp/clean.js"; export const DEFAULT_JPEG_CLEANING_POLICY = Object.freeze({ @@ -205,6 +206,9 @@ export function cleanMetadata( const reader = new ByteReader(bytes); const format = detectFormat(reader); + if (format === "png") { + return cleanPng(bytes, policy); + } if (format === "webp") { return cleanWebP(bytes, policy); } diff --git a/src/verify/verify.ts b/src/verify/verify.ts index eacf0c2..7149300 100644 --- a/src/verify/verify.ts +++ b/src/verify/verify.ts @@ -1,5 +1,6 @@ import { IncompleteJpegError, + IncompletePngError, IncompleteWebPError, UnsupportedFormatError, } from "../core/errors.js"; @@ -26,6 +27,14 @@ export const DEFAULT_WEBP_VERIFICATION_POLICY = Object.freeze({ icc: "ignore", } satisfies Record); +export const DEFAULT_PNG_VERIFICATION_POLICY = Object.freeze({ + exif: "absent", + xmp: "absent", + textMetadata: "absent", + timestamps: "absent", + icc: "ignore", +} satisfies Record); + export function verifyMetadata( input: BinaryInput, expectation?: VerificationPolicy, @@ -44,6 +53,10 @@ export function verifyMetadata( if (report.inspectionStatus === "container-partial") { throw new IncompleteWebPError("verifyMetadata", report.diagnostics); } + } else if (report.format === "png") { + if (report.inspectionStatus === "container-partial") { + throw new IncompletePngError("verifyMetadata", report.diagnostics); + } } else { throw new UnsupportedFormatError("verifyMetadata", report.format); } @@ -52,22 +65,32 @@ export function verifyMetadata( expectation?.requireNoPrivacyRelevantMetadata === false ? "ignore" : "absent"; - const expected: Partial< + let expected: Partial< Record - > = - report.format === "jpeg" - ? { - exif: expectation?.exif ?? privacyDefault, - xmp: expectation?.xmp ?? privacyDefault, - iptc: expectation?.iptc ?? privacyDefault, - "jpeg-comment": expectation?.comments ?? privacyDefault, - icc: expectation?.icc ?? "ignore", - } - : { - exif: expectation?.exif ?? privacyDefault, - xmp: expectation?.xmp ?? privacyDefault, - icc: expectation?.icc ?? "ignore", - }; + >; + if (report.format === "jpeg") { + expected = { + exif: expectation?.exif ?? privacyDefault, + xmp: expectation?.xmp ?? privacyDefault, + iptc: expectation?.iptc ?? privacyDefault, + "jpeg-comment": expectation?.comments ?? privacyDefault, + icc: expectation?.icc ?? "ignore", + }; + } else if (report.format === "webp") { + expected = { + exif: expectation?.exif ?? privacyDefault, + xmp: expectation?.xmp ?? privacyDefault, + icc: expectation?.icc ?? "ignore", + }; + } else { + expected = { + exif: expectation?.exif ?? privacyDefault, + xmp: expectation?.xmp ?? privacyDefault, + "png-text": expectation?.textMetadata ?? privacyDefault, + "png-time": expectation?.timestamps ?? privacyDefault, + icc: expectation?.icc ?? "ignore", + }; + } const checks: VerificationCheck[] = []; for (const [namespace, wanted] of Object.entries(expected) as Array< From b5c04616070eee5e426681d38cd074087901f5ac Mon Sep 17 00:00:00 2001 From: maruson08 Date: Tue, 25 Aug 2026 15:31:37 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=85[Test]=20Cover=20PNG=20parser=20an?= =?UTF-8?q?d=20cleaner=20invariants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/helpers/png-builder.ts | 77 +++++++ tests/unit/jpeg-cleaner-verification.test.ts | 5 +- tests/unit/jpeg-inspection.test.ts | 9 - tests/unit/png-cleaner-verification.test.ts | 229 +++++++++++++++++++ tests/unit/png-parser.test.ts | 181 +++++++++++++++ 5 files changed, 488 insertions(+), 13 deletions(-) create mode 100644 tests/helpers/png-builder.ts create mode 100644 tests/unit/png-cleaner-verification.test.ts create mode 100644 tests/unit/png-parser.test.ts diff --git a/tests/helpers/png-builder.ts b/tests/helpers/png-builder.ts new file mode 100644 index 0000000..4530d5c --- /dev/null +++ b/tests/helpers/png-builder.ts @@ -0,0 +1,77 @@ +import { pngCrc32 } from "../../src/png/crc32.js"; + +export const PNG_SIGNATURE = Uint8Array.of( + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, +); + +export function concat(...parts: readonly Uint8Array[]): Uint8Array { + const output = new Uint8Array( + parts.reduce((length, part) => length + part.byteLength, 0), + ); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.byteLength; + } + return output; +} + +export function ascii(value: string): Uint8Array { + return Uint8Array.from(value, (character) => character.charCodeAt(0)); +} + +export function u32be(value: number): Uint8Array { + const output = new Uint8Array(4); + new DataView(output.buffer).setUint32(0, value, false); + return output; +} + +export function chunk( + type: string, + data: Uint8Array = new Uint8Array(), + crc?: number, +): Uint8Array { + const typedData = concat(ascii(type), data); + return concat( + u32be(data.byteLength), + typedData, + u32be(crc ?? pngCrc32(typedData)), + ); +} + +export function png( + chunks: readonly Uint8Array[], + trailing: Uint8Array = new Uint8Array(), +): Uint8Array { + return concat(PNG_SIGNATURE, ...chunks, trailing); +} + +export function textChunk(keyword: string, text = "value"): Uint8Array { + return chunk("tEXt", concat(ascii(keyword), Uint8Array.of(0), ascii(text))); +} + +export function ztxtChunk(keyword: string): Uint8Array { + return chunk("zTXt", concat(ascii(keyword), Uint8Array.of(0, 0, 0x78, 0x9c))); +} + +export function itxtChunk( + keyword: string, + text = "value", + compressed = false, +): Uint8Array { + return chunk( + "iTXt", + concat( + ascii(keyword), + Uint8Array.of(0, compressed ? 1 : 0, 0, 0, 0), + ascii(text), + ), + ); +} diff --git a/tests/unit/jpeg-cleaner-verification.test.ts b/tests/unit/jpeg-cleaner-verification.test.ts index 520f215..7a9c957 100644 --- a/tests/unit/jpeg-cleaner-verification.test.ts +++ b/tests/unit/jpeg-cleaner-verification.test.ts @@ -229,10 +229,7 @@ describe("JPEG verification", () => { }); }); - it.each([ - [Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a), "png"], - [new Uint8Array(), "unknown"], - ] as const)( + it.each([[new Uint8Array(), "unknown"]] as const)( "rejects unsupported $format cleaning and verification", (input, format) => { for (const operation of [cleanMetadata, verifyMetadata]) { diff --git a/tests/unit/jpeg-inspection.test.ts b/tests/unit/jpeg-inspection.test.ts index 92a8136..29568cd 100644 --- a/tests/unit/jpeg-inspection.test.ts +++ b/tests/unit/jpeg-inspection.test.ts @@ -184,13 +184,4 @@ describe("JPEG inspection safety and status", () => { expect(inspectMetadata(input)).toEqual(inspectMetadata(input)); expect(input).toEqual(before); }); - - it.each([ - [Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a), "png"], - ] as const)("leaves PNG inspection at format-only", (input, format) => { - expect(inspectMetadata(input)).toMatchObject({ - format, - inspectionStatus: "format-only", - }); - }); }); diff --git a/tests/unit/png-cleaner-verification.test.ts b/tests/unit/png-cleaner-verification.test.ts new file mode 100644 index 0000000..e72593e --- /dev/null +++ b/tests/unit/png-cleaner-verification.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; + +import { TIFF_FIELD_TYPE } from "../../src/exif/field-types.js"; +import { + cleanMetadata, + IncompletePngError, + inspectMetadata, + verifyMetadata, +} from "../../src/index.js"; +import { + chunk, + concat, + itxtChunk, + png, + PNG_SIGNATURE, + textChunk, + ztxtChunk, +} from "../helpers/png-builder.js"; +import { TiffBuilder } from "../helpers/tiff-builder.js"; + +function canonicalFixture() { + const ihdr = chunk("IHDR", new Uint8Array(13)); + const gamma = chunk("gAMA", Uint8Array.of(0, 0, 0xb1, 0x8f)); + const icc = chunk("iCCP", Uint8Array.of(0x70, 0, 0, 0x78, 0x9c)); + const text = textChunk("Author", "Ada"); + const compressedText = ztxtChunk("Comment"); + const internationalText = itxtChunk("Description", "private"); + const xmp = itxtChunk("XML:com.adobe.xmp", "packet", true); + const exif = chunk("eXIf", new TiffBuilder().ifd(8, []).finish()); + const unknown = chunk("vpAg", Uint8Array.of(0xde, 0xad)); + const animation = [ + chunk("acTL", new Uint8Array(8)), + chunk("fcTL", new Uint8Array(26)), + chunk("fdAT", Uint8Array.of(0, 0, 0, 1, 0xaa)), + ]; + const image = [ + chunk("IDAT", Uint8Array.of(1, 2, 3)), + chunk("IDAT", Uint8Array.of(4, 5, 6)), + ]; + const time = chunk("tIME", Uint8Array.of(7, 0xe8, 1, 2, 3, 4, 5)); + const iend = chunk("IEND"); + const trailing = Uint8Array.of(0xfa, 0xfb, 0xfc); + + return { + input: png( + [ + ihdr, + gamma, + icc, + text, + compressedText, + internationalText, + xmp, + exif, + unknown, + ...animation, + ...image, + time, + iend, + ], + trailing, + ), + expected: png( + [ihdr, gamma, icc, unknown, ...animation, ...image, iend], + trailing, + ), + }; +} + +describe("PNG Privacy Clean", () => { + it("removes privacy chunks while preserving retained chunks, CRCs, order, APNG, IDAT, and trailing bytes exactly", () => { + const { input, expected } = canonicalFixture(); + const before = Uint8Array.from(input); + + const first = cleanMetadata(input); + const second = cleanMetadata(input); + const idempotent = cleanMetadata(first.output); + const verification = verifyMetadata(first.output, { icc: "present" }); + + expect(first.output).toEqual(expected); + expect(first.output).not.toBe(input); + expect(input).toEqual(before); + expect(second.output).toEqual(first.output); + expect(idempotent.output).toEqual(first.output); + expect(idempotent.removed).toEqual([]); + expect(first.removed.map(({ namespace }) => namespace)).toEqual([ + "png-text", + "png-text", + "png-text", + "xmp", + "exif", + "png-time", + ]); + expect(first.preserved.map(({ namespace }) => namespace)).toEqual([ + "icc", + "unknown", + ]); + expect(first.report.entries.map(({ namespace }) => namespace)).toEqual([ + "icc", + ]); + expect(verification.valid).toBe(true); + expect(verification.checks).toHaveLength(5); + }); + + it("returns a separate byte-identical output when there is nothing to remove", () => { + const input = png([ + chunk("IHDR", new Uint8Array(13)), + chunk("ABCD", Uint8Array.of(1)), + chunk("IDAT", Uint8Array.of(2)), + chunk("IEND"), + ]); + + const result = cleanMetadata(input); + + expect(result.output).toEqual(input); + expect(result.output).not.toBe(input); + expect(result.removed).toEqual([]); + expect(verifyMetadata(result.output).valid).toBe(true); + }); + + it("supports explicit text, timestamp, and ICC policy overrides", () => { + const text = textChunk("Author"); + const time = chunk("tIME", new Uint8Array(7)); + const input = png([ + text, + itxtChunk("XML:com.adobe.xmp"), + chunk("eXIf", new TiffBuilder().ifd(8, []).finish()), + chunk("iCCP"), + time, + chunk("IEND"), + ]); + + const result = cleanMetadata(input, { + removeTextMetadata: false, + removeTimestamps: false, + preserveIcc: false, + }); + + expect(result.output).toEqual(png([text, time, chunk("IEND")])); + expect(result.removed.map(({ namespace }) => namespace)).toEqual([ + "xmp", + "exif", + "icc", + ]); + expect(result.preserved.map(({ namespace }) => namespace)).toEqual([ + "png-text", + "png-time", + ]); + }); + + it.each([ + PNG_SIGNATURE, + concat(PNG_SIGNATURE, Uint8Array.of(0, 0, 0, 0, 0x49)), + png([chunk("IDAT")]), + ])("rejects structurally incomplete PNG without partial output", (input) => { + expect(() => cleanMetadata(input)).toThrowError(IncompletePngError); + expect(() => verifyMetadata(input)).toThrowError(IncompletePngError); + }); + + it("removes bounded EXIF even when its TIFF payload is malformed", () => { + const input = png([chunk("eXIf", Uint8Array.of(0x49)), chunk("IEND")]); + + expect(inspectMetadata(input).diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_TRUNCATED_HEADER" }), + ); + + const result = cleanMetadata(input); + expect(result.output).toEqual(png([chunk("IEND")])); + expect(result.removed).toEqual([ + expect.objectContaining({ namespace: "exif", action: "removed" }), + ]); + expect(verifyMetadata(result.output).valid).toBe(true); + }); + + it("uses eXIf data byte zero as the shared TIFF origin", () => { + const tiff = new TiffBuilder() + .ifd(8, [ + { + tag: 0x010f, + type: TIFF_FIELD_TYPE.ASCII, + count: 5, + valueOffset: 40, + }, + ]) + .ascii(40, "ACME") + .finish(); + const input = png([chunk("eXIf", tiff), chunk("IEND")]); + const report = inspectMetadata(input); + const make = report.entries.find(({ source }) => source.tiffTag === 0x010f); + + expect(report.inspectionStatus).toBe("metadata-partial"); + expect(make).toMatchObject({ value: "ACME", namespace: "exif" }); + expect(make?.source.offset).toBe(8 + 8 + 10); + }); + + it("honors the exact supplied Uint8Array subview", () => { + const embedded = canonicalFixture().input; + const backing = concat(Uint8Array.of(1, 2, 3), embedded, Uint8Array.of(4)); + const view = new Uint8Array( + backing.buffer, + backing.byteOffset + 3, + embedded.byteLength, + ); + + expect(cleanMetadata(view).output).toEqual(canonicalFixture().expected); + }); +}); + +describe("PNG verification", () => { + it("returns precise failures for retained privacy metadata", () => { + const result = verifyMetadata( + png([textChunk("Author"), chunk("tIME"), chunk("IEND")]), + ); + + expect(result.valid).toBe(false); + expect(result.checks).toContainEqual({ + namespace: "png-text", + expected: "absent", + actual: "present", + passed: false, + }); + expect(result.checks).toContainEqual({ + namespace: "png-time", + expected: "absent", + actual: "present", + passed: false, + }); + }); +}); diff --git a/tests/unit/png-parser.test.ts b/tests/unit/png-parser.test.ts new file mode 100644 index 0000000..c43fa24 --- /dev/null +++ b/tests/unit/png-parser.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { ByteReader } from "../../src/core/binary/index.js"; +import { pngCrc32 } from "../../src/png/crc32.js"; +import { parsePng } from "../../src/png/parser.js"; +import { + ascii, + chunk, + concat, + itxtChunk, + png, + PNG_SIGNATURE, + textChunk, + u32be, + ztxtChunk, +} from "../helpers/png-builder.js"; + +const parse = (input: Uint8Array, maxChunks = 64, maxStringBytes = 1_024) => + parsePng(new ByteReader(input), maxChunks, maxStringBytes); + +describe("PNG bounded container parser", () => { + it("classifies image, metadata, rendering, APNG, and unknown chunks", () => { + const input = png([ + chunk("IHDR", new Uint8Array(13)), + chunk("PLTE"), + chunk("IDAT", Uint8Array.of(1, 2)), + textChunk("Author"), + ztxtChunk("Comment"), + itxtChunk("Description"), + itxtChunk("XML:com.adobe.xmp", "packet", true), + chunk("eXIf"), + chunk("iCCP"), + chunk("tIME"), + chunk("gAMA"), + chunk("cHRM"), + chunk("sRGB"), + chunk("sBIT"), + chunk("pHYs"), + chunk("acTL"), + chunk("fcTL"), + chunk("fdAT"), + chunk("vpAg"), + chunk("ABCD"), + chunk("IEND"), + ]); + + const result = parse(input); + + expect(result.complete).toBe(true); + expect(result.chunks.map(({ fourCC, kind }) => [fourCC, kind])).toEqual([ + ["IHDR", "critical"], + ["PLTE", "critical"], + ["IDAT", "image"], + ["tEXt", "metadata"], + ["zTXt", "metadata"], + ["iTXt", "metadata"], + ["iTXt", "metadata"], + ["eXIf", "metadata"], + ["iCCP", "metadata"], + ["tIME", "metadata"], + ["gAMA", "color"], + ["cHRM", "color"], + ["sRGB", "color"], + ["sBIT", "color"], + ["pHYs", "color"], + ["acTL", "animation"], + ["fcTL", "animation"], + ["fdAT", "animation"], + ["vpAg", "unknown"], + ["ABCD", "critical"], + ["IEND", "critical"], + ]); + expect( + result.chunks.slice(3, 10).map(({ metadataKind }) => metadataKind), + ).toEqual(["text", "text", "text", "xmp", "exif", "icc", "timestamp"]); + expect(result.chunks[4]).toMatchObject({ + keyword: "Comment", + textCompressed: true, + ancillary: true, + }); + expect(result.chunks[6]).toMatchObject({ + keyword: "XML:com.adobe.xmp", + textCompressed: true, + }); + expect(result.chunks[19]?.ancillary).toBe(false); + }); + + it("validates CRCs without making a bounded file structurally incomplete", () => { + expect(pngCrc32(ascii("123456789"))).toBe(0xcbf43926); + const result = parse( + png([chunk("IDAT", Uint8Array.of(1), 0), chunk("IEND")]), + ); + + expect(result.complete).toBe(true); + expect(result.chunks[0]?.crcValid).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "PNG_INVALID_CRC", severity: "warning" }), + ); + }); + + it("stops at IEND and records but does not parse trailing data", () => { + const trailing = concat(u32be(0), ascii("tEXt"), u32be(0)); + const result = parse(png([chunk("IEND")], trailing)); + + expect(result.chunks).toHaveLength(1); + expect(result.containerLength).toBe(PNG_SIGNATURE.byteLength + 12); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "PNG_TRAILING_DATA" }), + ); + }); + + it("bounds keyword scans and recognizes XMP only by the exact keyword", () => { + const result = parse( + png([ + textChunk("LongKeyword"), + itxtChunk("XML:com.adobe.xmpx"), + chunk("IEND"), + ]), + 64, + 4, + ); + + expect(result.complete).toBe(true); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "PNG_TEXT_LIMIT_EXCEEDED" }), + ); + expect(result.chunks[1]?.metadataKind).toBe("text"); + }); + + it.each([ + ["signature", Uint8Array.of(1, 2), "PNG_INVALID_SIGNATURE"], + [ + "length", + concat(PNG_SIGNATURE, Uint8Array.of(0)), + "PNG_TRUNCATED_CHUNK_LENGTH", + ], + [ + "type", + concat(PNG_SIGNATURE, u32be(0), Uint8Array.of(0x49)), + "PNG_TRUNCATED_CHUNK_TYPE", + ], + [ + "invalid type", + concat(PNG_SIGNATURE, u32be(0), ascii("I3ND"), u32be(0)), + "PNG_INVALID_CHUNK_TYPE", + ], + [ + "data", + concat(PNG_SIGNATURE, u32be(5), ascii("IDAT"), Uint8Array.of(1, 2)), + "PNG_TRUNCATED_CHUNK_DATA", + ], + [ + "CRC", + concat(PNG_SIGNATURE, u32be(0), ascii("IDAT"), Uint8Array.of(1, 2)), + "PNG_MISSING_CRC", + ], + [ + "IEND payload", + png([chunk("IEND", Uint8Array.of(1))]), + "PNG_INVALID_IEND", + ], + ["IEND absence", png([chunk("IDAT")]), "PNG_MISSING_IEND"], + ] as const)("rejects malformed PNG %s", (_name, input, code) => { + const result = parse(input); + + expect(result.complete).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code }), + ); + }); + + it("enforces the chunk-count limit before reading another chunk", () => { + const result = parse(png([chunk("IDAT"), chunk("IEND")]), 1); + + expect(result.complete).toBe(false); + expect(result.chunks).toHaveLength(1); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "PNG_CHUNK_LIMIT_EXCEEDED" }), + ); + }); +}); From 7ab5fb987553e614ec7ea386cd164ff47c78164f Mon Sep 17 00:00:00 2001 From: maruson08 Date: Tue, 25 Aug 2026 15:31:47 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=93=84[Docs]=20Document=20PNG=20metad?= =?UTF-8?q?ata=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 ++++ README.md | 31 +++++++++------------ docs/architecture.md | 57 ++++++++++++--------------------------- docs/cleaning-policy.md | 59 +++++++++++++++++++++------------------- docs/format-support.md | 59 +++++++++++++++++++++------------------- docs/security-model.md | 60 +++++++++++++---------------------------- 6 files changed, 116 insertions(+), 155 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 668d9a5..8393818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes will be documented here. The project intends to follow seman ### Added +- Bounded PNG chunk parsing with chunk-count, IEND, trailing-data, type, range, CRC-field, and compact CRC-32 validation. +- PNG text, exact XMP `iTXt`, `eXIf`, ICC, timestamp, rendering/color, APNG, and unknown ancillary classification. +- Shared TIFF/EXIF field decoding for exact bounded PNG `eXIf` data views. +- Deterministic PNG Privacy Clean and verification with single-allocation reconstruction and byte-identical retained chunks, CRCs, IDAT/APNG data, and trailing bytes. + - Bounded WebP RIFF/chunk parsing with declared-size, padding, VP8X, and chunk-count validation. - WebP EXIF, XMP, and ICCP container inspection with image/alpha/animation distinction. - Deterministic WebP Privacy Clean with RIFF-size and VP8X metadata-flag repair. diff --git a/README.md b/README.md index 1811dc7..ac1cfc0 100644 --- a/README.md +++ b/README.md @@ -7,23 +7,18 @@ Current implementation: - bounded binary input and endian-aware read core; -- JPEG, PNG, and WebP signature detection; -- bounded JPEG marker, segment, and entropy-scan traversal; -- JPEG EXIF, XMP, ICC, Photoshop/IPTC, and comment container detection; -- shared little- and big-endian TIFF/EXIF decoder; -- iterative IFD0, ExifIFD, GPSIFD, and next-IFD traversal with cycle and depth protection; -- common TIFF, EXIF, and GPS field decoding with exact rational values; -- deterministic whole-segment JPEG Privacy Clean with byte-preserving reconstruction; -- structured JPEG verification for observable container presence or absence; -- bounded WebP RIFF/chunk inspection with EXIF, XMP, and ICCP container detection; -- deterministic WebP Privacy Clean with RIFF-size and VP8X metadata-flag repair; -- structured WebP verification for EXIF, XMP, and ICC presence. - -Not implemented: MakerNote or thumbnail decoding, XMP/IPTC/ICC payload parsing, WebP EXIF field decoding, and PNG container parsing, cleaning, or verification. +- JPEG inspection, common TIFF/EXIF decoding, whole-segment Privacy Clean, and verification; +- WebP RIFF/chunk inspection, EXIF/XMP/ICC container detection, Privacy Clean, and verification; +- PNG chunk inspection with text, XMP, EXIF, ICC, timestamp, rendering, and APNG classification; +- shared TIFF/EXIF decoding for JPEG EXIF and PNG `eXIf` payloads; +- deterministic PNG Privacy Clean and verification with retained chunks and CRC bytes preserved exactly; +- iterative IFD0, ExifIFD, GPSIFD, and next-IFD traversal with cycle and depth protection. + +Not implemented: MakerNote or thumbnail decoding, XMP/IPTC/ICC payload parsing, WebP EXIF field decoding, compressed PNG text or ICC decompression, and image/pixel decoding. ## Format status -JPEG reports can be `container-inspected`, `container-partial`, or `metadata-partial`. `metadata-partial` means supported TIFF/EXIF fields were attempted while the wider metadata space remains intentionally incomplete. WebP reports are `container-inspected` or `container-partial` and expose metadata containers only. PNG remains `format-only`. See [format support](docs/format-support.md). +JPEG supports bounded inspection, common TIFF/EXIF field decoding, cleaning, and verification. WebP supports bounded RIFF/chunk inspection and container-level cleaning and verification. PNG supports bounded chunk inspection, direct shared-TIFF decoding of `eXIf`, container-level cleaning, and verification. Compressed `zTXt`, compressed `iTXt`, and `iCCP` payloads remain opaque. See [format support](docs/format-support.md). ## Installation @@ -39,17 +34,17 @@ import { } from "secure-metadata"; ``` -`inspectMetadata` accepts `Uint8Array | ArrayBuffer`, enforces relevant parser limits, and returns deterministic normalized entries. JPEG EXIF reports retain the EXIF container entry and add decoded child entries with exact TIFF tag, type, count, source offset, and path information. +`inspectMetadata` accepts `Uint8Array | ArrayBuffer`, enforces relevant parser limits, and returns deterministic normalized entries. JPEG and PNG EXIF reports retain the EXIF container entry and add decoded child entries with exact TIFF tag, type, count, source offset, and path information. GPS rational components remain exact numerator/denominator pairs; decimal coordinates are not derived. Unknown TIFF tags and MakerNote are represented structurally without dumping or recursively parsing their payloads. -`cleanMetadata` supports JPEG and WebP. JPEG Privacy Clean removes EXIF, standard/extended XMP, Photoshop/IPTC, and COM segments while preserving ICC, JFIF/JFXX, Adobe APP14, unknown APP segments, structural data, scan bytes, and trailing bytes. WebP Privacy Clean removes EXIF and XMP chunks while preserving ICCP, VP8/VP8L, VP8X, ALPH, ANIM/ANMF, unknown chunks, and trailing bytes; it repairs RIFF size and VP8X metadata flags. +`cleanMetadata` supports JPEG, WebP, and PNG. JPEG removes EXIF, XMP, Photoshop/IPTC, and comments. WebP removes EXIF and XMP while repairing RIFF size and applicable VP8X flags. PNG removes `eXIf`, XMP `iTXt`, ordinary `tEXt`/`zTXt`/`iTXt`, and `tIME`; it preserves `iCCP`, rendering/color chunks, image and APNG chunks, unknown chunks, critical chunks, and trailing bytes. All formats preserve ICC by default. -`verifyMetadata` supports `absent`, `present`, or `ignore` expectations. JPEG defaults check EXIF, XMP, IPTC, and comments; WebP defaults check EXIF and XMP. Single-file verification observes presence or absence and cannot prove that bytes came from an original file. +`verifyMetadata` supports `absent`, `present`, or `ignore` expectations. PNG defaults check EXIF, XMP, ordinary text, and timestamps, while ICC is ignored unless explicitly requested. Single-file verification observes supported container presence or absence and cannot prove provenance or pixel privacy. ## Security philosophy -Every byte is untrusted. All offsets are interpreted within bounded views, traversal is iterative and limited, repeated IFD offsets are rejected, and malformed entries recover without unchecked access. Unknown JPEG APP segments and WebP chunks remain unknown and are preserved by cleaning. See the [security model](docs/security-model.md), [architecture](docs/architecture.md), and [cleaning policy](docs/cleaning-policy.md). +Every byte is untrusted. All offsets are interpreted within bounded views, traversal is iterative and limited, and malformed structures fail without unchecked access. PNG image data and compressed metadata are never inflated. Unknown JPEG APP segments, WebP chunks, and PNG ancillary chunks are preserved by default. See the [security model](docs/security-model.md), [architecture](docs/architecture.md), and [cleaning policy](docs/cleaning-policy.md). ## Non-goals diff --git a/docs/architecture.md b/docs/architecture.md index 47bcb12..ca30407 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,63 +1,40 @@ # Architecture -`secure-metadata` is a side-effect-free binary library with format-specific containers and shared metadata decoders. +`secure-metadata` is a side-effect-free binary library with format-specific containers and a shared metadata decoder. ```text JPEG APP1 Exif\0\0 ─┐ -PNG eXIf (future) ┴──→ bounded TIFF/EXIF core → normalized entries -WebP EXIF → normalized container entry only +PNG eXIf ┴──→ bounded TIFF/EXIF core → normalized entries +WebP EXIF → normalized container entry only ``` -JPEG integration passes the TIFF decoder only the byte view after the six-byte EXIF identifier. The decoder has no JPEG marker or absolute file-offset knowledge. Every TIFF offset is relative to byte zero of that view. Integration relocates decoded source offsets and diagnostics only after parsing. +JPEG passes the TIFF decoder the view after its six-byte EXIF identifier. PNG passes the exact `eXIf` data view directly. In both cases TIFF offset zero is the beginning of that bounded view; integrations relocate source offsets and diagnostics only after parsing. ## TIFF core -The decoder explicitly validates `II` or `MM`, magic value 42, and the first IFD offset. `TiffReader` centralizes endian-aware unsigned 16-/32-bit and signed 32-bit access over the bounded binary core. - -Each IFD table is validated as a complete `2 + count × 12 + 4` byte range before entries are visited. Field sizes support BYTE, ASCII, SHORT, LONG, RATIONAL, UNDEFINED, SLONG, and SRATIONAL. Values of four bytes or fewer use the entry's inline bytes in TIFF byte order; larger values use a bounded TIFF-relative offset. - -Traversal uses a FIFO work queue. Root IFD0 has depth 1; ExifIFD, GPSIFD, and next-IFD work is queued deterministically in that order. A visited-offset set rejects cycles and repeated references. `maxIfdEntries` bounds each table, `maxIfdDepth` bounds linked depth, and `maxMetadataEntries` caps total processed entries and queued IFD work. - -## Value and entry behavior - -Supported known values are decoded without converting exact rational pairs to floating point. ASCII stops at the first NUL within its declared count and maps non-ASCII bytes conservatively. Zero rational denominators remain represented and produce diagnostics. - -Unknown tags retain namespace, tag number, TIFF type, count, entry offset, and source path without exposing arbitrary payload bytes. Duplicate tags remain separate ordered entries. MakerNote is recognized but opaque and is never interpreted as nested standard TIFF. +The decoder validates byte order, magic 42, complete IFD tables, field sizes, offset values, and linked traversal. A FIFO queue plus visited-offset set provides deterministic IFD0, ExifIFD, GPSIFD, and next-IFD traversal. `maxIfdEntries`, `maxIfdDepth`, `maxMetadataEntries`, and `maxStringBytes` bound work. Known values retain exact rationals; unknown tags remain structural, and MakerNote stays opaque. ## Inspection status -- `format-only`: signature detection only; currently PNG and unknown input. -- `container-inspected`: JPEG or WebP container traversal completed without deep metadata decoding. -- `container-partial`: JPEG or WebP traversal stopped on corruption, truncation, structural invalidity, or a limit. -- `metadata-partial`: JPEG container traversal completed and common TIFF/EXIF decoding was attempted; XMP/IPTC/ICC and unknown fields remain incomplete. +- `format-only`: unknown input where only format detection applies. +- `container-inspected`: complete JPEG, WebP, or PNG traversal without TIFF decoding. +- `container-partial`: traversal stopped on structural invalidity or a limit. +- `metadata-partial`: complete JPEG or PNG traversal where common TIFF/EXIF decoding was attempted while broader metadata remains intentionally opaque. - `metadata-inspected`: reserved for future broader decoders. -## JPEG clean and verify flow +## Cleaning flows + +JPEG and WebP use their format-specific parsers and reconstruction rules. JPEG copies retained marker/scan ranges into one output. WebP copies retained chunks, repairs RIFF size, and aligns retained VP8X metadata bits. ```text -input JPEG - → bounded JPEG parser and existing APP classification +PNG bytes + → bounded PNG chunk parser and metadata classification + → shared TIFF decoder for eXIf inspection → direct keep/remove policy - → checked retained ranges + → checked retained physical ranges → one output allocation and ordered byte copies → inspectMetadata(output) → structured verification checks ``` -The parser remains the structural source of truth. Internal rewrite ranges include marker fill bytes associated with a removed marker while public source offsets retain their existing meaning. Cleaning does not invoke TIFF decoding on the source: a structurally bounded EXIF APP1 can be removed even if its TIFF body is malformed. The post-write inspection and verifier use the normal inspection layer. - -## WebP clean and verify flow - -```text -WebP bytes - → bounded RIFF/WebP parser and FourCC classification - → direct EXIF/XMP/ICC policy - → retained chunks - → minimal VP8X metadata-bit patch - → RIFF size patch - → one output allocation and ordered chunk copies - → inspectMetadata(output) - → structured verification checks -``` - -Chunk payloads remain bounded views and image, alpha, and animation bytes are opaque. The cleaner does not synthesize VP8X; a valid retained VP8X has only its ICC, EXIF, and XMP bits aligned with actual retained chunks. Bytes outside the declared RIFF container are copied as uninterpreted trailing data. +The PNG cleaner parses the source once for boundaries, never routes decisions through semantic entries, and does not decode TIFF before removing a bounded `eXIf`. It copies the signature, retained complete chunks, and bytes after IEND. Retained length/type/data/CRC bytes and relative order are unchanged. IDAT, APNG, compressed text, and ICC payloads stay opaque. diff --git a/docs/cleaning-policy.md b/docs/cleaning-policy.md index 607efe1..5a97ef9 100644 --- a/docs/cleaning-policy.md +++ b/docs/cleaning-policy.md @@ -4,37 +4,40 @@ Privacy Clean removes complete recognized metadata containers and never decodes ## JPEG -| JPEG structure | Default action | -| -------------------------------------- | -------------- | -| EXIF APP1 | Remove | -| Standard/extended XMP APP1 | Remove | -| Photoshop/IPTC APP13 | Remove | -| COM | Remove | -| ICC APP2 | Preserve | -| JFIF/JFXX APP0 | Preserve | -| Adobe APP14 | Preserve | -| Unknown APP | Preserve | -| Structural markers and image/scan data | Preserve | -| Data after EOI | Preserve | - -JPEG v0.1 removes the entire EXIF APP1, including malformed TIFF bodies whose JPEG segment boundary is valid. Selective GPS/tag rewriting and TIFF reserialization are deferred. +| JPEG structure | Default action | +| ------------------------------------------------- | -------------- | +| EXIF APP1; standard/extended XMP APP1 | Remove | +| Photoshop/IPTC APP13; COM | Remove | +| ICC APP2; JFIF/JFXX; Adobe APP14 | Preserve | +| Unknown APP; structural/scan data; data after EOI | Preserve | ## WebP -| WebP chunk or data | Default action | -| --------------------------------- | ---------------------------------- | -| EXIF | Remove | -| XMP | Remove | -| ICCP | Preserve | -| VP8 / VP8L | Preserve | -| VP8X | Preserve; align ICC/EXIF/XMP flags | -| ALPH | Preserve | -| ANIM / ANMF | Preserve | -| Unknown chunks | Preserve | -| Data after declared RIFF boundary | Preserve | +| WebP chunk or data | Default action | +| ---------------------------------------- | ---------------------------------- | +| EXIF; XMP | Remove | +| ICCP; VP8/VP8L; ALPH; ANIM/ANMF; unknown | Preserve | +| VP8X | Preserve; align ICC/EXIF/XMP flags | +| Data after declared RIFF boundary | Preserve | + +WebP cleaning removes targeted physical chunks including padding, repairs RIFF size, and patches only the three VP8X metadata bits. No VP8X is synthesized. + +## PNG + +| PNG chunk or data | Default action | +| -------------------------------------- | -------------- | +| `eXIf` | Remove | +| XMP `iTXt` | Remove | +| Ordinary `tEXt`, `zTXt`, and `iTXt` | Remove | +| `tIME` | Remove | +| `iCCP` | Preserve | +| `gAMA`, `cHRM`, `sRGB`, `sBIT`, `pHYs` | Preserve | +| `IDAT`; APNG structure | Preserve | +| Unknown ancillary; critical chunks | Preserve | +| Data after `IEND` | Preserve | -WebP cleaning removes every targeted physical chunk including odd-byte padding, repairs the RIFF size, and patches only the three VP8X metadata feature bits. Alpha, animation, reserved, and other VP8X bits remain unchanged. No VP8X is synthesized. Structurally bounded malformed metadata payloads remain removable. +Compressed text and ICC payloads are removed or preserved as whole chunks without decompression. Retained physical chunks—including their original CRC bytes—and trailing data remain byte-identical and ordered. -The shared policy fields `removeExif`, `removeXmp`, and `preserveIcc` apply to both formats. JPEG-only `removeIptc` and `removeComments` have no WebP effect. `preserveColorProfiles` remains a deprecated alias for `preserveIcc`. Unknown removal is intentionally unavailable. +The shared fields `removeExif`, `removeXmp`, and `preserveIcc` apply across supported formats. PNG also uses `removeTextMetadata` and `removeTimestamps`; JPEG-only `removeIptc` and `removeComments` have no PNG effect. `preserveColorProfiles` remains a deprecated alias for `preserveIcc`. Unknown removal is intentionally unavailable. -`cleanMetadata` returns a new `Uint8Array`, container-level change evidence, diagnostics, and an inspection report of its output. Structurally incomplete input is rejected before output allocation. PNG and unknown formats return a typed unsupported-format error. +`cleanMetadata` always returns a new `Uint8Array`, change evidence, diagnostics, and a re-inspection report. Unsafe container boundaries reject cleaning before output. Unknown formats return a typed unsupported-format error. diff --git a/docs/format-support.md b/docs/format-support.md index 718b770..5af4cfb 100644 --- a/docs/format-support.md +++ b/docs/format-support.md @@ -1,33 +1,38 @@ # Format Support -| Capability | JPEG | WebP | PNG | -| ----------------------------- | ---------------- | -------------- | -------------- | -| Signature detection | Supported | Supported | Supported | -| Bounded container traversal | Supported | Supported | Not yet | -| EXIF container detection | Supported | Supported | Not yet | -| XMP/ICC container detection | Supported | Supported | Not yet | -| TIFF/EXIF field decoding | Supported subset | Container only | Not integrated | -| MakerNote decoding | Not supported | Not supported | Not supported | -| XMP/IPTC/ICC payload decoding | Not supported | Not supported | Not supported | -| Whole-container cleaning | Supported | Supported | Not yet | -| Structured verification | Supported | Supported | Not yet | - -## TIFF/EXIF subset - -JPEG integrates the shared little- and big-endian TIFF decoder. Traversal covers IFD0, ExifIFDPointer, GPSInfoIFDPointer, and next-IFD links with table, entry, depth, offset, and cycle checks. - -Decoded IFD0 tags: ImageDescription, Make, Model, Orientation, Software, DateTime, Artist, and Copyright. - -Decoded ExifIFD tags: ExposureTime, FNumber, PhotographicSensitivity, ExifVersion, DateTimeOriginal, DateTimeDigitized, FocalLength, PixelXDimension, PixelYDimension, and FocalLengthIn35mmFilm. MakerNote remains opaque. - -Decoded GPS tags include version, latitude/longitude components and references, altitude, time, and date. Coordinates remain exact rational components; decimal coordinates are not derived. - -WebP EXIF is intentionally container detection only. Its bounded chunk payload is not passed to TIFF decoding because Sprint 5 does not guess a prefix or offset base. +| Capability | JPEG | WebP | PNG | +| ----------------------------- | ---------------- | -------------- | ------------------------------------- | +| Signature detection | Supported | Supported | Supported | +| Bounded container traversal | Supported | Supported | Supported | +| EXIF container detection | Supported | Supported | Supported (`eXIf`) | +| XMP detection | Supported | Supported | Supported (exact XMP `iTXt` keyword) | +| ICC detection | Supported | Supported | Supported (`iCCP`) | +| TIFF/EXIF field decoding | Supported subset | Container only | Supported subset via shared TIFF core | +| MakerNote decoding | Not supported | Not supported | Not supported | +| XMP/IPTC/ICC payload decoding | Not supported | Not supported | Not supported | +| Whole-container cleaning | Supported | Supported | Supported | +| Structured verification | Supported | Supported | Supported | + +## PNG detail + +| PNG capability | Status | +| ----------------------------------------- | -------------------------------- | +| Signature and chunk traversal | Supported | +| `tEXt`, `zTXt`, `iTXt` detection | Supported | +| Exact XMP `iTXt` detection | Supported | +| `eXIf` detection and shared TIFF decoding | Supported | +| `iCCP` and `tIME` detection | Supported | +| CRC-32 validation | Supported; mismatch is a warning | +| Rendering/color and APNG classification | Supported at chunk level | +| Compressed text decompression | Not supported | +| ICC decompression | Not supported | +| IDAT/APNG decoding | Not supported | +| Privacy Clean and verification | Supported | + +The shared TIFF subset covers common IFD0, ExifIFD, GPSIFD, and next-IFD entries with bounded tables, offsets, depth, counts, and cycles. PNG `eXIf` begins directly at TIFF byte zero; JPEG begins after `Exif\0\0`. WebP EXIF remains container-only because its payload convention is not guessed. ## Container cleaning and verification -JPEG Privacy Clean removes EXIF, standard/extended XMP, Photoshop/IPTC, and comments while preserving ICC, application/rendering structures, scan data, unknown APP segments, and trailing bytes. - -WebP Privacy Clean removes EXIF and XMP chunks while preserving ICCP, VP8/VP8L, VP8X, ALPH, ANIM/ANMF, unknown chunks, original padding on retained chunks, and trailing bytes. It repairs RIFF size and retained VP8X metadata flags. +JPEG removes EXIF, XMP, Photoshop/IPTC, and comments. WebP removes EXIF and XMP, repairs RIFF size, and aligns retained VP8X flags. PNG removes `eXIf`, XMP and ordinary text chunks, and `tIME`; it preserves ICC, rendering/color, IDAT, APNG, unknown, critical, CRC, and trailing bytes by default. -Verification reports observable supported metadata-container presence or absence. It does not decode XMP/IPTC/ICC, prove byte provenance, or prove complete removal of personal information. PNG remains format detection only. +Verification reports observable supported metadata-container presence or absence. It does not decode XMP/IPTC/ICC or compressed PNG text, prove byte provenance, or prove complete removal of personal information. diff --git a/docs/security-model.md b/docs/security-model.md index 2a0e6b1..3a5b5a0 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -4,55 +4,31 @@ Binary metadata parsing processes attacker-controlled structures, sizes, offsets ## Invariants -1. All binary input is untrusted and all reads use bounded primitives. -2. Parsers perform no unchecked offset arithmetic or unbounded recursion. -3. Parser traversal and attacker-controlled counts are hard bounded. -4. Core functions make no network requests and access no filesystem or DOM APIs. -5. Image pixel payloads are never decoded. -6. Unknown metadata is not deleted or assigned speculative meaning. -7. JPEG and WebP cleaning preserve ICC, unknown structures, and image/rendering structures by default. -8. Cleaner output is re-inspected before it is returned. -9. Metadata absence never proves an image contains no private information. -10. Steganography detection, malware scanning, visual redaction, and pixel privacy analysis are outside scope. - -## Bounded binary and JPEG properties - -Offsets and lengths must be non-negative safe integers. Ranges use subtraction-based capacity checks before access. JPEG declared segment lengths must fit completely; marker and scan loops always advance or return. `FF 00`, restart markers, multiple scans, EOI, and marker limits are handled without entropy decoding or payload copies. - -## TIFF-specific properties - -- Byte order is accepted only from explicit `II` or `MM`; magic 42 is validated before traversal. -- The decoder receives a bounded TIFF-only view. All TIFF and IFD offsets are relative to its header, never to JPEG or APP1. -- A full IFD table range, including the next-IFD pointer, is validated before entry iteration. -- `count × typeSize` uses checked safe-integer multiplication before range calculations. -- Inline values use their actual byte region and endian order; offset values must fit completely within the TIFF view. -- `maxIfdEntries` bounds per-IFD work, `maxIfdDepth` bounds linked depth, and `maxMetadataEntries` caps total entry and queue work. -- A visited-offset set rejects cyclic and repeated IFD references. -- Unsupported types and invalid individual values produce diagnostics while later safe entries remain recoverable. -- Large or extreme values are rejected before allocation or reading. Known numeric component decoding has an additional small hard cap. -- Duplicate tags remain ordered; unknown tags retain structure without arbitrary binary payload copies. -- MakerNote stays opaque and is never recursively interpreted. -- RATIONAL and SRATIONAL preserve exact components; zero denominators produce diagnostics rather than division. -- No thumbnail, TIFF image, JPEG image, or pixel data is decoded. - -Every traversal or decoding loop has a validated finite count or advances a bounded cursor. Native `DataView` bounds errors are not used as control flow. +1. All input is untrusted and all reads use bounded primitives. +2. Parsers use checked range arithmetic, finite iteration limits, and no unbounded recursion. +3. Core functions make no network requests and access no filesystem or DOM APIs. +4. Image pixels and compressed image/metadata payloads are never decoded. +5. Unknown structures are not assigned speculative meaning or removed by default. +6. ICC, rendering/color, and image structures are preserved by default. +7. Cleaner output is re-inspected before return. +8. Metadata absence never proves an image has no private pixels, unsupported metadata, steganography, malware, or provenance concerns. -## Environment and dependencies +## Bounded binary and TIFF properties -Core code is local-only and side-effect-free. It has no network, analytics, telemetry, filesystem, DOM, or pixel-codec behavior. The package has zero runtime dependencies. +Offsets and lengths must be non-negative safe integers. Ranges use subtraction-based capacity checks before access. TIFF decoders receive bounded TIFF-only views: after the JPEG EXIF identifier or at PNG `eXIf` data byte zero. IFD table size, `count × typeSize`, inline/offset value locations, linked depth, entry count, metadata count, string length, and cycles are checked. Unsupported values produce diagnostics; MakerNote, thumbnails, and pixels remain opaque. -## JPEG cleaning properties +## JPEG and WebP properties -Cleaning proceeds only after bounded traversal reaches EOI. Truncated lengths, invalid marker structure, unterminated scans, and segment-limit failures produce a typed `IncompleteJpegError`; no partial output is returned. TIFF validity is not required to remove a structurally bounded EXIF APP1. +JPEG traversal validates marker and scan progression through EOI before cleaning; malformed structure produces `IncompleteJpegError`. Retained scan, marker, and trailing bytes are copied in one allocation. WebP validates the RIFF boundary, complete chunk headers/payload/padding, VP8X constraints, and chunk limits; malformed structure produces `IncompleteWebPError`. Its cleaner copies retained chunks, repairs RIFF size, and updates only applicable VP8X metadata bits. -Removal uses checked, non-overlapping parser ranges. Output length is a safe integer no larger than input length, one output buffer is allocated, and retained ranges are copied in original order. Entropy-coded bytes, restart markers, retained marker fill, structural segments, and bytes after EOI are neither decoded nor regenerated. Exact `Uint8Array` views are honored and caller input is never mutated. +## PNG parsing and cleaning properties -The default policy preserves every ICC and unknown APP segment. Verification proves only the requested observable container state supported by inspection. It does not prove provenance, byte preservation without an original, absence of unknown metadata, or absence of personal information in pixels or unsupported structures. +The PNG parser requires the complete signature and validates every big-endian length, four-letter type, data range, and CRC field before advancing. `maxChunks` bounds traversal. IEND stops logical parsing; trailing bytes are warned about and preserved rather than interpreted. Missing IEND, truncated fields, impossible ranges, and limit failures produce `IncompletePngError` before output. -## WebP parsing and cleaning properties +CRC-32 is checked over each chunk type and data. A mismatch produces a warning but does not obscure otherwise valid whole-chunk boundaries; the cleaner may remove targeted chunks but never repairs or mutates retained CRCs. -The parser validates the 12-byte RIFF/WebP header, checked declared RIFF boundary, complete eight-byte chunk headers, payload lengths, odd-byte padding, VP8X length/uniqueness, and `maxChunks`. Every chunk loop either advances by its validated physical length or terminates. Trailing bytes outside the declared RIFF boundary are warned about, not parsed. +IDAT and APNG payloads are never decoded or rewritten. `zTXt`, compressed `iTXt`, and `iCCP` are never inflated, avoiding metadata decompression-bomb exposure in this sprint. A malformed but bounded text or `eXIf` payload can still be removed as a whole chunk. Unknown ancillary, critical, ICC, and rendering/color chunks are preserved by default. Reconstruction parses once, calculates checked retained ranges, allocates one output, and copies complete chunks and trailing bytes in order. Exact caller subviews are honored and inputs are never mutated. -VP8, VP8L, VP8X, ALPH, ANIM, ANMF, ICCP, and unknown chunk payloads remain opaque. Privacy Clean removes whole EXIF and XMP chunks, including their padding. ICCP and unknown chunks remain by default. Reconstruction allocates one output buffer, copies retained physical chunks in order, patches only VP8X ICC/EXIF/XMP flag bits, repairs the little-endian RIFF size, and preserves trailing bytes outside that size. +## Environment and dependencies -Unsafe RIFF/chunk boundaries, missing padding, invalid or duplicate VP8X, and chunk-limit failures produce `IncompleteWebPError` before output. Malformed EXIF/XMP payloads do not block safe whole-chunk removal. WebP verification observes supported chunk presence only and makes no claim about provenance, unknown metadata, pixels, or complete personal-information removal. +Core code is local-only and side-effect-free, with zero runtime dependencies. Verification observes supported container presence only; it cannot prove provenance, absence of unknown metadata, or complete removal of personal information.