From 4b5ad4ca37230a8084edebe5782522625520506c Mon Sep 17 00:00:00 2001 From: maruson08 Date: Tue, 25 Aug 2026 14:09:49 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8[Feat]=20Add=20bounded=20WebP=20RI?= =?UTF-8?q?FF=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/diagnostics.ts | 11 ++ src/inspect.ts | 17 +++ src/webp/chunks.ts | 38 ++++++ src/webp/index.ts | 7 ++ src/webp/metadata.ts | 54 +++++++++ src/webp/parser.ts | 251 ++++++++++++++++++++++++++++++++++++++++ src/webp/types.ts | 24 ++++ 7 files changed, 402 insertions(+) create mode 100644 src/webp/chunks.ts create mode 100644 src/webp/index.ts create mode 100644 src/webp/metadata.ts create mode 100644 src/webp/parser.ts create mode 100644 src/webp/types.ts diff --git a/src/core/diagnostics.ts b/src/core/diagnostics.ts index 3481a27..e2a1143 100644 --- a/src/core/diagnostics.ts +++ b/src/core/diagnostics.ts @@ -14,6 +14,17 @@ export type DiagnosticCode = | "JPEG_MISSING_EOI" | "JPEG_SEGMENT_LIMIT_EXCEEDED" | "JPEG_TRAILING_DATA" + | "WEBP_INVALID_RIFF_HEADER" + | "WEBP_INVALID_RIFF_SIZE" + | "WEBP_TRUNCATED_RIFF" + | "WEBP_TRUNCATED_CHUNK_HEADER" + | "WEBP_TRUNCATED_CHUNK" + | "WEBP_CHUNK_LIMIT_EXCEEDED" + | "WEBP_INVALID_PADDING" + | "WEBP_INVALID_VP8X" + | "WEBP_DUPLICATE_VP8X" + | "WEBP_INCONSISTENT_FEATURE_FLAGS" + | "WEBP_TRAILING_DATA" | "TIFF_TRUNCATED_HEADER" | "TIFF_INVALID_BYTE_ORDER" | "TIFF_INVALID_MAGIC" diff --git a/src/inspect.ts b/src/inspect.ts index ffdbdc8..f22d842 100644 --- a/src/inspect.ts +++ b/src/inspect.ts @@ -9,6 +9,8 @@ import type { } from "./core/types.js"; import { inspectJpegMetadata } from "./jpeg/metadata.js"; import { parseJpeg } from "./jpeg/parser.js"; +import { inspectWebPMetadata } from "./webp/metadata.js"; +import { parseWebP } from "./webp/parser.js"; export function inspectMetadata( input: BinaryInput, @@ -66,6 +68,21 @@ export function inspectMetadata( }; } + if (format === "webp") { + const webp = parseWebP( + reader, + resolveParseLimit("maxChunks", options?.limits?.maxChunks), + ); + return { + format, + size: bytes.byteLength, + inspectionStatus: webp.complete + ? "container-inspected" + : "container-partial", + entries: inspectWebPMetadata(webp), + diagnostics: webp.diagnostics, + }; + } return { format, size: bytes.byteLength, diff --git a/src/webp/chunks.ts b/src/webp/chunks.ts new file mode 100644 index 0000000..260fbb3 --- /dev/null +++ b/src/webp/chunks.ts @@ -0,0 +1,38 @@ +import type { WebPChunkKind, WebPMetadataKind } from "./types.js"; + +export const WEBP_VP8X_FLAG = Object.freeze({ + icc: 0x20, + alpha: 0x10, + exif: 0x08, + xmp: 0x04, + animation: 0x02, +}); + +export const WEBP_VP8X_METADATA_MASK = + WEBP_VP8X_FLAG.icc | WEBP_VP8X_FLAG.exif | WEBP_VP8X_FLAG.xmp; + +export function classifyWebPChunk(fourCC: string): { + readonly kind: WebPChunkKind; + readonly metadataKind?: WebPMetadataKind; +} { + switch (fourCC) { + case "VP8 ": + case "VP8L": + return { kind: "image" }; + case "ALPH": + return { kind: "alpha" }; + case "VP8X": + return { kind: "extended" }; + case "ANIM": + case "ANMF": + return { kind: "animation" }; + case "EXIF": + return { kind: "metadata", metadataKind: "exif" }; + case "XMP ": + return { kind: "metadata", metadataKind: "xmp" }; + case "ICCP": + return { kind: "metadata", metadataKind: "icc" }; + default: + return { kind: "unknown" }; + } +} diff --git a/src/webp/index.ts b/src/webp/index.ts new file mode 100644 index 0000000..dd718cf --- /dev/null +++ b/src/webp/index.ts @@ -0,0 +1,7 @@ +export { parseWebP } from "./parser.js"; +export type { + WebPChunk, + WebPChunkKind, + WebPMetadataKind, + WebPParseResult, +} from "./types.js"; diff --git a/src/webp/metadata.ts b/src/webp/metadata.ts new file mode 100644 index 0000000..53c396c --- /dev/null +++ b/src/webp/metadata.ts @@ -0,0 +1,54 @@ +import type { MetadataEntry } from "../core/types.js"; +import type { WebPParseResult } from "./types.js"; + +export function inspectWebPMetadata( + result: WebPParseResult, +): readonly MetadataEntry[] { + return result.chunks.flatMap((chunk): readonly MetadataEntry[] => { + const source = { + format: "webp" as const, + container: "webp-chunk" as const, + offset: chunk.offset, + length: chunk.totalLength, + chunkType: chunk.fourCC, + }; + + switch (chunk.metadataKind) { + case "exif": + return [ + { + id: `webp-exif-${String(chunk.offset)}`, + namespace: "exif", + name: "WebP EXIF container", + category: "unknown", + privacy: "potentially-sensitive", + source, + }, + ]; + case "xmp": + return [ + { + id: `webp-xmp-${String(chunk.offset)}`, + namespace: "xmp", + name: "WebP XMP container", + category: "unknown", + privacy: "potentially-sensitive", + source, + }, + ]; + case "icc": + return [ + { + id: `webp-icc-${String(chunk.offset)}`, + namespace: "icc", + name: "WebP ICC profile container", + category: "color", + privacy: "non-sensitive", + source, + }, + ]; + default: + return []; + } + }); +} diff --git a/src/webp/parser.ts b/src/webp/parser.ts new file mode 100644 index 0000000..6dcec08 --- /dev/null +++ b/src/webp/parser.ts @@ -0,0 +1,251 @@ +import { type ByteReader } from "../core/binary/index.js"; +import type { Diagnostic, DiagnosticCode } from "../core/diagnostics.js"; +import { + classifyWebPChunk, + WEBP_VP8X_FLAG, + WEBP_VP8X_METADATA_MASK, +} from "./chunks.js"; +import type { WebPChunk, WebPParseResult } from "./types.js"; + +const RIFF = [0x52, 0x49, 0x46, 0x46]; +const WEBP = [0x57, 0x45, 0x42, 0x50]; + +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 WebPChunk[] = [], + containerLength = 0, +): WebPParseResult { + return { chunks, complete: 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), + ); +} + +export function parseWebP( + reader: ByteReader, + maxChunks: number, +): WebPParseResult { + const diagnostics: Diagnostic[] = []; + const chunks: WebPChunk[] = []; + + if ( + !reader.has(0, 12) || + !reader.matches(0, RIFF) || + !reader.matches(8, WEBP) + ) { + return failure([ + diagnostic( + "error", + "WEBP_INVALID_RIFF_HEADER", + "WebP input requires a 12-byte RIFF....WEBP header.", + 0, + ), + ]); + } + + const declaredRiffSize = reader.u32LE(4); + const containerLength = declaredRiffSize + 8; + if ( + declaredRiffSize < 4 || + !Number.isSafeInteger(containerLength) || + containerLength < 12 + ) { + return failure( + [ + diagnostic( + "error", + "WEBP_INVALID_RIFF_SIZE", + "WebP RIFF size does not include the WEBP form type.", + 4, + ), + ], + chunks, + containerLength, + ); + } + if (containerLength > reader.length) { + return failure( + [ + diagnostic( + "error", + "WEBP_TRUNCATED_RIFF", + "WebP RIFF size extends beyond the supplied input.", + 4, + ), + ], + chunks, + containerLength, + ); + } + if (containerLength < reader.length) { + diagnostics.push( + diagnostic( + "warning", + "WEBP_TRAILING_DATA", + `WebP contains ${String(reader.length - containerLength)} trailing byte(s) after the RIFF container.`, + containerLength, + ), + ); + } + + let offset = 12; + let vp8xCount = 0; + while (offset < containerLength) { + if (chunks.length >= maxChunks) { + diagnostics.push( + diagnostic( + "error", + "WEBP_CHUNK_LIMIT_EXCEEDED", + `WebP chunk count exceeds maxChunks ${String(maxChunks)}.`, + offset, + ), + ); + return failure(diagnostics, chunks, containerLength); + } + if (containerLength - offset < 8) { + diagnostics.push( + diagnostic( + "error", + "WEBP_TRUNCATED_CHUNK_HEADER", + "WebP RIFF ends within a chunk header.", + offset, + ), + ); + return failure(diagnostics, chunks, containerLength); + } + + const type = fourCC(reader, offset); + const payloadLength = reader.u32LE(offset + 4); + const payloadOffset = offset + 8; + const payloadEnd = payloadOffset + payloadLength; + if (!Number.isSafeInteger(payloadEnd) || payloadEnd > containerLength) { + diagnostics.push( + diagnostic( + "error", + "WEBP_TRUNCATED_CHUNK", + `${type} payload extends beyond the RIFF boundary.`, + offset, + ), + ); + return failure(diagnostics, chunks, containerLength); + } + + const padding = payloadLength % 2; + if (padding === 1 && payloadEnd === containerLength) { + diagnostics.push( + diagnostic( + "error", + "WEBP_INVALID_PADDING", + `${type} has an odd payload without its required padding byte.`, + payloadEnd, + ), + ); + return failure(diagnostics, chunks, containerLength); + } + const totalLength = 8 + payloadLength + padding; + if ( + !Number.isSafeInteger(totalLength) || + totalLength > containerLength - offset + ) { + diagnostics.push( + diagnostic( + "error", + "WEBP_TRUNCATED_CHUNK", + `${type} physical chunk range exceeds the RIFF boundary.`, + offset, + ), + ); + return failure(diagnostics, chunks, containerLength); + } + + const classification = classifyWebPChunk(type); + let vp8xFlags: number | undefined; + if (type === "VP8X") { + vp8xCount += 1; + if (vp8xCount > 1) { + diagnostics.push( + diagnostic( + "error", + "WEBP_DUPLICATE_VP8X", + "WebP contains more than one VP8X chunk.", + offset, + ), + ); + } + if (payloadLength !== 10) { + diagnostics.push( + diagnostic( + "error", + "WEBP_INVALID_VP8X", + "VP8X payload must be exactly 10 bytes.", + offset, + ), + ); + } else { + vp8xFlags = reader.u8(payloadOffset); + } + } + + chunks.push({ + fourCC: type, + offset, + payloadOffset, + payloadLength, + totalLength, + ...classification, + ...(vp8xFlags === undefined ? {} : { vp8xFlags }), + }); + offset += totalLength; + } + + const hasStructuralError = diagnostics.some( + ({ severity }) => severity === "error", + ); + const vp8x = chunks.find(({ fourCC: type }) => type === "VP8X"); + if (!hasStructuralError && vp8x?.vp8xFlags !== undefined) { + const observedFlags = + (chunks.some(({ metadataKind }) => metadataKind === "icc") + ? WEBP_VP8X_FLAG.icc + : 0) | + (chunks.some(({ metadataKind }) => metadataKind === "exif") + ? WEBP_VP8X_FLAG.exif + : 0) | + (chunks.some(({ metadataKind }) => metadataKind === "xmp") + ? WEBP_VP8X_FLAG.xmp + : 0); + if ((vp8x.vp8xFlags & WEBP_VP8X_METADATA_MASK) !== observedFlags) { + diagnostics.push( + diagnostic( + "warning", + "WEBP_INCONSISTENT_FEATURE_FLAGS", + "VP8X metadata flags do not match observed metadata chunks.", + vp8x.payloadOffset, + ), + ); + } + } + + return { + chunks, + complete: !hasStructuralError, + containerLength, + diagnostics, + }; +} diff --git a/src/webp/types.ts b/src/webp/types.ts new file mode 100644 index 0000000..7db0c6a --- /dev/null +++ b/src/webp/types.ts @@ -0,0 +1,24 @@ +import type { Diagnostic } from "../core/diagnostics.js"; + +export type WebPChunkKind = + "image" | "alpha" | "extended" | "animation" | "metadata" | "unknown"; + +export type WebPMetadataKind = "exif" | "xmp" | "icc"; + +export interface WebPChunk { + readonly fourCC: string; + readonly offset: number; + readonly payloadOffset: number; + readonly payloadLength: number; + readonly totalLength: number; + readonly kind: WebPChunkKind; + readonly metadataKind?: WebPMetadataKind; + readonly vp8xFlags?: number; +} + +export interface WebPParseResult { + readonly chunks: readonly WebPChunk[]; + readonly complete: boolean; + readonly containerLength: number; + readonly diagnostics: readonly Diagnostic[]; +} From 73b20b1281f667410928f7b876711808b3eea07a Mon Sep 17 00:00:00 2001 From: maruson08 Date: Tue, 25 Aug 2026 14:09:50 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8[Feat]=20Add=20WebP=20cleaning=20a?= =?UTF-8?q?nd=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 | 2 +- src/index.ts | 3 + src/policy/clean.ts | 4 ++ src/verify/verify.ts | 51 +++++++++---- src/webp/clean.ts | 166 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 226 insertions(+), 17 deletions(-) create mode 100644 src/webp/clean.ts diff --git a/src/core/errors.ts b/src/core/errors.ts index 2c5f5d3..4164fb8 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -9,6 +9,7 @@ export type SecureMetadataErrorCode = | "INPUT_LIMIT_EXCEEDED" | "UNSUPPORTED_FORMAT" | "INCOMPLETE_JPEG" + | "INCOMPLETE_WEBP" | "CLEAN_OUTPUT_SIZE_INVALID"; export class SecureMetadataError extends Error { @@ -86,7 +87,7 @@ export class UnsupportedFormatError extends SecureMetadataError { constructor( readonly operation: "cleanMetadata" | "verifyMetadata", - readonly format: "png" | "webp" | "unknown", + readonly format: "png" | "unknown", ) { super( `${operation} does not support ${format} input.`, @@ -108,3 +109,17 @@ export class IncompleteJpegError extends SecureMetadataError { ); } } + +export class IncompleteWebPError extends SecureMetadataError { + override readonly name: string = "IncompleteWebPError"; + + constructor( + readonly operation: "cleanMetadata" | "verifyMetadata", + readonly diagnostics: readonly Diagnostic[], + ) { + super( + `${operation} requires a structurally complete WebP RIFF container.`, + "INCOMPLETE_WEBP", + ); + } +} diff --git a/src/core/types.ts b/src/core/types.ts index 33e1eaa..de73e40 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -112,7 +112,7 @@ export interface MetadataChange { export interface CleanResult { readonly output: Uint8Array; - readonly format: "jpeg"; + readonly format: "jpeg" | "webp"; readonly report: MetadataReport; readonly removed: readonly MetadataChange[]; readonly preserved: readonly MetadataChange[]; diff --git a/src/index.ts b/src/index.ts index 19ad72e..452dcb8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,16 @@ export { inspectMetadata } from "./inspect.js"; export { cleanMetadata, DEFAULT_JPEG_CLEANING_POLICY } from "./policy/clean.js"; +export { DEFAULT_WEBP_CLEANING_POLICY } from "./webp/clean.js"; export { DEFAULT_JPEG_VERIFICATION_POLICY, + DEFAULT_WEBP_VERIFICATION_POLICY, verifyMetadata, } from "./verify/verify.js"; export { BinaryBoundsError, IncompleteJpegError, + IncompleteWebPError, InputLimitExceededError, InvalidParseLimitError, NotImplementedError, diff --git a/src/policy/clean.ts b/src/policy/clean.ts index 17af914..ada7ce4 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 { cleanWebP } from "../webp/clean.js"; export const DEFAULT_JPEG_CLEANING_POLICY = Object.freeze({ removeExif: true, @@ -204,6 +205,9 @@ export function cleanMetadata( const reader = new ByteReader(bytes); const format = detectFormat(reader); + if (format === "webp") { + return cleanWebP(bytes, policy); + } if (format !== "jpeg") { throw new UnsupportedFormatError("cleanMetadata", format); } diff --git a/src/verify/verify.ts b/src/verify/verify.ts index b03d69b..eacf0c2 100644 --- a/src/verify/verify.ts +++ b/src/verify/verify.ts @@ -1,4 +1,8 @@ -import { IncompleteJpegError, UnsupportedFormatError } from "../core/errors.js"; +import { + IncompleteJpegError, + IncompleteWebPError, + UnsupportedFormatError, +} from "../core/errors.js"; import type { BinaryInput, VerificationCheck, @@ -16,6 +20,12 @@ export const DEFAULT_JPEG_VERIFICATION_POLICY = Object.freeze({ icc: "ignore", } satisfies Record); +export const DEFAULT_WEBP_VERIFICATION_POLICY = Object.freeze({ + exif: "absent", + xmp: "absent", + icc: "ignore", +} satisfies Record); + export function verifyMetadata( input: BinaryInput, expectation?: VerificationPolicy, @@ -26,27 +36,38 @@ export function verifyMetadata( ? undefined : { limits: expectation.limits }, ); - if (report.format !== "jpeg") { + if (report.format === "jpeg") { + if (report.inspectionStatus === "container-partial") { + throw new IncompleteJpegError("verifyMetadata", report.diagnostics); + } + } else if (report.format === "webp") { + if (report.inspectionStatus === "container-partial") { + throw new IncompleteWebPError("verifyMetadata", report.diagnostics); + } + } else { throw new UnsupportedFormatError("verifyMetadata", report.format); } - if (report.inspectionStatus === "container-partial") { - throw new IncompleteJpegError("verifyMetadata", report.diagnostics); - } const privacyDefault = expectation?.requireNoPrivacyRelevantMetadata === false ? "ignore" : "absent"; - const expected = { - exif: expectation?.exif ?? privacyDefault, - xmp: expectation?.xmp ?? privacyDefault, - iptc: expectation?.iptc ?? privacyDefault, - "jpeg-comment": expectation?.comments ?? privacyDefault, - icc: expectation?.icc ?? "ignore", - } satisfies Record< - "exif" | "xmp" | "iptc" | "jpeg-comment" | "icc", - VerificationExpectation - >; + const 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", + }; const checks: VerificationCheck[] = []; for (const [namespace, wanted] of Object.entries(expected) as Array< diff --git a/src/webp/clean.ts b/src/webp/clean.ts new file mode 100644 index 0000000..de49aec --- /dev/null +++ b/src/webp/clean.ts @@ -0,0 +1,166 @@ +import { ByteReader } from "../core/binary/index.js"; +import { IncompleteWebPError, 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 { WEBP_VP8X_FLAG, WEBP_VP8X_METADATA_MASK } from "./chunks.js"; +import { parseWebP } from "./parser.js"; +import type { WebPChunk } from "./types.js"; + +export const DEFAULT_WEBP_CLEANING_POLICY = Object.freeze({ + removeExif: true, + removeXmp: true, + preserveIcc: true, +}); + +interface EffectivePolicy { + readonly removeExif: boolean; + readonly removeXmp: boolean; + readonly preserveIcc: boolean; +} + +function effectivePolicy(policy: CleaningPolicy | undefined): EffectivePolicy { + return { + removeExif: policy?.removeExif ?? true, + removeXmp: policy?.removeXmp ?? true, + preserveIcc: policy?.preserveIcc ?? policy?.preserveColorProfiles ?? true, + }; +} + +function shouldRemove(chunk: WebPChunk, policy: EffectivePolicy): boolean { + switch (chunk.metadataKind) { + case "exif": + return policy.removeExif; + case "xmp": + return policy.removeXmp; + case "icc": + return !policy.preserveIcc; + default: + return false; + } +} + +function changeFor( + chunk: WebPChunk, + action: MetadataChange["action"], +): MetadataChange { + let namespace: MetadataNamespace = "unknown"; + let name = `Unknown ${chunk.fourCC} chunk`; + if (chunk.metadataKind !== undefined) { + namespace = chunk.metadataKind; + name = + chunk.metadataKind === "icc" + ? "WebP ICC profile container" + : `WebP ${chunk.metadataKind.toUpperCase()} container`; + } + + return { + namespace, + action, + name, + source: { + format: "webp", + container: "webp-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 cleanWebP( + bytes: Uint8Array, + policy?: CleaningPolicy, +): CleanResult { + const parsed = parseWebP( + new ByteReader(bytes), + resolveParseLimit("maxChunks", policy?.limits?.maxChunks), + ); + if (!parsed.complete) { + throw new IncompleteWebPError("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 = 12; + for (const chunk of retained) { + containerLength += chunk.totalLength; + if ( + !Number.isSafeInteger(containerLength) || + containerLength > parsed.containerLength + ) { + throw outputError("WebP cleaner output RIFF size is invalid."); + } + } + + const trailingLength = bytes.byteLength - parsed.containerLength; + const outputLength = containerLength + trailingLength; + if ( + !Number.isSafeInteger(outputLength) || + outputLength < 12 || + outputLength > bytes.byteLength + ) { + throw outputError("WebP cleaner output size is invalid."); + } + + const hasIcc = retained.some(({ metadataKind }) => metadataKind === "icc"); + const hasExif = retained.some(({ metadataKind }) => metadataKind === "exif"); + const hasXmp = retained.some(({ metadataKind }) => metadataKind === "xmp"); + const metadataFlags = + (hasIcc ? WEBP_VP8X_FLAG.icc : 0) | + (hasExif ? WEBP_VP8X_FLAG.exif : 0) | + (hasXmp ? WEBP_VP8X_FLAG.xmp : 0); + + const output = new Uint8Array(outputLength); + output.set(bytes.subarray(0, 12)); + new DataView(output.buffer).setUint32(4, containerLength - 8, true); + let outputOffset = 12; + for (const chunk of retained) { + output.set( + bytes.subarray(chunk.offset, chunk.offset + chunk.totalLength), + outputOffset, + ); + if (chunk.vp8xFlags !== undefined) { + output[outputOffset + 8] = + (chunk.vp8xFlags & ~WEBP_VP8X_METADATA_MASK) | metadataFlags; + } + 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 IncompleteWebPError("cleanMetadata", report.diagnostics); + } + + return { + output, + format: "webp", + 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, + }; +} From 671579f8722a4f9d006bed022cffa8dee6deffc7 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Tue, 25 Aug 2026 14:09:50 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=85[Test]=20Cover=20WebP=20container?= =?UTF-8?q?=20and=20cleaner=20invariants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/helpers/webp-builder.ts | 69 +++++++ tests/unit/jpeg-cleaner-verification.test.ts | 4 - tests/unit/jpeg-inspection.test.ts | 6 +- tests/unit/webp-cleaner-verification.test.ts | 179 +++++++++++++++++++ tests/unit/webp-parser.test.ts | 125 +++++++++++++ 5 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 tests/helpers/webp-builder.ts create mode 100644 tests/unit/webp-cleaner-verification.test.ts create mode 100644 tests/unit/webp-parser.test.ts diff --git a/tests/helpers/webp-builder.ts b/tests/helpers/webp-builder.ts new file mode 100644 index 0000000..e6be9ef --- /dev/null +++ b/tests/helpers/webp-builder.ts @@ -0,0 +1,69 @@ +export function concat(...parts: readonly Uint8Array[]): Uint8Array { + const length = parts.reduce((total, part) => total + part.byteLength, 0); + const output = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.byteLength; + } + return output; +} + +export function fourCC(value: string): Uint8Array { + if (value.length !== 4) { + throw new Error("Test FourCC must contain exactly four characters."); + } + return Uint8Array.from(value, (character) => character.charCodeAt(0)); +} + +export function u32le(value: number): Uint8Array { + const output = new Uint8Array(4); + new DataView(output.buffer).setUint32(0, value, true); + return output; +} + +export function chunk( + type: string, + payload: Uint8Array = new Uint8Array(), + paddingByte = 0, +): Uint8Array { + return concat( + fourCC(type), + u32le(payload.byteLength), + payload, + payload.byteLength % 2 === 1 + ? Uint8Array.of(paddingByte) + : new Uint8Array(), + ); +} + +export function vp8x( + flags: number, + remainingPayload: Uint8Array = Uint8Array.of( + 0, + 0, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + ), +): Uint8Array { + return chunk("VP8X", concat(Uint8Array.of(flags), remainingPayload)); +} + +export function webp( + chunks: readonly Uint8Array[], + trailing = new Uint8Array(), +): Uint8Array { + const body = concat(fourCC("WEBP"), ...chunks); + return concat(fourCC("RIFF"), u32le(body.byteLength), body, trailing); +} + +export function withRiffSize(input: Uint8Array, size: number): Uint8Array { + const output = Uint8Array.from(input); + new DataView(output.buffer).setUint32(4, size, true); + return output; +} diff --git a/tests/unit/jpeg-cleaner-verification.test.ts b/tests/unit/jpeg-cleaner-verification.test.ts index 5553590..520f215 100644 --- a/tests/unit/jpeg-cleaner-verification.test.ts +++ b/tests/unit/jpeg-cleaner-verification.test.ts @@ -231,10 +231,6 @@ describe("JPEG verification", () => { it.each([ [Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a), "png"], - [ - Uint8Array.of(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50), - "webp", - ], [new Uint8Array(), "unknown"], ] as const)( "rejects unsupported $format cleaning and verification", diff --git a/tests/unit/jpeg-inspection.test.ts b/tests/unit/jpeg-inspection.test.ts index 4c89ea3..92a8136 100644 --- a/tests/unit/jpeg-inspection.test.ts +++ b/tests/unit/jpeg-inspection.test.ts @@ -187,11 +187,7 @@ describe("JPEG inspection safety and status", () => { it.each([ [Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a), "png"], - [ - Uint8Array.of(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50), - "webp", - ], - ] as const)("leaves %s inspection at format-only", (input, format) => { + ] as const)("leaves PNG inspection at format-only", (input, format) => { expect(inspectMetadata(input)).toMatchObject({ format, inspectionStatus: "format-only", diff --git a/tests/unit/webp-cleaner-verification.test.ts b/tests/unit/webp-cleaner-verification.test.ts new file mode 100644 index 0000000..d176805 --- /dev/null +++ b/tests/unit/webp-cleaner-verification.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; + +import { + cleanMetadata, + IncompleteWebPError, + inspectMetadata, + verifyMetadata, +} from "../../src/index.js"; +import { + chunk, + concat, + fourCC, + u32le, + vp8x, + webp, + withRiffSize, +} from "../helpers/webp-builder.js"; + +function canonicalFixture() { + const trailing = Uint8Array.of(0xfa, 0xfb, 0xfc); + const remainingVp8x = Uint8Array.of(9, 8, 7, 6, 5, 4, 3, 2, 1); + const extended = vp8x(0x3e, remainingVp8x); + const repairedExtended = vp8x(0x32, remainingVp8x); + const icc = chunk("ICCP", Uint8Array.of(0x10, 0x11, 0x12), 0x7f); + const exif = chunk("EXIF", Uint8Array.of(0x49), 0xee); + const xmp = chunk("XMP ", Uint8Array.of(1, 2, 3), 0xdd); + const unknown = chunk("zzZZ", Uint8Array.of(4, 5, 6, 7, 8), 0xab); + const animation = chunk("ANIM", Uint8Array.of(9, 10)); + const frame = chunk("ANMF", Uint8Array.of(11, 12, 13), 0xbc); + const image = chunk("VP8 ", Uint8Array.of(14, 15)); + const secondExif = chunk("EXIF", Uint8Array.of(16, 17)); + + return { + input: webp( + [extended, icc, exif, xmp, unknown, animation, frame, secondExif, image], + trailing, + ), + expected: webp( + [repairedExtended, icc, unknown, animation, frame, image], + trailing, + ), + trailing, + }; +} + +describe("WebP Privacy Clean", () => { + it("repairs only WebP bookkeeping while preserving retained chunks and padding exactly", () => { + const { input, expected, trailing } = canonicalFixture(); + const before = Uint8Array.from(input); + + const inspected = inspectMetadata(input); + const first = cleanMetadata(input); + const second = cleanMetadata(input); + const idempotent = cleanMetadata(first.output); + const verification = verifyMetadata(first.output, { icc: "present" }); + + expect(inspected).toMatchObject({ + format: "webp", + inspectionStatus: "container-inspected", + }); + 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([ + "exif", + "xmp", + "exif", + ]); + 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(3); + expect(new DataView(first.output.buffer).getUint32(4, true) + 8).toBe( + first.output.byteLength - trailing.byteLength, + ); + }); + + it("cleans metadata without synthesizing VP8X", () => { + const image = chunk("VP8L", Uint8Array.of(1, 2, 3), 0x9a); + const input = webp([chunk("EXIF", Uint8Array.of(4)), image]); + + const result = cleanMetadata(input); + + expect(result.output).toEqual(webp([image])); + expect(result.report.entries).toEqual([]); + }); + + it("supports one custom policy and aligns VP8X flags with retained metadata", () => { + const extended = vp8x(0x2c); + const exif = chunk("EXIF", Uint8Array.of(1)); + const xmp = chunk("XMP ", Uint8Array.of(2)); + const icc = chunk("ICCP", Uint8Array.of(3)); + const input = webp([extended, exif, xmp, icc]); + + const result = cleanMetadata(input, { removeExif: false }); + + expect(result.output).toEqual(webp([vp8x(0x28), exif, icc])); + expect(result.removed.map(({ namespace }) => namespace)).toEqual(["xmp"]); + expect(result.report.entries.map(({ namespace }) => namespace)).toEqual([ + "exif", + "icc", + ]); + }); + + it("returns a separate byte-identical output for an already consistent no-op", () => { + const input = webp([chunk("VP8 ", Uint8Array.of(1, 2))]); + + 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("removes malformed EXIF payload because its chunk boundary is valid", () => { + const input = webp([chunk("EXIF", Uint8Array.of(0x49))]); + + expect(inspectMetadata(input).entries).toEqual([ + expect.objectContaining({ namespace: "exif" }), + ]); + const result = cleanMetadata(input); + + expect(result.output).toEqual(webp([])); + expect(verifyMetadata(result.output).valid).toBe(true); + }); + + it("honors the exact supplied Uint8Array subview without leaking backing bytes", () => { + const fixture = canonicalFixture(); + const prefix = Uint8Array.of(0xaa, 0xbb); + const suffix = Uint8Array.of(0xcc, 0xdd, 0xee); + const backing = concat(prefix, fixture.input, suffix); + const view = new Uint8Array( + backing.buffer, + backing.byteOffset + prefix.byteLength, + fixture.input.byteLength, + ); + + expect(cleanMetadata(view).output).toEqual(fixture.expected); + }); + + it.each([ + withRiffSize(webp([]), 100), + webp([concat(fourCC("EXIF"), u32le(5), Uint8Array.of(1))]), + webp([vp8x(0), vp8x(0)]), + ])("rejects incomplete WebP without producing partial output", (input) => { + expect(inspectMetadata(input).inspectionStatus).toBe("container-partial"); + expect(() => cleanMetadata(input)).toThrowError(IncompleteWebPError); + expect(() => cleanMetadata(input)).toThrowError( + expect.objectContaining({ code: "INCOMPLETE_WEBP" }), + ); + }); +}); + +describe("WebP verification", () => { + it("reports a precise failure when EXIF remains", () => { + const result = verifyMetadata(webp([chunk("EXIF")])); + + expect(result.valid).toBe(false); + expect(result.checks).toContainEqual({ + namespace: "exif", + expected: "absent", + actual: "present", + passed: false, + }); + expect(result.checks.map(({ namespace }) => namespace)).toEqual([ + "exif", + "xmp", + ]); + }); +}); diff --git a/tests/unit/webp-parser.test.ts b/tests/unit/webp-parser.test.ts new file mode 100644 index 0000000..b2e707b --- /dev/null +++ b/tests/unit/webp-parser.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { ByteReader } from "../../src/core/binary/byte-reader.js"; +import { parseWebP } from "../../src/webp/parser.js"; +import { + chunk, + concat, + fourCC, + u32le, + vp8x, + webp, + withRiffSize, +} from "../helpers/webp-builder.js"; + +function parse(input: Uint8Array, maxChunks = 100) { + return parseWebP(new ByteReader(input), maxChunks); +} + +describe("WebP RIFF parser", () => { + it("classifies known chunks, retains exact FourCC spaces, and bounds trailing data", () => { + const trailing = Uint8Array.of(0xfa, 0xfb); + const input = webp( + [ + vp8x(0x3e), + chunk("ICCP", Uint8Array.of(1)), + chunk("EXIF", Uint8Array.of(2, 3)), + chunk("XMP ", Uint8Array.of(4)), + chunk("VP8 ", Uint8Array.of(5, 6)), + chunk("VP8L", Uint8Array.of(7)), + chunk("ALPH", Uint8Array.of(8)), + chunk("ANIM", Uint8Array.of(9, 10)), + chunk("ANMF", Uint8Array.of(11)), + chunk("zzZZ", Uint8Array.of(12, 13, 14), 0x7f), + ], + trailing, + ); + + const result = parse(input); + + expect(result.complete).toBe(true); + expect(result.containerLength).toBe(input.byteLength - trailing.byteLength); + expect( + result.chunks.map(({ fourCC: type, kind, metadataKind }) => [ + type, + kind, + metadataKind, + ]), + ).toEqual([ + ["VP8X", "extended", undefined], + ["ICCP", "metadata", "icc"], + ["EXIF", "metadata", "exif"], + ["XMP ", "metadata", "xmp"], + ["VP8 ", "image", undefined], + ["VP8L", "image", undefined], + ["ALPH", "alpha", undefined], + ["ANIM", "animation", undefined], + ["ANMF", "animation", undefined], + ["zzZZ", "unknown", undefined], + ]); + expect(result.chunks.at(-1)).toMatchObject({ + payloadLength: 3, + totalLength: 12, + }); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ code: "WEBP_TRAILING_DATA" }), + ]); + }); + + it("reports inconsistent metadata flags without treating chunks as absent", () => { + const result = parse(webp([vp8x(0), chunk("EXIF")])); + + expect(result.complete).toBe(true); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + severity: "warning", + code: "WEBP_INCONSISTENT_FEATURE_FLAGS", + }), + ); + }); + + it.each([ + [new Uint8Array(11), "WEBP_INVALID_RIFF_HEADER"], + [ + concat(fourCC("NOPE"), u32le(4), fourCC("WEBP")), + "WEBP_INVALID_RIFF_HEADER", + ], + [ + concat(fourCC("RIFF"), u32le(4), fourCC("NOPE")), + "WEBP_INVALID_RIFF_HEADER", + ], + [withRiffSize(webp([]), 3), "WEBP_INVALID_RIFF_SIZE"], + [withRiffSize(webp([]), 100), "WEBP_TRUNCATED_RIFF"], + [webp([Uint8Array.of(1, 2, 3, 4)]), "WEBP_TRUNCATED_CHUNK_HEADER"], + [ + webp([concat(fourCC("EXIF"), u32le(5), Uint8Array.of(1, 2))]), + "WEBP_TRUNCATED_CHUNK", + ], + [ + webp([concat(fourCC("XMP "), u32le(1), Uint8Array.of(1))]), + "WEBP_INVALID_PADDING", + ], + [chunk("VP8X", Uint8Array.of(1)), "WEBP_INVALID_VP8X", true], + [concat(vp8x(0), vp8x(0)), "WEBP_DUPLICATE_VP8X", true], + ] as const)( + "rejects a malformed RIFF/chunk case with %s", + (value, code, wrap?: true) => { + const result = parse(wrap ? webp([value]) : value); + + expect(result.complete).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code }), + ); + }, + ); + + it("enforces maxChunks before parsing another chunk", () => { + const result = parse(webp([chunk("VP8 "), chunk("EXIF")]), 1); + + expect(result.complete).toBe(false); + expect(result.chunks).toHaveLength(1); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "WEBP_CHUNK_LIMIT_EXCEEDED" }), + ); + }); +}); From aaf2855d80c93475e2617850919786687012d0ed Mon Sep 17 00:00:00 2001 From: maruson08 Date: Tue, 25 Aug 2026 14:14:30 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=93=84[Docs]=20Document=20WebP=20supp?= =?UTF-8?q?ort=20and=20cleaning=20guarantees?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++++ README.md | 15 +++++++++------ docs/architecture.md | 29 +++++++++++++++++++++------- docs/cleaning-policy.md | 29 ++++++++++++++++++++++------ docs/format-support.md | 42 +++++++++++++++++++---------------------- docs/security-model.md | 10 +++++++++- 6 files changed, 86 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a08593..668d9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes will be documented here. The project intends to follow seman ### Added +- 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. +- WebP metadata verification, ICC/unknown preservation, and canonical malformed/padding coverage. - Deterministic JPEG Privacy Clean for whole EXIF, XMP, Photoshop/IPTC, and COM segments. - Checked single-allocation JPEG reconstruction preserving ICC, unknown APP, structural, scan, and trailing bytes. - Structured JPEG presence/absence verification and typed unsupported/incomplete-input errors. diff --git a/README.md b/README.md index 9b443f0..1811dc7 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,16 @@ Current implementation: - 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. +- 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, PNG/WebP container parsing or cleaning, and PNG/WebP verification. +Not implemented: MakerNote or thumbnail decoding, XMP/IPTC/ICC payload parsing, WebP EXIF field decoding, and PNG container parsing, cleaning, or verification. ## 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. PNG and WebP remain `format-only`. See [format support](docs/format-support.md). +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). ## Installation @@ -40,13 +43,13 @@ import { 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. Its default policy removes complete 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. It returns a separate output, container-level change evidence, and an inspection report of that output. +`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. -`verifyMetadata` supports JPEG expectations of `absent`, `present`, or `ignore` for EXIF, XMP, IPTC, comments, and ICC. The default checks the four privacy-clean removal targets. A single-file verification can observe presence or absence; it cannot prove that bytes came from an original file. +`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. ## 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 structures 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, 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). ## Non-goals diff --git a/docs/architecture.md b/docs/architecture.md index 6bf23a7..47bcb12 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,12 +4,11 @@ ```text JPEG APP1 Exif\0\0 ─┐ -PNG eXIf (future) ├──→ bounded TIFF/EXIF core -WebP EXIF (future) ─┘ ↓ - normalized entries +PNG eXIf (future) ┴──→ bounded TIFF/EXIF core → normalized entries +WebP EXIF → normalized container entry only ``` -The TIFF decoder receives only the TIFF byte view after the six-byte EXIF identifier. It 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 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. ## TIFF core @@ -27,9 +26,9 @@ Unknown tags retain namespace, tag number, TIFF type, count, entry offset, and s ## Inspection status -- `format-only`: signature detection only; currently PNG, WebP, and unknown input. -- `container-inspected`: JPEG reached EOI and no EXIF decode was attempted. -- `container-partial`: JPEG traversal stopped on corruption, truncation, or a limit. +- `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. - `metadata-inspected`: reserved for future broader decoders. @@ -46,3 +45,19 @@ input JPEG ``` 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. diff --git a/docs/cleaning-policy.md b/docs/cleaning-policy.md index 356f8fa..607efe1 100644 --- a/docs/cleaning-policy.md +++ b/docs/cleaning-policy.md @@ -1,12 +1,13 @@ # Cleaning Policy -JPEG Privacy Clean removes complete recognized metadata containers. It never rewrites TIFF/EXIF fields, XMP XML, IPTC blocks, comments, or ICC payloads. +Privacy Clean removes complete recognized metadata containers and never decodes or re-encodes image payloads. + +## JPEG | JPEG structure | Default action | | -------------------------------------- | -------------- | | EXIF APP1 | Remove | -| Standard XMP APP1 | Remove | -| Extended XMP APP1 | Remove | +| Standard/extended XMP APP1 | Remove | | Photoshop/IPTC APP13 | Remove | | COM | Remove | | ICC APP2 | Preserve | @@ -16,8 +17,24 @@ JPEG Privacy Clean removes complete recognized metadata containers. It never rew | Structural markers and image/scan data | Preserve | | Data after EOI | Preserve | -Every recognized instance is handled independently and retained content keeps its original order and bytes. Unknown APP removal is intentionally unavailable in Sprint 4. Callers may override the four removal booleans and ICC preservation; `preserveColorProfiles` remains a deprecated alias for `preserveIcc`. +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. + +## 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 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. -v0.1 removes the entire EXIF APP1, including malformed TIFF bodies whose JPEG segment boundary is valid. Selective GPS or tag rewriting and TIFF reserialization are deferred. +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. -`cleanMetadata` returns a new `Uint8Array`, container-level removed/preserved records, diagnostics, and an inspection report of the produced JPEG. A structurally incomplete JPEG is rejected before allocation. PNG, WebP, and unknown inputs return a typed unsupported-format error. +`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. diff --git a/docs/format-support.md b/docs/format-support.md index c6b2720..718b770 100644 --- a/docs/format-support.md +++ b/docs/format-support.md @@ -1,37 +1,33 @@ # Format Support -| Capability | JPEG | PNG | WebP | -| --------------------------- | ---------------------- | -------------- | -------------- | -| Signature detection | Supported | Supported | Supported | -| Bounded container traversal | Supported | Not yet | Not yet | -| EXIF container detection | Supported | Not yet | Not yet | -| TIFF header and IFD0 | Supported through JPEG | Not integrated | Not integrated | -| ExifIFD and GPSIFD | Supported through JPEG | Not integrated | Not integrated | -| Common EXIF/GPS fields | Supported subset | Not integrated | Not integrated | -| MakerNote decoding | Not supported | Not supported | Not supported | -| XMP payload decoding | Not yet | Not yet | Not yet | -| IPTC/ICC payload decoding | Not yet | Not yet | Not yet | -| Whole-container cleaning | Supported | Not yet | Not yet | -| Structured verification | Supported | Not yet | Not yet | +| 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 -Both `II` and `MM` byte orders are supported. Traversal covers IFD0, ExifIFDPointer, GPSInfoIFDPointer, and next-IFD links with table, entry, depth, offset, and cycle checks. +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 is named and retained as opaque structure. +Decoded ExifIFD tags: ExposureTime, FNumber, PhotographicSensitivity, ExifVersion, DateTimeOriginal, DateTimeDigitized, FocalLength, PixelXDimension, PixelYDimension, and FocalLengthIn35mmFilm. MakerNote remains opaque. -Decoded GPS tags: GPSVersionID, GPSLatitudeRef, GPSLatitude, GPSLongitudeRef, GPSLongitude, GPSAltitudeRef, GPSAltitude, GPSTimeStamp, and GPSDateStamp. Coordinates remain exact raw rational components plus reference fields; decimal coordinates are not derived. +Decoded GPS tags include version, latitude/longitude components and references, altitude, time, and date. Coordinates remain exact rational components; decimal coordinates are not derived. -Unknown tags remain structurally represented without speculative meaning or large binary values. +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. -## Remaining container support +## Container cleaning and verification -JPEG marker and scan traversal remains supported. XMP, ICC, and Photoshop/IPTC signatures are container-detected only. PNG requires its complete signature and WebP requires `RIFF....WEBP`; their chunks and metadata are not parsed yet. +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. -## JPEG cleaning and verification +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 Privacy Clean removes recognized EXIF, standard/extended XMP, Photoshop/IPTC, and comment segments. ICC, JFIF/JFXX, Adobe APP14, unknown APP segments, structural markers, all scan data, and trailing bytes are retained. Structurally incomplete JPEGs are rejected; malformed TIFF inside a bounded removable EXIF segment does not block cleaning. - -Verification reports observable container presence or absence for EXIF, XMP, IPTC, comments, and ICC. It does not decode XMP/IPTC/ICC payloads or prove preservation from an original input. +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. diff --git a/docs/security-model.md b/docs/security-model.md index e5680ea..2a0e6b1 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -10,7 +10,7 @@ Binary metadata parsing processes attacker-controlled structures, sizes, offsets 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 cleaning preserves ICC, unknown APP, and rendering/container segments by default. +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. @@ -48,3 +48,11 @@ Cleaning proceeds only after bounded traversal reaches EOI. Truncated lengths, i 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. 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. + +## WebP parsing and cleaning properties + +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. + +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. + +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.