diff --git a/CHANGELOG.md b/CHANGELOG.md index 8393818..114ae77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,8 +33,16 @@ All notable changes will be documented here. The project intends to follow seman ### Changed -- JPEG parser records internal fill-aware rewrite ranges while retaining existing public source offsets. -- Parse-limit validation is shared by inspection and cleaning. +- Normalized cleaning defaults and deprecated ICC alias precedence through one immutable semantic policy across JPEG, WebP, and PNG. +- Verification now omits not-applicable format concepts and fails closed when metadata-entry limits truncate reporting. +- Aligned metadata-entry, diagnostic, status, ICC-classification, and source-order invariants across supported formats. + +### Fixed + +- Enforced `maxMetadataEntries` within a single TIFF IFD and across normalized public metadata reports. +- Enforced `maxDiagnostics` while container/TIFF diagnostics are emitted and in typed incomplete-cleaner errors. +- Kept WebP structural failure state independent from capped diagnostic storage. +- Removed unreachable foundation-era `NotImplementedError` and unused diagnostic codes. ### Foundation diff --git a/README.md b/README.md index ac1cfc0..8677bd0 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ 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, 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. +`DEFAULT_CLEANING_POLICY` is the authoritative semantic default: remove recognized EXIF, XMP, IPTC, comments, ordinary text, and standalone timestamps; preserve ICC, unknown, rendering, and image data. Each format maps only applicable concepts to physical containers. The deprecated `preserveColorProfiles` alias is used only when explicit `preserveIcc` is absent. -`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. +`verifyMetadata` supports `absent`, `present`, or `ignore` expectations. Concepts not implemented for a format produce no check rather than implying an exhaustive search. Verification fails closed if metadata reporting reaches its configured entry limit. Single-file verification observes supported container presence or absence and cannot prove provenance or pixel privacy. ## Security philosophy diff --git a/docs/architecture.md b/docs/architecture.md index ca30407..51c64b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,40 +1,33 @@ # Architecture -`secure-metadata` is a side-effect-free binary library with format-specific containers and a shared metadata decoder. +`secure-metadata` is a side-effect-free binary library with shared semantic policy and format-specific container logic. ```text -JPEG APP1 Exif\0\0 ─┐ -PNG eXIf ┴──→ bounded TIFF/EXIF core → normalized entries -WebP EXIF → normalized container entry only +input + → format detection + → JPEG / WebP / PNG bounded container parser + → optional shared TIFF decoder (JPEG EXIF, PNG eXIf) + → normalized metadata report + → normalized semantic cleaning policy + → format-specific conservative reconstruction + → one output re-inspection + → observational verification ``` -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. +Shared semantic policy does not imply a shared binary writer. JPEG copies retained marker/scan ranges. WebP copies retained chunks and repairs RIFF size plus applicable VP8X metadata bits. PNG copies complete retained chunks and original CRC bytes. Each cleaner performs one input container parse, one final output allocation, and one output inspection. -## TIFF core +## Shared TIFF core -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. +JPEG passes the view after `Exif\0\0`; PNG passes exact `eXIf` data. TIFF byte zero, tag definitions, rational representation, diagnostics, and internal paths such as `IFD0/ExifIFD/DateTimeOriginal` are shared. Outer source containers and relocated absolute offsets remain format-specific. WebP EXIF remains container-only. -## Inspection status - -- `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. - -## Cleaning flows +Traversal validates byte order, magic, complete IFD tables, field sizes, offset values, cycles, and progress. `maxIfdEntries`, `maxIfdDepth`, `maxMetadataEntries`, `maxStringBytes`, and `maxDiagnostics` bound work and reporting. -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. +## Inspection status -```text -PNG bytes - → bounded PNG chunk parser and metadata classification - → shared TIFF decoder for eXIf inspection - → direct keep/remove policy - → checked retained physical ranges - → one output allocation and ordered byte copies - → inspectMetadata(output) - → structured verification checks -``` +- `format-only`: only format detection is available; currently unknown input. +- `container-inspected`: the supported JPEG, WebP, or PNG container structure was fully traversed without shared TIFF decoding. +- `container-partial`: container traversal stopped because structure was unsafe or a structural limit was reached. +- `metadata-partial`: JPEG or PNG container traversal completed and the supported TIFF/EXIF subset was attempted, while broader metadata semantics remain intentionally incomplete. +- `metadata-inspected`: reserved for future exhaustive metadata decoders. -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. +A report includes `metadataTruncated: true` when its entry budget is reached; a diagnostic is also emitted when the diagnostic budget permits. Verification fails closed rather than deriving absence from a truncated report. diff --git a/docs/cleaning-policy.md b/docs/cleaning-policy.md index 5a97ef9..9c4daa9 100644 --- a/docs/cleaning-policy.md +++ b/docs/cleaning-policy.md @@ -1,43 +1,53 @@ # Cleaning Policy -Privacy Clean removes complete recognized metadata containers and never decodes or re-encodes image payloads. +Privacy Clean applies one normalized semantic policy, then maps it directly to each format's physical containers. Binary reconstruction remains format-specific. -## JPEG +## Semantic defaults -| 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 | +| Policy field | Default | Meaning | +| -------------------- | ------- | ----------------------------------------------- | +| `removeExif` | `true` | Remove recognized EXIF containers | +| `removeXmp` | `true` | Remove recognized XMP containers | +| `removeIptc` | `true` | Remove recognized IPTC containers | +| `removeComments` | `true` | Remove recognized comment containers | +| `removeTextMetadata` | `true` | Remove recognized ordinary text metadata | +| `removeTimestamps` | `true` | Remove recognized standalone timestamp metadata | +| `preserveIcc` | `true` | Preserve recognized ICC containers | -## WebP +Unknown, rendering, and image data are preserved. `preserveColorProfiles` remains a deprecated alias for `preserveIcc`: explicit `preserveIcc` wins, otherwise the alias is used, otherwise the default applies. The exported `DEFAULT_CLEANING_POLICY` is authoritative; legacy format-named defaults reference the same frozen object. -| 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 | +## JPEG mapping -WebP cleaning removes targeted physical chunks including padding, repairs RIFF size, and patches only the three VP8X metadata bits. No VP8X is synthesized. +| Semantic field | Physical mapping | +| ---------------- | ------------------------------ | +| `removeExif` | EXIF APP1 | +| `removeXmp` | Standard and extended XMP APP1 | +| `removeIptc` | Photoshop/IPTC APP13 | +| `removeComments` | COM | +| `preserveIcc` | ICC APP2 | -## PNG +JFIF/JFXX, Adobe APP14, unknown APP segments, structural markers, scan data, and data after EOI are preserved. -| 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 mapping -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. +| Semantic field | Physical mapping | +| -------------- | ---------------- | +| `removeExif` | EXIF chunk | +| `removeXmp` | XMP chunk | +| `preserveIcc` | ICCP chunk | -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. +Other semantic fields are not applicable. VP8/VP8L, ALPH, ANIM/ANMF, unknown chunks, and trailing data are preserved. RIFF size and only necessary VP8X ICC/EXIF/XMP bits are repaired. -`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. +## PNG mapping + +| Semantic field | Physical mapping | +| -------------------- | ----------------------------------- | +| `removeExif` | `eXIf` | +| `removeXmp` | Exact XMP `iTXt` | +| `removeTextMetadata` | Ordinary `tEXt`, `zTXt`, and `iTXt` | +| `removeTimestamps` | `tIME` | +| `preserveIcc` | `iCCP` | + +Rendering/color chunks, IDAT, APNG structure, unknown ancillary and critical chunks, retained CRCs, and data after IEND are preserved. Compressed text and ICC payloads are never decompressed. + +One removed physical container produces one source-ordered change record. `cleanMetadata` always returns a distinct output view, change evidence, bounded diagnostics, and one re-inspection report. Valid outer boundaries permit whole-container removal even when inner metadata is malformed; unsafe container boundaries produce a typed incomplete-format error without output. diff --git a/docs/format-support.md b/docs/format-support.md index 5af4cfb..fa028e7 100644 --- a/docs/format-support.md +++ b/docs/format-support.md @@ -35,4 +35,4 @@ The shared TIFF subset covers common IFD0, ExifIFD, GPSIFD, and next-IFD entries 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 or compressed PNG text, prove byte provenance, or prove complete removal of personal information. +Verification reports observable supported metadata-container presence or absence. Expectations for concepts not implemented by a format are omitted as not applicable; they do not generate synthetic passing checks. If `maxMetadataEntries` truncates reporting, the report carries `metadataTruncated: true` and verification returns `valid: false` with no checks. 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 3a5b5a0..4847c26 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -1,34 +1,28 @@ # Security Model -Binary metadata parsing processes attacker-controlled structures, sizes, offsets, encodings, and nesting. Malformed files, parser crashes, excessive allocation or traversal, and incorrect offset arithmetic are security concerns. +All binary input is attacker-controlled. The cross-format guarantees are: -## Invariants +1. bounded reads and checked range arithmetic; +2. advancing or terminating parser loops; +3. hard segment, chunk, IFD, entry, depth, string, diagnostic, and input limits; +4. no recursion over untrusted container structures; +5. no pixel decoding or image re-encoding; +6. no compressed metadata inflation; +7. safe whole-container removal when outer boundaries are trustworthy; +8. unknown metadata preservation by default; +9. ICC and rendering/color preservation by default; +10. deterministic, source-ordered output; +11. exactly one output re-inspection per cleaner; +12. observational verification without provenance or privacy claims. -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. +`maxMetadataEntries` bounds processed TIFF work and normalized public entries. `maxDiagnostics` is enforced while JPEG, WebP, PNG, and TIFF diagnostics are emitted and again when reports are combined. `maxStringBytes` bounds TIFF values and PNG keyword extraction. `maxDecompressedBytes` is reserved and currently unused because no decompression exists. -## Bounded binary and TIFF properties +## Format-specific reconstruction -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 requires trustworthy traversal through EOI and preserves retained marker, fill, scan, restart, and trailing bytes. WebP requires a complete RIFF/chunk boundary, preserves padding and trailing data, repairs RIFF size, and changes only applicable VP8X metadata flags. PNG requires a complete IEND boundary, preserves trailing data, and copies every retained length/type/data/CRC byte unchanged. Each reconstruction uses one final output allocation and honors the caller's exact `Uint8Array` view. -## JPEG and WebP properties +Malformed inner EXIF/TIFF or textual payloads do not block removal of their bounded JPEG segment, WebP chunk, or PNG chunk. Unsafe outer boundaries produce `IncompleteJpegError`, `IncompleteWebPError`, or `IncompletePngError` without partial output. -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. +Verification reports only supported `present` or `absent` observations. Not-applicable format concepts produce no check. Truncated metadata reporting is recorded independently of diagnostic output, produces no checks, and fails verification. The library does not establish authenticity, provenance, absence of proprietary metadata, visible-person privacy, steganography safety, malware safety, or complete metadata absence. -## PNG parsing and cleaning properties - -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. - -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. - -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. - -## Environment and dependencies - -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. +Core production code has zero runtime dependencies and no network, analytics, telemetry, filesystem, DOM, Node `Buffer`, or required platform-global behavior. diff --git a/src/core/diagnostics.ts b/src/core/diagnostics.ts index a634d00..fc2296b 100644 --- a/src/core/diagnostics.ts +++ b/src/core/diagnostics.ts @@ -1,9 +1,7 @@ export type DiagnosticSeverity = "warning" | "error"; export type DiagnosticCode = - | "NOT_IMPLEMENTED" - | "INPUT_LIMIT_EXCEEDED" - | "MALFORMED_INPUT" + | "METADATA_ENTRY_LIMIT_EXCEEDED" | "JPEG_INVALID_SOI" | "JPEG_INVALID_MARKER" | "JPEG_TRUNCATED_MARKER" diff --git a/src/core/errors.ts b/src/core/errors.ts index 34ad6b5..833105d 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,7 +1,6 @@ import type { Diagnostic } from "./diagnostics.js"; export type SecureMetadataErrorCode = - | "NOT_IMPLEMENTED" | "INVALID_OFFSET" | "INVALID_LENGTH" | "OUT_OF_BOUNDS" @@ -25,17 +24,6 @@ export class SecureMetadataError extends Error { } } -export class NotImplementedError extends SecureMetadataError { - override readonly name: string = "NotImplementedError"; - - constructor(operation: string) { - super( - `${operation} is not implemented in the secure-metadata 0.0.0 foundation.`, - "NOT_IMPLEMENTED", - ); - } -} - export type BinaryBoundsErrorCode = "INVALID_OFFSET" | "INVALID_LENGTH" | "OUT_OF_BOUNDS"; diff --git a/src/core/types.ts b/src/core/types.ts index b83a0c2..f54d9ef 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -91,6 +91,7 @@ export interface MetadataReport { readonly inspectionStatus: InspectionStatus; readonly entries: readonly MetadataEntry[]; readonly diagnostics: readonly Diagnostic[]; + readonly metadataTruncated?: true; } export interface CleaningPolicy { diff --git a/src/exif/tiff.ts b/src/exif/tiff.ts index 9c0cf32..4ca411c 100644 --- a/src/exif/tiff.ts +++ b/src/exif/tiff.ts @@ -1,5 +1,6 @@ import { ByteReader } from "../core/binary/index.js"; import type { Diagnostic, DiagnosticCode } from "../core/diagnostics.js"; +import { DEFAULT_PARSE_LIMITS } from "../core/limits.js"; import { decodeTiffValue } from "./decode-value.js"; import { TIFF_FIELD_TYPE, tiffFieldTypeSize } from "./field-types.js"; import { TIFF_TAG, tiffTagDefinition } from "./tags.js"; @@ -15,6 +16,7 @@ export interface TiffParseLimits { readonly maxIfdDepth: number; readonly maxMetadataEntries: number; readonly maxStringBytes: number; + readonly maxDiagnostics?: number; } interface PendingIfd { @@ -30,6 +32,7 @@ interface TiffState { complete: boolean; processedEntries: number; traversalLimitReported: boolean; + readonly maxDiagnostics: number; } function checkedMultiply(left: number, right: number): number | undefined { @@ -45,11 +48,13 @@ function emit( offset?: number, severity: Diagnostic["severity"] = "error", ): void { - state.diagnostics.push( - offset === undefined - ? { severity, code, message } - : { severity, code, message, offset }, - ); + if (state.diagnostics.length < state.maxDiagnostics) { + state.diagnostics.push( + offset === undefined + ? { severity, code, message } + : { severity, code, message, offset }, + ); + } if (severity === "error") { state.complete = false; } @@ -58,13 +63,18 @@ function emit( function initialFailure( code: DiagnosticCode, message: string, + maxDiagnostics: number, offset?: number, ): TiffParseResult { const diagnostic: Diagnostic = offset === undefined ? { severity: "error", code, message } : { severity: "error", code, message, offset }; - return { complete: false, entries: [], diagnostics: [diagnostic] }; + return { + complete: false, + entries: [], + diagnostics: maxDiagnostics === 0 ? [] : [diagnostic], + }; } function byteOrder(bytes: ByteReader): TiffByteOrder | undefined { @@ -119,11 +129,14 @@ export function parseTiff( bytes: Uint8Array, limits: TiffParseLimits, ): TiffParseResult { + const maxDiagnostics = + limits.maxDiagnostics ?? DEFAULT_PARSE_LIMITS.maxDiagnostics; const raw = new ByteReader(bytes); if (!raw.has(0, 8)) { return initialFailure( "TIFF_TRUNCATED_HEADER", "TIFF header requires at least eight bytes.", + maxDiagnostics, 0, ); } @@ -133,6 +146,7 @@ export function parseTiff( return initialFailure( "TIFF_INVALID_BYTE_ORDER", "TIFF byte order must be II or MM.", + maxDiagnostics, 0, ); } @@ -143,14 +157,17 @@ export function parseTiff( byteOrder: order, complete: false, entries: [], - diagnostics: [ - { - severity: "error", - code: "TIFF_INVALID_MAGIC", - message: "TIFF magic value is not 42.", - offset: 2, - }, - ], + diagnostics: + maxDiagnostics === 0 + ? [] + : [ + { + severity: "error", + code: "TIFF_INVALID_MAGIC", + message: "TIFF magic value is not 42.", + offset: 2, + }, + ], }; } @@ -168,14 +185,17 @@ export function parseTiff( byteOrder: order, complete: false, entries: [], - diagnostics: [ - { - severity: "error", - code: "TIFF_INVALID_FIRST_IFD_OFFSET", - message: "TIFF first IFD offset is outside the TIFF payload.", - offset: 4, - }, - ], + diagnostics: + maxDiagnostics === 0 + ? [] + : [ + { + severity: "error", + code: "TIFF_INVALID_FIRST_IFD_OFFSET", + message: "TIFF first IFD offset is outside the TIFF payload.", + offset: 4, + }, + ], }; } @@ -185,6 +205,7 @@ export function parseTiff( complete: true, processedEntries: 0, traversalLimitReported: false, + maxDiagnostics, }; const pending: PendingIfd[] = [ { offset: firstIfdOffset, kind: "ifd0", path: "IFD0", depth: 1 }, @@ -256,6 +277,19 @@ export function parseTiff( const entriesOffset = current.offset + 2; for (let index = 0; index < entryCount; index += 1) { + if (state.processedEntries >= limits.maxMetadataEntries) { + if (!state.traversalLimitReported) { + emit( + state, + "TIFF_TRAVERSAL_LIMIT_EXCEEDED", + `TIFF traversal exceeds maxMetadataEntries ${String(limits.maxMetadataEntries)}.`, + entriesOffset + index * 12, + ); + state.traversalLimitReported = true; + } + break; + } + state.processedEntries += 1; const entryOffset = entriesOffset + index * 12; const tag = reader.u16(entryOffset); const type = reader.u16(entryOffset + 2); @@ -365,7 +399,9 @@ export function parseTiff( ? { diagnostics: [] as readonly Diagnostic[] } : decodeTiffValue(reader, type, count, valueOffset, definition); for (const item of decoded.diagnostics) { - state.diagnostics.push(item); + if (state.diagnostics.length < state.maxDiagnostics) { + state.diagnostics.push(item); + } if (item.severity === "error") { state.complete = false; } @@ -432,5 +468,8 @@ export function parseTiff( complete: state.complete, entries: state.entries, diagnostics: state.diagnostics, + ...(state.traversalLimitReported + ? { entryLimitExceeded: true as const } + : {}), }; } diff --git a/src/exif/types.ts b/src/exif/types.ts index b6c71d1..e695ef8 100644 --- a/src/exif/types.ts +++ b/src/exif/types.ts @@ -29,4 +29,5 @@ export interface TiffParseResult { readonly complete: boolean; readonly entries: readonly TiffDecodedEntry[]; readonly diagnostics: readonly Diagnostic[]; + readonly entryLimitExceeded?: true; } diff --git a/src/index.ts b/src/index.ts index b5603b5..a31f4c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export { inspectMetadata } from "./inspect.js"; export { cleanMetadata, DEFAULT_JPEG_CLEANING_POLICY } from "./policy/clean.js"; +export { DEFAULT_CLEANING_POLICY } from "./policy/normalize.js"; export { DEFAULT_PNG_CLEANING_POLICY } from "./png/clean.js"; export { DEFAULT_WEBP_CLEANING_POLICY } from "./webp/clean.js"; export { @@ -16,7 +17,6 @@ export { IncompleteWebPError, InputLimitExceededError, InvalidParseLimitError, - NotImplementedError, SecureMetadataError, UnsupportedFormatError, } from "./core/errors.js"; diff --git a/src/inspect.ts b/src/inspect.ts index 2912b15..7892814 100644 --- a/src/inspect.ts +++ b/src/inspect.ts @@ -1,5 +1,6 @@ import { ByteReader, toUint8Array } from "./core/binary/index.js"; import { detectFormat } from "./core/detect-format.js"; +import type { Diagnostic } from "./core/diagnostics.js"; import { InputLimitExceededError } from "./core/errors.js"; import { DEFAULT_PARSE_LIMITS, resolveParseLimit } from "./core/limits.js"; import type { ParseLimits } from "./core/limits.js"; @@ -33,9 +34,31 @@ function resolveTiffLimits( maxStringBytes: enabled ? resolveParseLimit("maxStringBytes", limits?.maxStringBytes) : DEFAULT_PARSE_LIMITS.maxStringBytes, + maxDiagnostics: enabled + ? resolveParseLimit("maxDiagnostics", limits?.maxDiagnostics) + : DEFAULT_PARSE_LIMITS.maxDiagnostics, }; } +function boundedDiagnostics( + diagnostics: readonly Diagnostic[], + entryLimitExceeded: boolean, + maxDiagnostics: number, +): readonly Diagnostic[] { + const withEntryLimit = entryLimitExceeded + ? [ + { + severity: "warning" as const, + code: "METADATA_ENTRY_LIMIT_EXCEEDED" as const, + message: + "Metadata report exceeds the configured maxMetadataEntries limit.", + }, + ...diagnostics, + ] + : diagnostics; + return withEntryLimit.slice(0, maxDiagnostics); +} + export function inspectMetadata( input: BinaryInput, options?: InspectOptions, @@ -53,9 +76,18 @@ export function inspectMetadata( const reader = new ByteReader(bytes); const format = detectFormat(reader); if (format === "jpeg") { + const maxMetadataEntries = resolveParseLimit( + "maxMetadataEntries", + options?.limits?.maxMetadataEntries, + ); + const maxDiagnostics = resolveParseLimit( + "maxDiagnostics", + options?.limits?.maxDiagnostics, + ); const jpeg = parseJpeg( reader, resolveParseLimit("maxSegments", options?.limits?.maxSegments), + maxDiagnostics, ); const hasExif = jpeg.segments.some( ({ metadataKind }) => metadataKind === "exif", @@ -64,6 +96,7 @@ export function inspectMetadata( reader, jpeg, resolveTiffLimits(options?.limits, hasExif), + maxMetadataEntries, ); return { format, @@ -74,31 +107,64 @@ export function inspectMetadata( ? "metadata-partial" : "container-inspected", entries: metadata.entries, - diagnostics: [...jpeg.diagnostics, ...metadata.diagnostics], + ...(metadata.entryLimitExceeded + ? { metadataTruncated: true as const } + : {}), + diagnostics: boundedDiagnostics( + [...jpeg.diagnostics, ...metadata.diagnostics], + metadata.entryLimitExceeded, + maxDiagnostics, + ), }; } if (format === "webp") { + const maxMetadataEntries = resolveParseLimit( + "maxMetadataEntries", + options?.limits?.maxMetadataEntries, + ); + const maxDiagnostics = resolveParseLimit( + "maxDiagnostics", + options?.limits?.maxDiagnostics, + ); const webp = parseWebP( reader, resolveParseLimit("maxChunks", options?.limits?.maxChunks), + maxDiagnostics, ); + const metadata = inspectWebPMetadata(webp, maxMetadataEntries); return { format, size: bytes.byteLength, inspectionStatus: webp.complete ? "container-inspected" : "container-partial", - entries: inspectWebPMetadata(webp), - diagnostics: webp.diagnostics, + entries: metadata.entries, + ...(metadata.entryLimitExceeded + ? { metadataTruncated: true as const } + : {}), + diagnostics: boundedDiagnostics( + webp.diagnostics, + metadata.entryLimitExceeded, + maxDiagnostics, + ), }; } if (format === "png") { + const maxMetadataEntries = resolveParseLimit( + "maxMetadataEntries", + options?.limits?.maxMetadataEntries, + ); + const maxDiagnostics = resolveParseLimit( + "maxDiagnostics", + options?.limits?.maxDiagnostics, + ); const png = parsePng( reader, resolveParseLimit("maxChunks", options?.limits?.maxChunks), resolveParseLimit("maxStringBytes", options?.limits?.maxStringBytes), + maxDiagnostics, ); const hasExif = png.chunks.some( ({ metadataKind }) => metadataKind === "exif", @@ -107,6 +173,7 @@ export function inspectMetadata( reader, png, resolveTiffLimits(options?.limits, hasExif), + maxMetadataEntries, ); return { format, @@ -117,7 +184,14 @@ export function inspectMetadata( ? "metadata-partial" : "container-inspected", entries: metadata.entries, - diagnostics: [...png.diagnostics, ...metadata.diagnostics], + ...(metadata.entryLimitExceeded + ? { metadataTruncated: true as const } + : {}), + diagnostics: boundedDiagnostics( + [...png.diagnostics, ...metadata.diagnostics], + metadata.entryLimitExceeded, + maxDiagnostics, + ), }; } diff --git a/src/jpeg/metadata.ts b/src/jpeg/metadata.ts index 1fe77f5..ecafecd 100644 --- a/src/jpeg/metadata.ts +++ b/src/jpeg/metadata.ts @@ -1,5 +1,6 @@ import { type ByteReader } from "../core/binary/index.js"; import type { Diagnostic } from "../core/diagnostics.js"; +import { DEFAULT_PARSE_LIMITS } from "../core/limits.js"; import type { MetadataEntry } from "../core/types.js"; import { metadataEntriesFromTiff, @@ -13,6 +14,7 @@ export interface JpegMetadataInspection { readonly entries: readonly MetadataEntry[]; readonly diagnostics: readonly Diagnostic[]; readonly attemptedExifDecode: boolean; + readonly entryLimitExceeded: boolean; } function source(segment: JpegSegment): MetadataEntry["source"] { @@ -29,14 +31,25 @@ export function inspectJpegMetadata( reader: ByteReader, result: JpegParseResult, tiffLimits: TiffParseLimits, + maxMetadataEntries: number, ): JpegMetadataInspection { const entries: MetadataEntry[] = []; const diagnostics: Diagnostic[] = []; let attemptedExifDecode = false; + let entryLimitExceeded = false; + + const add = (entry: MetadataEntry): boolean => { + if (entries.length >= maxMetadataEntries) { + entryLimitExceeded = true; + return false; + } + entries.push(entry); + return true; + }; for (const segment of result.segments) { if (segment.marker === JPEG_MARKER.COM) { - entries.push({ + add({ id: `jpeg-comment-${String(segment.offset)}`, namespace: "jpeg-comment", name: "JPEG comment", @@ -49,15 +62,15 @@ export function inspectJpegMetadata( switch (segment.metadataKind) { case "exif": { - entries.push({ - id: `jpeg-exif-${String(segment.offset)}`, - namespace: "exif", - name: "EXIF container", - category: "unknown", - privacy: "potentially-sensitive", - source: source(segment), - }); if ( + !add({ + id: `jpeg-exif-${String(segment.offset)}`, + namespace: "exif", + name: "EXIF container", + category: "unknown", + privacy: "potentially-sensitive", + source: source(segment), + }) || segment.payloadOffset === undefined || segment.payloadLength === undefined ) { @@ -66,10 +79,13 @@ export function inspectJpegMetadata( attemptedExifDecode = true; const tiffOffset = segment.payloadOffset + 6; const tiffLength = segment.payloadLength - 6; - const tiff = parseTiff( - reader.slice(tiffOffset, tiffLength), - tiffLimits, - ); + const tiff = parseTiff(reader.slice(tiffOffset, tiffLength), { + ...tiffLimits, + maxMetadataEntries: maxMetadataEntries - entries.length, + maxDiagnostics: + (tiffLimits.maxDiagnostics ?? DEFAULT_PARSE_LIMITS.maxDiagnostics) - + diagnostics.length, + }); entries.push( ...metadataEntriesFromTiff(tiff, { format: "jpeg", @@ -80,10 +96,11 @@ export function inspectJpegMetadata( diagnostics.push( ...relocateTiffDiagnostics(tiff.diagnostics, tiffOffset), ); + entryLimitExceeded ||= tiff.entryLimitExceeded === true; break; } case "xmp": - entries.push({ + add({ id: `jpeg-xmp-${String(segment.offset)}`, namespace: "xmp", name: @@ -96,7 +113,7 @@ export function inspectJpegMetadata( }); break; case "icc": - entries.push({ + add({ id: `jpeg-icc-${String(segment.offset)}`, namespace: "icc", name: "ICC profile container", @@ -106,7 +123,7 @@ export function inspectJpegMetadata( }); break; case "iptc": - entries.push({ + add({ id: `jpeg-iptc-${String(segment.offset)}`, namespace: "iptc", name: "Photoshop/IPTC container", @@ -115,10 +132,13 @@ export function inspectJpegMetadata( source: source(segment), }); break; - default: - break; } } - return { entries, diagnostics, attemptedExifDecode }; + return { + entries, + diagnostics, + attemptedExifDecode, + entryLimitExceeded, + }; } diff --git a/src/jpeg/parser.ts b/src/jpeg/parser.ts index f220429..80e0472 100644 --- a/src/jpeg/parser.ts +++ b/src/jpeg/parser.ts @@ -1,5 +1,6 @@ import { type ByteReader } from "../core/binary/index.js"; import type { Diagnostic, DiagnosticCode } from "../core/diagnostics.js"; +import { DEFAULT_PARSE_LIMITS } from "../core/limits.js"; import { classifyApplicationSegment, classifySegmentKind } from "./classify.js"; import { isApplicationMarker, @@ -21,8 +22,18 @@ interface MarkerPosition { interface ParserState { readonly segments: JpegSegment[]; readonly diagnostics: Diagnostic[]; + readonly maxDiagnostics: number; } +function addDiagnostic( + state: ParserState, + ...items: readonly Diagnostic[] +): void { + const remaining = state.maxDiagnostics - state.diagnostics.length; + if (remaining > 0) { + state.diagnostics.push(...items.slice(0, remaining)); + } +} function diagnostic( severity: Diagnostic["severity"], code: DiagnosticCode, @@ -85,7 +96,8 @@ function addSegment( maxSegments: number, ): boolean { if (state.segments.length >= maxSegments) { - state.diagnostics.push( + addDiagnostic( + state, diagnostic( "error", "JPEG_SEGMENT_LIMIT_EXCEEDED", @@ -134,7 +146,8 @@ function skipScanData( } if (!reader.has(cursor)) { - state.diagnostics.push( + addDiagnostic( + state, diagnostic( "error", "JPEG_TRUNCATED_SCAN", @@ -177,7 +190,8 @@ function skipScanData( return cursor - 1; } - state.diagnostics.push( + addDiagnostic( + state, diagnostic( "error", "JPEG_TRUNCATED_SCAN", @@ -191,11 +205,13 @@ function skipScanData( export function parseJpeg( reader: ByteReader, maxSegments: number, + maxDiagnostics = DEFAULT_PARSE_LIMITS.maxDiagnostics, ): JpegParseResult { - const state: ParserState = { segments: [], diagnostics: [] }; + const state: ParserState = { segments: [], diagnostics: [], maxDiagnostics }; if (!reader.matches(0, [0xff, JPEG_MARKER.SOI])) { - state.diagnostics.push( + addDiagnostic( + state, diagnostic( "error", "JPEG_INVALID_SOI", @@ -228,13 +244,14 @@ export function parseJpeg( while (reader.has(offset)) { const markerResult = readMarker(reader, offset); if ("severity" in markerResult) { - state.diagnostics.push(markerResult); + addDiagnostic(state, markerResult); return incompleteResult(state, true); } const { marker, markerOffset, rangeOffset, afterMarker } = markerResult; if (marker === JPEG_MARKER.SOI) { - state.diagnostics.push( + addDiagnostic( + state, diagnostic( "error", "JPEG_INVALID_MARKER", @@ -267,7 +284,8 @@ export function parseJpeg( offset = afterMarker; if (marker === JPEG_MARKER.EOI) { if (offset < reader.length) { - state.diagnostics.push( + addDiagnostic( + state, diagnostic( "warning", "JPEG_TRAILING_DATA", @@ -288,7 +306,8 @@ export function parseJpeg( } if (!reader.has(afterMarker, 2)) { - state.diagnostics.push( + addDiagnostic( + state, diagnostic( "error", "JPEG_TRUNCATED_SEGMENT_LENGTH", @@ -301,7 +320,8 @@ export function parseJpeg( const declaredLength = reader.u16BE(afterMarker); if (declaredLength < 2) { - state.diagnostics.push( + addDiagnostic( + state, diagnostic( "error", "JPEG_INVALID_SEGMENT_LENGTH", @@ -313,7 +333,8 @@ export function parseJpeg( } if (!reader.has(afterMarker, declaredLength)) { - state.diagnostics.push( + addDiagnostic( + state, diagnostic( "error", "JPEG_TRUNCATED_SEGMENT", @@ -362,7 +383,8 @@ export function parseJpeg( } } - state.diagnostics.push( + addDiagnostic( + state, diagnostic( "error", "JPEG_MISSING_EOI", diff --git a/src/png/clean.ts b/src/png/clean.ts index e1c7612..449eb25 100644 --- a/src/png/clean.ts +++ b/src/png/clean.ts @@ -8,36 +8,20 @@ import type { MetadataNamespace, } from "../core/types.js"; import { inspectMetadata } from "../inspect.js"; +import { + DEFAULT_CLEANING_POLICY, + normalizeCleaningPolicy, + type NormalizedCleaningPolicy, +} from "../policy/normalize.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, -}); +export const DEFAULT_PNG_CLEANING_POLICY = DEFAULT_CLEANING_POLICY; -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 { +function shouldRemove( + chunk: PngChunk, + policy: NormalizedCleaningPolicy, +): boolean { switch (chunk.metadataKind) { case "exif": return policy.removeExif; @@ -109,12 +93,19 @@ export function cleanPng( new ByteReader(bytes), resolveParseLimit("maxChunks", policy?.limits?.maxChunks), resolveParseLimit("maxStringBytes", policy?.limits?.maxStringBytes), + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics), ); if (!parsed.complete) { - throw new IncompletePngError("cleanMetadata", parsed.diagnostics); + throw new IncompletePngError( + "cleanMetadata", + parsed.diagnostics.slice( + 0, + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics), + ), + ); } - const resolved = effectivePolicy(policy); + const resolved = normalizeCleaningPolicy(policy); const removals = parsed.chunks.filter((chunk) => shouldRemove(chunk, resolved), ); diff --git a/src/png/metadata.ts b/src/png/metadata.ts index d461d5b..d626395 100644 --- a/src/png/metadata.ts +++ b/src/png/metadata.ts @@ -1,5 +1,6 @@ import { type ByteReader } from "../core/binary/index.js"; import type { Diagnostic } from "../core/diagnostics.js"; +import { DEFAULT_PARSE_LIMITS } from "../core/limits.js"; import type { MetadataEntry } from "../core/types.js"; import { metadataEntriesFromTiff, @@ -12,6 +13,7 @@ export interface PngMetadataInspection { readonly entries: readonly MetadataEntry[]; readonly diagnostics: readonly Diagnostic[]; readonly attemptedExifDecode: boolean; + readonly entryLimitExceeded: boolean; } function source(chunk: PngChunk): MetadataEntry["source"] { @@ -28,26 +30,47 @@ export function inspectPngMetadata( reader: ByteReader, result: PngParseResult, tiffLimits: TiffParseLimits, + maxMetadataEntries: number, ): PngMetadataInspection { const entries: MetadataEntry[] = []; const diagnostics: Diagnostic[] = []; let attemptedExifDecode = false; + let entryLimitExceeded = false; + + const add = (entry: MetadataEntry): boolean => { + if (entries.length >= maxMetadataEntries) { + entryLimitExceeded = true; + return false; + } + entries.push(entry); + return true; + }; 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), - }); + if ( + !add({ + id: `png-exif-${String(chunk.offset)}`, + namespace: "exif", + name: "PNG EXIF container", + category: "unknown", + privacy: "potentially-sensitive", + source: source(chunk), + }) + ) { + break; + } attemptedExifDecode = true; const tiff = parseTiff( reader.slice(chunk.dataOffset, chunk.dataLength), - tiffLimits, + { + ...tiffLimits, + maxMetadataEntries: maxMetadataEntries - entries.length, + maxDiagnostics: + (tiffLimits.maxDiagnostics ?? + DEFAULT_PARSE_LIMITS.maxDiagnostics) - diagnostics.length, + }, ); entries.push( ...metadataEntriesFromTiff(tiff, { @@ -59,10 +82,11 @@ export function inspectPngMetadata( diagnostics.push( ...relocateTiffDiagnostics(tiff.diagnostics, chunk.dataOffset), ); + entryLimitExceeded ||= tiff.entryLimitExceeded === true; break; } case "xmp": - entries.push({ + add({ id: `png-xmp-${String(chunk.offset)}`, namespace: "xmp", name: "PNG XMP iTXt container", @@ -72,7 +96,7 @@ export function inspectPngMetadata( }); break; case "text": - entries.push({ + add({ id: `png-text-${String(chunk.offset)}`, namespace: "png-text", name: @@ -85,7 +109,7 @@ export function inspectPngMetadata( }); break; case "timestamp": - entries.push({ + add({ id: `png-time-${String(chunk.offset)}`, namespace: "png-time", name: "PNG modification time", @@ -95,7 +119,7 @@ export function inspectPngMetadata( }); break; case "icc": - entries.push({ + add({ id: `png-icc-${String(chunk.offset)}`, namespace: "icc", name: "PNG ICC profile container", @@ -107,5 +131,10 @@ export function inspectPngMetadata( } } - return { entries, diagnostics, attemptedExifDecode }; + return { + entries, + diagnostics, + attemptedExifDecode, + entryLimitExceeded, + }; } diff --git a/src/png/parser.ts b/src/png/parser.ts index 4500c9b..84c5719 100644 --- a/src/png/parser.ts +++ b/src/png/parser.ts @@ -1,5 +1,6 @@ import { type ByteReader } from "../core/binary/index.js"; import type { Diagnostic, DiagnosticCode } from "../core/diagnostics.js"; +import { DEFAULT_PARSE_LIMITS } from "../core/limits.js"; import { pngCrc32 } from "./crc32.js"; import type { PngChunk, @@ -11,6 +12,16 @@ import type { const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; const XMP_KEYWORD = "XML:com.adobe.xmp"; +function addDiagnostic( + diagnostics: Diagnostic[], + maximum: number, + ...items: readonly Diagnostic[] +): void { + const remaining = maximum - diagnostics.length; + if (remaining > 0) { + diagnostics.push(...items.slice(0, remaining)); + } +} function diagnostic( severity: Diagnostic["severity"], code: DiagnosticCode, @@ -94,6 +105,7 @@ function readKeyword( dataLength: number, maxStringBytes: number, diagnostics: Diagnostic[], + maxDiagnostics: number, fourCC: string, ): { readonly value: string; readonly afterKeyword: number } | undefined { const keywordLimit = Math.min(maxStringBytes, 79); @@ -103,7 +115,9 @@ function readKeyword( continue; } if (index === 0) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "warning", "PNG_INVALID_TEXT", @@ -123,7 +137,9 @@ function readKeyword( }; } - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, dataLength > keywordLimit && keywordLimit === maxStringBytes ? diagnostic( "warning", @@ -145,24 +161,30 @@ export function parsePng( reader: ByteReader, maxChunks: number, maxStringBytes: number, + maxDiagnostics = DEFAULT_PARSE_LIMITS.maxDiagnostics, ): PngParseResult { const diagnostics: Diagnostic[] = []; const chunks: PngChunk[] = []; if (!reader.matches(0, PNG_SIGNATURE)) { - return failure([ + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "error", "PNG_INVALID_SIGNATURE", "PNG input does not contain the complete eight-byte signature.", 0, ), - ]); + ); + return failure(diagnostics); } let offset = 8; while (offset < reader.length) { if (chunks.length >= maxChunks) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "error", "PNG_CHUNK_LIMIT_EXCEEDED", @@ -174,7 +196,9 @@ export function parsePng( } const remaining = reader.length - offset; if (remaining < 4) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "error", "PNG_TRUNCATED_CHUNK_LENGTH", @@ -185,7 +209,9 @@ export function parsePng( return failure(diagnostics, chunks, offset); } if (remaining < 8) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "error", "PNG_TRUNCATED_CHUNK_TYPE", @@ -200,7 +226,9 @@ export function parsePng( const typeOffset = offset + 4; for (let index = 0; index < 4; index += 1) { if (!isAsciiLetter(reader.u8(typeOffset + index))) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "error", "PNG_INVALID_CHUNK_TYPE", @@ -216,7 +244,9 @@ export function parsePng( const dataOffset = offset + 8; const available = reader.length - dataOffset; if (dataLength > available) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "error", "PNG_TRUNCATED_CHUNK_DATA", @@ -227,7 +257,9 @@ export function parsePng( return failure(diagnostics, chunks, offset); } if (available - dataLength < 4) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "error", "PNG_MISSING_CRC", @@ -241,7 +273,9 @@ export function parsePng( const crcOffset = dataOffset + dataLength; const totalLength = 12 + dataLength; if (!Number.isSafeInteger(totalLength) || totalLength > remaining) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "error", "PNG_TRUNCATED_CHUNK_DATA", @@ -263,6 +297,7 @@ export function parsePng( dataLength, maxStringBytes, diagnostics, + maxDiagnostics, type, ); keyword = parsedKeyword?.value; @@ -272,7 +307,9 @@ export function parsePng( parsedKeyword !== undefined && parsedKeyword.afterKeyword >= dataOffset + dataLength ) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "warning", "PNG_INVALID_TEXT", @@ -283,7 +320,9 @@ export function parsePng( } } else if (type === "iTXt" && parsedKeyword !== undefined) { if (dataOffset + dataLength - parsedKeyword.afterKeyword < 2) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "warning", "PNG_INVALID_TEXT", @@ -295,7 +334,9 @@ export function parsePng( const flag = reader.u8(parsedKeyword.afterKeyword); textCompressed = flag === 1; if (flag > 1) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "warning", "PNG_INVALID_TEXT", @@ -312,7 +353,9 @@ export function parsePng( const actualCrc = pngCrc32(reader.slice(typeOffset, 4 + dataLength)); const crcValid = expectedCrc === actualCrc; if (!crcValid) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "warning", "PNG_INVALID_CRC", @@ -342,7 +385,9 @@ export function parsePng( if (type === "IEND") { if (dataLength !== 0) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "error", "PNG_INVALID_IEND", @@ -353,7 +398,9 @@ export function parsePng( return failure(diagnostics, chunks, offset); } if (offset < reader.length) { - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "warning", "PNG_TRAILING_DATA", @@ -372,7 +419,9 @@ export function parsePng( } } - diagnostics.push( + addDiagnostic( + diagnostics, + maxDiagnostics, diagnostic( "error", "PNG_MISSING_IEND", diff --git a/src/policy/clean.ts b/src/policy/clean.ts index 1faa06a..b8ebe65 100644 --- a/src/policy/clean.ts +++ b/src/policy/clean.ts @@ -17,36 +17,20 @@ import type { import { inspectMetadata } from "../inspect.js"; import { parseJpeg } from "../jpeg/parser.js"; import type { JpegSegment } from "../jpeg/types.js"; +import { + DEFAULT_CLEANING_POLICY, + normalizeCleaningPolicy, + type NormalizedCleaningPolicy, +} from "./normalize.js"; import { cleanPng } from "../png/clean.js"; import { cleanWebP } from "../webp/clean.js"; -export const DEFAULT_JPEG_CLEANING_POLICY = Object.freeze({ - removeExif: true, - removeXmp: true, - removeIptc: true, - removeComments: true, - preserveIcc: true, -}); - -interface EffectivePolicy { - readonly removeExif: boolean; - readonly removeXmp: boolean; - readonly removeIptc: boolean; - readonly removeComments: boolean; - readonly preserveIcc: boolean; -} +export const DEFAULT_JPEG_CLEANING_POLICY = DEFAULT_CLEANING_POLICY; -function effectivePolicy(policy: CleaningPolicy | undefined): EffectivePolicy { - return { - removeExif: policy?.removeExif ?? true, - removeXmp: policy?.removeXmp ?? true, - removeIptc: policy?.removeIptc ?? true, - removeComments: policy?.removeComments ?? true, - preserveIcc: policy?.preserveIcc ?? policy?.preserveColorProfiles ?? true, - }; -} - -function shouldRemove(segment: JpegSegment, policy: EffectivePolicy): boolean { +function shouldRemove( + segment: JpegSegment, + policy: NormalizedCleaningPolicy, +): boolean { if (segment.kind === "comment") { return policy.removeComments; } @@ -219,12 +203,19 @@ export function cleanMetadata( const jpeg = parseJpeg( reader, resolveParseLimit("maxSegments", policy?.limits?.maxSegments), + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics), ); if (!jpeg.complete) { - throw new IncompleteJpegError("cleanMetadata", jpeg.diagnostics); + throw new IncompleteJpegError( + "cleanMetadata", + jpeg.diagnostics.slice( + 0, + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics), + ), + ); } - const resolved = effectivePolicy(policy); + const resolved = normalizeCleaningPolicy(policy); const removals = jpeg.segments.filter((segment) => shouldRemove(segment, resolved), ); diff --git a/src/policy/normalize.ts b/src/policy/normalize.ts new file mode 100644 index 0000000..a5edef3 --- /dev/null +++ b/src/policy/normalize.ts @@ -0,0 +1,42 @@ +import type { CleaningPolicy } from "../core/types.js"; + +export interface NormalizedCleaningPolicy { + readonly removeExif: boolean; + readonly removeXmp: boolean; + readonly removeIptc: boolean; + readonly removeComments: boolean; + readonly removeTextMetadata: boolean; + readonly removeTimestamps: boolean; + readonly preserveIcc: boolean; +} + +export const DEFAULT_CLEANING_POLICY: Readonly = + Object.freeze({ + removeExif: true, + removeXmp: true, + removeIptc: true, + removeComments: true, + removeTextMetadata: true, + removeTimestamps: true, + preserveIcc: true, + }); + +export function normalizeCleaningPolicy( + policy?: CleaningPolicy, +): Readonly { + return Object.freeze({ + removeExif: policy?.removeExif ?? DEFAULT_CLEANING_POLICY.removeExif, + removeXmp: policy?.removeXmp ?? DEFAULT_CLEANING_POLICY.removeXmp, + removeIptc: policy?.removeIptc ?? DEFAULT_CLEANING_POLICY.removeIptc, + removeComments: + policy?.removeComments ?? DEFAULT_CLEANING_POLICY.removeComments, + removeTextMetadata: + policy?.removeTextMetadata ?? DEFAULT_CLEANING_POLICY.removeTextMetadata, + removeTimestamps: + policy?.removeTimestamps ?? DEFAULT_CLEANING_POLICY.removeTimestamps, + preserveIcc: + policy?.preserveIcc ?? + policy?.preserveColorProfiles ?? + DEFAULT_CLEANING_POLICY.preserveIcc, + }); +} diff --git a/src/verify/verify.ts b/src/verify/verify.ts index 7149300..ff45b04 100644 --- a/src/verify/verify.ts +++ b/src/verify/verify.ts @@ -61,6 +61,14 @@ export function verifyMetadata( throw new UnsupportedFormatError("verifyMetadata", report.format); } + if (report.metadataTruncated === true) { + return { + valid: false, + checks: [], + report, + diagnostics: report.diagnostics, + }; + } const privacyDefault = expectation?.requireNoPrivacyRelevantMetadata === false ? "ignore" diff --git a/src/webp/clean.ts b/src/webp/clean.ts index de49aec..a71eea4 100644 --- a/src/webp/clean.ts +++ b/src/webp/clean.ts @@ -8,31 +8,21 @@ import type { MetadataNamespace, } from "../core/types.js"; import { inspectMetadata } from "../inspect.js"; +import { + DEFAULT_CLEANING_POLICY, + normalizeCleaningPolicy, + type NormalizedCleaningPolicy, +} from "../policy/normalize.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, -}); +export const DEFAULT_WEBP_CLEANING_POLICY = DEFAULT_CLEANING_POLICY; -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 { +function shouldRemove( + chunk: WebPChunk, + policy: NormalizedCleaningPolicy, +): boolean { switch (chunk.metadataKind) { case "exif": return policy.removeExif; @@ -84,12 +74,19 @@ export function cleanWebP( const parsed = parseWebP( new ByteReader(bytes), resolveParseLimit("maxChunks", policy?.limits?.maxChunks), + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics), ); if (!parsed.complete) { - throw new IncompleteWebPError("cleanMetadata", parsed.diagnostics); + throw new IncompleteWebPError( + "cleanMetadata", + parsed.diagnostics.slice( + 0, + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics), + ), + ); } - const resolved = effectivePolicy(policy); + const resolved = normalizeCleaningPolicy(policy); const removals = parsed.chunks.filter((chunk) => shouldRemove(chunk, resolved), ); diff --git a/src/webp/metadata.ts b/src/webp/metadata.ts index 53c396c..cb5987a 100644 --- a/src/webp/metadata.ts +++ b/src/webp/metadata.ts @@ -1,10 +1,27 @@ import type { MetadataEntry } from "../core/types.js"; import type { WebPParseResult } from "./types.js"; +export interface WebPMetadataInspection { + readonly entries: readonly MetadataEntry[]; + readonly entryLimitExceeded: boolean; +} + export function inspectWebPMetadata( result: WebPParseResult, -): readonly MetadataEntry[] { - return result.chunks.flatMap((chunk): readonly MetadataEntry[] => { + maxMetadataEntries: number, +): WebPMetadataInspection { + const entries: MetadataEntry[] = []; + let entryLimitExceeded = false; + + for (const chunk of result.chunks) { + if (chunk.metadataKind === undefined) { + continue; + } + if (entries.length >= maxMetadataEntries) { + entryLimitExceeded = true; + continue; + } + const source = { format: "webp" as const, container: "webp-chunk" as const, @@ -12,43 +29,39 @@ export function inspectWebPMetadata( 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, - }, - ]; + entries.push({ + id: `webp-exif-${String(chunk.offset)}`, + namespace: "exif", + name: "WebP EXIF container", + category: "unknown", + privacy: "potentially-sensitive", + source, + }); + break; case "xmp": - return [ - { - id: `webp-xmp-${String(chunk.offset)}`, - namespace: "xmp", - name: "WebP XMP container", - category: "unknown", - privacy: "potentially-sensitive", - source, - }, - ]; + entries.push({ + id: `webp-xmp-${String(chunk.offset)}`, + namespace: "xmp", + name: "WebP XMP container", + category: "unknown", + privacy: "potentially-sensitive", + source, + }); + break; case "icc": - return [ - { - id: `webp-icc-${String(chunk.offset)}`, - namespace: "icc", - name: "WebP ICC profile container", - category: "color", - privacy: "non-sensitive", - source, - }, - ]; - default: - return []; + entries.push({ + id: `webp-icc-${String(chunk.offset)}`, + namespace: "icc", + name: "WebP ICC profile container", + category: "color", + privacy: "non-sensitive", + source, + }); + break; } - }); + } + + return { entries, entryLimitExceeded }; } diff --git a/src/webp/parser.ts b/src/webp/parser.ts index 6dcec08..b63a4e6 100644 --- a/src/webp/parser.ts +++ b/src/webp/parser.ts @@ -1,5 +1,6 @@ import { type ByteReader } from "../core/binary/index.js"; import type { Diagnostic, DiagnosticCode } from "../core/diagnostics.js"; +import { DEFAULT_PARSE_LIMITS } from "../core/limits.js"; import { classifyWebPChunk, WEBP_VP8X_FLAG, @@ -41,23 +42,33 @@ function fourCC(reader: ByteReader, offset: number): string { export function parseWebP( reader: ByteReader, maxChunks: number, + maxDiagnostics = DEFAULT_PARSE_LIMITS.maxDiagnostics, ): WebPParseResult { const diagnostics: Diagnostic[] = []; const chunks: WebPChunk[] = []; + let hasStructuralError = false; + const addDiagnostic = (...items: readonly Diagnostic[]): void => { + hasStructuralError ||= items.some(({ severity }) => severity === "error"); + const remaining = maxDiagnostics - diagnostics.length; + if (remaining > 0) { + diagnostics.push(...items.slice(0, remaining)); + } + }; if ( !reader.has(0, 12) || !reader.matches(0, RIFF) || !reader.matches(8, WEBP) ) { - return failure([ + addDiagnostic( diagnostic( "error", "WEBP_INVALID_RIFF_HEADER", "WebP input requires a 12-byte RIFF....WEBP header.", 0, ), - ]); + ); + return failure(diagnostics); } const declaredRiffSize = reader.u32LE(4); @@ -67,35 +78,29 @@ export function parseWebP( !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, + addDiagnostic( + diagnostic( + "error", + "WEBP_INVALID_RIFF_SIZE", + "WebP RIFF size does not include the WEBP form type.", + 4, + ), ); + return failure(diagnostics, chunks, containerLength); } if (containerLength > reader.length) { - return failure( - [ - diagnostic( - "error", - "WEBP_TRUNCATED_RIFF", - "WebP RIFF size extends beyond the supplied input.", - 4, - ), - ], - chunks, - containerLength, + addDiagnostic( + diagnostic( + "error", + "WEBP_TRUNCATED_RIFF", + "WebP RIFF size extends beyond the supplied input.", + 4, + ), ); + return failure(diagnostics, chunks, containerLength); } if (containerLength < reader.length) { - diagnostics.push( + addDiagnostic( diagnostic( "warning", "WEBP_TRAILING_DATA", @@ -109,7 +114,7 @@ export function parseWebP( let vp8xCount = 0; while (offset < containerLength) { if (chunks.length >= maxChunks) { - diagnostics.push( + addDiagnostic( diagnostic( "error", "WEBP_CHUNK_LIMIT_EXCEEDED", @@ -120,7 +125,7 @@ export function parseWebP( return failure(diagnostics, chunks, containerLength); } if (containerLength - offset < 8) { - diagnostics.push( + addDiagnostic( diagnostic( "error", "WEBP_TRUNCATED_CHUNK_HEADER", @@ -136,7 +141,7 @@ export function parseWebP( const payloadOffset = offset + 8; const payloadEnd = payloadOffset + payloadLength; if (!Number.isSafeInteger(payloadEnd) || payloadEnd > containerLength) { - diagnostics.push( + addDiagnostic( diagnostic( "error", "WEBP_TRUNCATED_CHUNK", @@ -149,7 +154,7 @@ export function parseWebP( const padding = payloadLength % 2; if (padding === 1 && payloadEnd === containerLength) { - diagnostics.push( + addDiagnostic( diagnostic( "error", "WEBP_INVALID_PADDING", @@ -164,7 +169,7 @@ export function parseWebP( !Number.isSafeInteger(totalLength) || totalLength > containerLength - offset ) { - diagnostics.push( + addDiagnostic( diagnostic( "error", "WEBP_TRUNCATED_CHUNK", @@ -180,7 +185,7 @@ export function parseWebP( if (type === "VP8X") { vp8xCount += 1; if (vp8xCount > 1) { - diagnostics.push( + addDiagnostic( diagnostic( "error", "WEBP_DUPLICATE_VP8X", @@ -190,7 +195,7 @@ export function parseWebP( ); } if (payloadLength !== 10) { - diagnostics.push( + addDiagnostic( diagnostic( "error", "WEBP_INVALID_VP8X", @@ -215,9 +220,6 @@ export function parseWebP( offset += totalLength; } - const hasStructuralError = diagnostics.some( - ({ severity }) => severity === "error", - ); const vp8x = chunks.find(({ fourCC: type }) => type === "VP8X"); if (!hasStructuralError && vp8x?.vp8xFlags !== undefined) { const observedFlags = @@ -231,7 +233,7 @@ export function parseWebP( ? WEBP_VP8X_FLAG.xmp : 0); if ((vp8x.vp8xFlags & WEBP_VP8X_METADATA_MASK) !== observedFlags) { - diagnostics.push( + addDiagnostic( diagnostic( "warning", "WEBP_INCONSISTENT_FEATURE_FLAGS", diff --git a/tests/malformed/tiff-traversal-limit.test.ts b/tests/malformed/tiff-traversal-limit.test.ts index 46d20ff..d67cb7f 100644 --- a/tests/malformed/tiff-traversal-limit.test.ts +++ b/tests/malformed/tiff-traversal-limit.test.ts @@ -36,4 +36,56 @@ describe("TIFF total traversal limit", () => { expect.objectContaining({ code: "TIFF_TRAVERSAL_LIMIT_EXCEEDED" }), ); }); + + it("caps entries within a single IFD table", () => { + const input = new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.ORIENTATION, + type: TIFF_FIELD_TYPE.SHORT, + count: 1, + value: 1, + }, + { + tag: TIFF_TAG.ORIENTATION, + type: TIFF_FIELD_TYPE.SHORT, + count: 1, + value: 2, + }, + ]) + .finish(); + + const result = parseTiff(input, { + maxIfdEntries: 4_096, + maxIfdDepth: 16, + maxMetadataEntries: 1, + maxStringBytes: 4 * 1024 * 1024, + }); + + expect(result.entries).toHaveLength(1); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_TRAVERSAL_LIMIT_EXCEEDED" }), + ); + }); + + it("caps diagnostics while continuing bounded TIFF work", () => { + const input = new TiffBuilder() + .ifd(8, [ + { tag: 1, type: 99, count: 1 }, + { tag: 2, type: 99, count: 1 }, + ]) + .finish(); + + const result = parseTiff(input, { + maxIfdEntries: 4_096, + maxIfdDepth: 16, + maxMetadataEntries: 10, + maxStringBytes: 4 * 1024 * 1024, + maxDiagnostics: 1, + }); + + expect(result.entries).toHaveLength(2); + expect(result.diagnostics).toHaveLength(1); + expect(result.complete).toBe(false); + }); }); diff --git a/tests/unit/cross-format-hardening.test.ts b/tests/unit/cross-format-hardening.test.ts new file mode 100644 index 0000000..5d45000 --- /dev/null +++ b/tests/unit/cross-format-hardening.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; + +import { + cleanMetadata, + DEFAULT_CLEANING_POLICY, + DEFAULT_JPEG_CLEANING_POLICY, + DEFAULT_PNG_CLEANING_POLICY, + DEFAULT_WEBP_CLEANING_POLICY, + IncompletePngError, + inspectMetadata, + verifyMetadata, + type CleaningPolicy, + type VerificationPolicy, +} from "../../src/index.js"; +import { ICC, jpeg, MARKER, segment, XMP } from "../helpers/jpeg-builder.js"; +import { + chunk as pngChunk, + png, + PNG_SIGNATURE, + textChunk, +} from "../helpers/png-builder.js"; +import { chunk as webpChunk, webp } from "../helpers/webp-builder.js"; + +const ICC_FIXTURES = [ + ["jpeg", jpeg(segment(MARKER.APP2, ICC))], + ["webp", webp([webpChunk("ICCP")])], + ["png", png([pngChunk("iCCP"), pngChunk("IEND")])], +] as const; + +describe("cross-format policy normalization", () => { + it("uses one immutable authoritative default policy", () => { + expect(DEFAULT_CLEANING_POLICY).toEqual({ + removeExif: true, + removeXmp: true, + removeIptc: true, + removeComments: true, + removeTextMetadata: true, + removeTimestamps: true, + preserveIcc: true, + }); + expect(Object.isFrozen(DEFAULT_CLEANING_POLICY)).toBe(true); + expect(DEFAULT_JPEG_CLEANING_POLICY).toBe(DEFAULT_CLEANING_POLICY); + expect(DEFAULT_WEBP_CLEANING_POLICY).toBe(DEFAULT_CLEANING_POLICY); + expect(DEFAULT_PNG_CLEANING_POLICY).toBe(DEFAULT_CLEANING_POLICY); + }); + + it("applies deprecated ICC alias precedence identically once across all formats", () => { + const cases: readonly [CleaningPolicy, boolean][] = [ + [{ preserveColorProfiles: false }, false], + [{ preserveIcc: true, preserveColorProfiles: false }, true], + [{ preserveIcc: false, preserveColorProfiles: true }, false], + ]; + + for (const [, input] of ICC_FIXTURES) { + for (const [policy, expectedPresent] of cases) { + const result = cleanMetadata(input, policy); + expect( + result.report.entries.some(({ namespace }) => namespace === "icc"), + ).toBe(expectedPresent); + } + } + }); +}); + +describe("cross-format reporting and verification", () => { + it.each(ICC_FIXTURES)("classifies %s ICC consistently", (_format, input) => { + expect(inspectMetadata(input).entries).toContainEqual( + expect.objectContaining({ + namespace: "icc", + category: "color", + privacy: "non-sensitive", + }), + ); + }); + + it.each([ + ["jpeg", jpeg(), { textMetadata: "present", timestamps: "present" }], + [ + "webp", + webp([]), + { + iptc: "present", + comments: "present", + textMetadata: "present", + timestamps: "present", + }, + ], + ["png", png([pngChunk("IEND")]), { iptc: "present", comments: "present" }], + ] as const)( + "omits not-applicable %s expectations instead of claiming a search", + (_format, input, unsupported) => { + const result = verifyMetadata(input, { + ...unsupported, + requireNoPrivacyRelevantMetadata: false, + } as VerificationPolicy); + + expect(result.valid).toBe(true); + expect(result.checks).toEqual([]); + }, + ); + + it.each([ + ["jpeg", jpeg(segment(MARKER.APP1, XMP), segment(MARKER.APP2, ICC))], + ["webp", webp([webpChunk("XMP "), webpChunk("ICCP")])], + ["png", png([textChunk("Author"), pngChunk("tIME"), pngChunk("IEND")])], + ] as const)("bounds normalized %s metadata entries", (_format, input) => { + const report = inspectMetadata(input, { + limits: { maxMetadataEntries: 1 }, + }); + + expect(report.entries).toHaveLength(1); + expect(report.metadataTruncated).toBe(true); + expect(report.diagnostics).toContainEqual( + expect.objectContaining({ code: "METADATA_ENTRY_LIMIT_EXCEEDED" }), + ); + }); + + it("caps accumulated diagnostics for reports and typed cleaner errors", () => { + const warnings = png([ + pngChunk("IDAT", Uint8Array.of(1), 0), + pngChunk("IDAT", Uint8Array.of(2), 0), + pngChunk("IEND"), + ]); + expect( + inspectMetadata(warnings, { limits: { maxDiagnostics: 1 } }).diagnostics, + ).toHaveLength(1); + + try { + cleanMetadata(PNG_SIGNATURE, { limits: { maxDiagnostics: 0 } }); + expect.fail("Expected incomplete PNG cleaning to fail."); + } catch (error) { + expect(error).toBeInstanceOf(IncompletePngError); + expect((error as IncompletePngError).diagnostics).toEqual([]); + } + }); + + it("fails verification closed when metadata reporting is truncated", () => { + const input = webp([webpChunk("EXIF"), webpChunk("XMP ")]); + const result = verifyMetadata(input, { + exif: "ignore", + xmp: "absent", + requireNoPrivacyRelevantMetadata: false, + limits: { maxMetadataEntries: 1, maxDiagnostics: 0 }, + }); + + expect(result.valid).toBe(false); + expect(result.checks).toEqual([]); + expect(result.report.metadataTruncated).toBe(true); + expect(result.diagnostics).toEqual([]); + }); +}); diff --git a/tests/unit/webp-parser.test.ts b/tests/unit/webp-parser.test.ts index b2e707b..7ff558a 100644 --- a/tests/unit/webp-parser.test.ts +++ b/tests/unit/webp-parser.test.ts @@ -122,4 +122,14 @@ describe("WebP RIFF parser", () => { expect.objectContaining({ code: "WEBP_CHUNK_LIMIT_EXCEEDED" }), ); }); + + it("keeps structural failure state when its diagnostic is capped", () => { + const input = webp([chunk("VP8X")], Uint8Array.of(1)); + const result = parseWebP(new ByteReader(input), 100, 1); + + expect(result.complete).toBe(false); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ code: "WEBP_TRAILING_DATA" }), + ]); + }); });