diff --git a/CHANGELOG.md b/CHANGELOG.md index ab62d76..79938dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,16 @@ All notable changes will be documented here. The project intends to follow seman ### Added -- Bounded JPEG marker and length-prefixed segment traversal. -- Standalone, fill-byte, restart-marker, EOI, and multi-scan handling. -- JPEG APP and COM container classification. -- EXIF, standard/extended XMP, ICC, Photoshop/IPTC, JFIF/JFXX, and Adobe presence detection. -- Structured malformed, truncation, trailing-data, and segment-limit diagnostics. -- Normalized JPEG metadata-container entries and complete/partial container status. -- Bounded binary reader, no-copy input normalization, and JPEG/PNG/WebP format detection. -- Binary boundary, sliced-view, format, malformed-input, and JPEG container tests. +- Shared bounded little- and big-endian TIFF/EXIF decoder. +- Iterative IFD0, ExifIFD, GPSIFD, and next-IFD traversal. +- IFD entry/depth limits and repeated-offset cycle protection. +- Inline and TIFF-relative offset value handling for common field types. +- Exact RATIONAL/SRATIONAL, conservative ASCII, and full-range LONG/SLONG decoding. +- Common IFD0, ExifIFD, and GPS tag normalization with deterministic source paths. +- JPEG EXIF child-field inspection through bounded TIFF-only subviews. +- Structured malformed header, table, pointer, value, type, rational, limit, and cycle diagnostics. +- Bounded JPEG marker traversal and EXIF/XMP/ICC/IPTC container detection. +- Binary boundary, JPEG container, TIFF endian, malformed, cycle, and integration tests. ### Foundation diff --git a/README.md b/README.md index e607588..e470e37 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,17 @@ Current implementation: - bounded binary input and endian-aware read core; - JPEG, PNG, and WebP signature detection; -- bounded JPEG marker and segment traversal; -- JPEG entropy-scan skipping without image decoding; -- JPEG EXIF, XMP, extended XMP, ICC, Photoshop/IPTC, and comment container-presence detection. +- bounded JPEG marker, segment, and entropy-scan traversal; +- JPEG EXIF, XMP, ICC, Photoshop/IPTC, and comment container detection; +- shared little- and big-endian TIFF/EXIF decoder; +- iterative IFD0, ExifIFD, GPSIFD, and next-IFD traversal with cycle and depth protection; +- common TIFF, EXIF, and GPS field decoding with exact rational values. -Not implemented: TIFF/EXIF or GPS field decoding, XML/IPTC/ICC payload decoding, PNG/WebP container parsing, metadata cleaning, and verification. +Not implemented: MakerNote or thumbnail decoding, XMP/IPTC/ICC payload parsing, PNG/WebP container parsing, metadata cleaning, and verification. ## Format status -JPEG reports can be `container-inspected` or `container-partial`. PNG and WebP remain `format-only`. See [format support](docs/format-support.md) for the precise matrix. - -A detected or traversed container is not necessarily a decodable image. The JPEG parser validates marker and segment boundaries, not quantization, Huffman, frame, scan-header, or entropy semantics. +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). ## Installation @@ -34,21 +34,23 @@ import { } from "secure-metadata"; ``` -`inspectMetadata` accepts `Uint8Array | ArrayBuffer`, enforces relevant parse limits, and returns a deterministic report. For JPEG it inventories the container and emits one normalized entry per recognized privacy/color metadata container. Entries identify container presence only; payload values are not decoded. +`inspectMetadata` accepts `Uint8Array | ArrayBuffer`, enforces relevant parser limits, and returns deterministic normalized entries. JPEG EXIF reports retain the EXIF container entry and add decoded child entries with exact TIFF tag, type, count, source offset, and path information. + +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` and `verifyMetadata` still throw a typed `NotImplementedError`. Node.js `Buffer` values work structurally as `Uint8Array` but are not part of the public contract. +`cleanMetadata` and `verifyMetadata` still throw a typed `NotImplementedError`. ## Security philosophy -Every byte is untrusted. Binary reads use centrally checked ranges, parser input views retain their original boundaries, traversal is hard bounded, and malformed or tiny inputs are ordinary data. Unknown APP segments remain unknown and should be preserved by future 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 structures remain unknown and should be preserved by future cleaning. See the [security model](docs/security-model.md), [architecture](docs/architecture.md), and [cleaning policy](docs/cleaning-policy.md). ## Non-goals -The library does not perform image decoding or encoding, visual redaction, pixel-content privacy analysis, steganography detection, or malware scanning. Absence of recognized metadata containers is never proof that an image contains no private information. +The library does not perform image decoding or encoding, visual redaction, pixel-content privacy analysis, steganography detection, or malware scanning. Absence of recognized or decoded metadata is never proof that an image contains no private information. ## Secure Tools ecosystem -This is an independent open-source library in the broader Secure Tools ecosystem. It has its own package, lifecycle, and repository; future Secure Tools integration will use a pinned browser artifact rather than coupling application code to this repository. +This is an independent open-source library in the broader Secure Tools ecosystem. Future integration will use a pinned browser artifact rather than coupling application code to this repository. ## License diff --git a/docs/architecture.md b/docs/architecture.md index 51209dc..8d6a414 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,44 +1,34 @@ # Architecture -`secure-metadata` is organized as a side-effect-free binary library. Its current and planned flow is: +`secure-metadata` is a side-effect-free binary library with format-specific containers and shared metadata decoders. ```text -Input bytes implemented - ↓ -Safe binary view / bounded reads implemented - ↓ -Format detection implemented - ↓ -JPEG container parser implemented for JPEG - ↓ -Metadata container classification implemented for JPEG - ↓ -Metadata payload decoder planned - ↓ -Normalization / field classification planned - ↓ -Policy engine and cleaner planned - ↓ -Output re-inspection / verification planned +JPEG APP1 Exif\0\0 ─┐ +PNG eXIf (future) ├──→ bounded TIFF/EXIF core +WebP EXIF (future) ─┘ ↓ + normalized entries ``` -## Binary core +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. -Input normalization returns the caller's exact `Uint8Array` view or creates a no-copy view over an `ArrayBuffer`. `ByteReader` validates offsets and lengths as non-negative safe integers and checks remaining capacity with subtraction before every read. It provides bounded unsigned 8-, 16-, and 32-bit reads, subarray views, and allocation-free signature matching. +## TIFF core -## JPEG container layer +The decoder explicitly validates `II` or `MM`, magic value 42, and the first IFD offset. `TiffReader` centralizes endian-aware unsigned 16-/32-bit and signed 32-bit access over the bounded binary core. -The iterative JPEG parser validates SOI and walks markers using the binary core. A central marker model distinguishes SOI, EOI, TEM, RST0–RST7, APP0–APP15, COM, SOS, common image-structure markers, and length-prefixed unknown markers. Repeated `FF` fill bytes are collapsed to one marker; declared lengths include their two-byte length field and must fit fully before offsets advance. +Each IFD table is validated as a complete `2 + count × 12 + 4` byte range before entries are visited. Field sizes support BYTE, ASCII, SHORT, LONG, RATIONAL, UNDEFINED, SLONG, and SRATIONAL. Values of four bytes or fewer use the entry's inline bytes in TIFF byte order; larger values use a bounded TIFF-relative offset. -After SOS, the parser scans rather than decodes entropy data. `FF 00` remains stuffed data, restart markers are recorded without terminating the scan, and the next real marker resumes normal traversal. This supports multiple scans. Every marker, including SOI, EOI, SOS, and restarts, counts toward `maxSegments`. +Traversal uses a FIFO work queue. Root IFD0 has depth 1; ExifIFD, GPSIFD, and next-IFD work is queued deterministically in that order. A visited-offset set rejects cycles and repeated references. `maxIfdEntries` bounds each table, `maxIfdDepth` bounds linked depth, and `maxMetadataEntries` caps total processed entries and queued IFD work. -APP signatures are checked within segment payload boundaries without retaining payload copies. EXIF, standard/extended XMP, ICC, Photoshop/IPTC, JFIF/JFXX, Adobe, and unknown classifications remain container-level observations. +## Value and entry behavior -## Inspection status +Supported known values are decoded without converting exact rational pairs to floating point. ASCII stops at the first NUL within its declared count and maps non-ASCII bytes conservatively. Zero rational denominators remain represented and produce diagnostics. + +Unknown tags retain namespace, tag number, TIFF type, count, entry offset, and source path without exposing arbitrary payload bytes. Duplicate tags remain separate ordered entries. MakerNote is recognized but opaque and is never interpreted as nested standard TIFF. -- `format-only`: a signature was detected; no container parser ran. Currently PNG, WebP, unknown, and short arbitrary inputs. -- `container-inspected`: JPEG traversal reached EOI safely. -- `container-partial`: JPEG identity is known, but traversal stopped on a structural error, truncation, or limit. -- `metadata-inspected`: reserved for future payload decoders. +## Inspection status -An empty entry list means no supported metadata container was recognized during the completed portion of traversal. It does not prove metadata or private information is absent. +- `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. +- `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. diff --git a/docs/format-support.md b/docs/format-support.md index e2da878..b1e5581 100644 --- a/docs/format-support.md +++ b/docs/format-support.md @@ -1,33 +1,30 @@ # Format Support -| Capability | JPEG | PNG | WebP | -| --------------------------- | --------------------------------- | --------- | --------- | -| Signature detection | Supported | Supported | Supported | -| Bounded container traversal | Supported | Not yet | Not yet | -| APP/COM classification | Supported | N/A | N/A | -| EXIF container detection | Supported | Not yet | Not yet | -| XMP container detection | Supported, including extended XMP | Not yet | Not yet | -| ICC container detection | Supported | Not yet | Not yet | -| IPTC/Photoshop detection | Supported | Not yet | Not yet | -| Metadata field decoding | Not yet | Not yet | Not yet | -| Cleaning | Not yet | Not yet | Not yet | +| 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 | +| Cleaning and verification | Not yet | Not yet | Not yet | -## JPEG +## TIFF/EXIF subset -JPEG detection requires `FF D8`. Container inspection validates marker boundaries and two-byte big-endian declared lengths, recognizes standalone markers and fill bytes, stops at EOI, and reports trailing bytes. SOS headers are traversed, while entropy-coded bytes are skipped without decoding; `FF 00`, RST0–RST7, and multiple scans are handled structurally. +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. -Payload signatures identify: +Decoded IFD0 tags: ImageDescription, Make, Model, Orientation, Software, DateTime, Artist, and Copyright. -- APP0 `JFIF\0` and `JFXX\0` as technical container data; -- APP1 `Exif\0\0` as EXIF; -- APP1 standard and extended Adobe XMP identifiers as XMP; -- APP2 `ICC_PROFILE\0` as ICC; -- APP13 `Photoshop 3.0\0` as Photoshop/IPTC; -- APP14 `Adobe` as rendering/container data; -- COM as comment metadata. +Decoded ExifIFD tags: ExposureTime, FNumber, PhotographicSensitivity, ExifVersion, DateTimeOriginal, DateTimeDigitized, FocalLength, PixelXDimension, PixelYDimension, and FocalLengthIn35mmFilm. MakerNote is named and retained as opaque structure. -Unknown APP payloads remain unknown. No TIFF, EXIF, XMP XML, ICC, IPTC, thumbnail, frame, Huffman, quantization, or entropy payload is decoded. +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. -## PNG and WebP +Unknown tags remain structurally represented without speculative meaning or large binary values. -PNG requires its complete eight-byte signature. WebP requires `RIFF` at offset 0 and `WEBP` at offset 8. Their chunk structures, sizes, CRCs, metadata, and image payloads are not yet parsed. +## Remaining container support + +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. diff --git a/docs/security-model.md b/docs/security-model.md index 3a7558f..8459b57 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -1,46 +1,42 @@ # Security Model -Binary metadata parsing processes attacker-controlled structure, sizes, offsets, encodings, and nesting. Malformed files, parser crashes, excessive allocation or traversal, and incorrect offset arithmetic are security concerns. +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. ## Invariants -1. All binary input is untrusted. -2. All binary reads go through bounded primitives. -3. No parser may perform unchecked offset arithmetic. -4. Parser traversal must be hard bounded. -5. TIFF/IFD traversal must eventually include cycle detection. -6. Core functions must not make network requests. -7. Core functions must not access the filesystem. -8. The library must not decode image pixel payloads. -9. Unknown metadata must not be deleted by inference. -10. Privacy cleaning must preserve ICC and color data by default unless explicitly requested otherwise. -11. Cleaning should preserve unaffected bytes byte-for-byte whenever practical. -12. Cleaner output must be independently inspectable and verifiable. -13. The library must never claim that an image contains no private information merely because metadata is absent. -14. Steganography detection, malware scanning, visual redaction, and pixel-content privacy analysis are outside project scope. - -## Bounded binary reads - -Offsets and lengths must be non-negative safe integers. Ranges use `length <= inputLength - offset`, avoiding overflow-prone addition during validation. Invalid offsets, lengths, and ranges throw typed library errors before `DataView` access. A supplied `Uint8Array` retains its exact offset and length; `ArrayBuffer` normalization creates a no-copy byte view. Inspection never writes through either representation. - -## JPEG-specific properties - -- Marker reads and fill-byte scans remain within the supplied input view. -- Every recorded marker, including restart markers inside scans, counts toward `maxSegments`. -- A declared segment length must be at least two and fit completely before subtraction or offset advancement. -- APP signatures must fit within their segment payload and cannot match across segment boundaries. -- Entropy-coded scan data is traversed but never decoded or copied. -- `FF 00` stuffing remains data; RST0–RST7 do not terminate a scan. -- Normal parsing resumes at non-stuffed, non-restart markers, allowing multiple SOS scans. -- EOI stops traversal; trailing bytes produce a warning rather than being parsed as JPEG. -- Malformed and truncated JPEGs return bounded structured diagnostics instead of uncontrolled native bounds exceptions. - -All parser loops are iterative. Each successful branch advances its cursor or returns, which prevents non-progress cycles on hostile fill, scan, or marker data. - -## Hard limits - -`inspectMetadata` enforces `maxInputBytes` before parsing. JPEG traversal enforces `maxSegments`; unused limits remain reserved for their future parsers. Defaults are conservative safeguards rather than permanent `0.x` API guarantees. +1. All binary input is untrusted and all reads use bounded primitives. +2. Parsers perform no unchecked offset arithmetic or unbounded recursion. +3. Parser traversal and attacker-controlled counts are hard bounded. +4. Core functions make no network requests and access no filesystem or DOM APIs. +5. Image pixel payloads are never decoded. +6. Unknown metadata is not deleted or assigned speculative meaning. +7. ICC and color data will be preserved by default during future cleaning. +8. Cleaner output must eventually be independently inspected and verified. +9. Metadata absence never proves an image contains no private information. +10. Steganography detection, malware scanning, visual redaction, and pixel privacy analysis are outside scope. + +## Bounded binary and JPEG properties + +Offsets and lengths must be non-negative safe integers. Ranges use subtraction-based capacity checks before access. JPEG declared segment lengths must fit completely; marker and scan loops always advance or return. `FF 00`, restart markers, multiple scans, EOI, and marker limits are handled without entropy decoding or payload copies. + +## TIFF-specific properties + +- Byte order is accepted only from explicit `II` or `MM`; magic 42 is validated before traversal. +- The decoder receives a bounded TIFF-only view. All TIFF and IFD offsets are relative to its header, never to JPEG or APP1. +- A full IFD table range, including the next-IFD pointer, is validated before entry iteration. +- `count × typeSize` uses checked safe-integer multiplication before range calculations. +- Inline values use their actual byte region and endian order; offset values must fit completely within the TIFF view. +- `maxIfdEntries` bounds per-IFD work, `maxIfdDepth` bounds linked depth, and `maxMetadataEntries` caps total entry and queue work. +- A visited-offset set rejects cyclic and repeated IFD references. +- Unsupported types and invalid individual values produce diagnostics while later safe entries remain recoverable. +- Large or extreme values are rejected before allocation or reading. Known numeric component decoding has an additional small hard cap. +- Duplicate tags remain ordered; unknown tags retain structure without arbitrary binary payload copies. +- MakerNote stays opaque and is never recursively interpreted. +- RATIONAL and SRATIONAL preserve exact components; zero denominators produce diagnostics rather than division. +- No thumbnail, TIFF image, JPEG image, or pixel data is decoded. + +Every traversal or decoding loop has a validated finite count or advances a bounded cursor. Native `DataView` bounds errors are not used as control flow. ## Environment and dependencies -Core code is local-only and side-effect-free. It has no network, analytics, telemetry, filesystem, DOM, or pixel-codec behavior. The package has zero runtime dependencies. Development tools are not part of the shipped runtime. +Core code is local-only and side-effect-free. It has no network, analytics, telemetry, filesystem, DOM, or pixel-codec behavior. The package has zero runtime dependencies. diff --git a/src/core/diagnostics.ts b/src/core/diagnostics.ts index c690773..3481a27 100644 --- a/src/core/diagnostics.ts +++ b/src/core/diagnostics.ts @@ -13,7 +13,21 @@ export type DiagnosticCode = | "JPEG_TRUNCATED_SCAN" | "JPEG_MISSING_EOI" | "JPEG_SEGMENT_LIMIT_EXCEEDED" - | "JPEG_TRAILING_DATA"; + | "JPEG_TRAILING_DATA" + | "TIFF_TRUNCATED_HEADER" + | "TIFF_INVALID_BYTE_ORDER" + | "TIFF_INVALID_MAGIC" + | "TIFF_INVALID_FIRST_IFD_OFFSET" + | "TIFF_TRUNCATED_IFD" + | "TIFF_IFD_ENTRY_LIMIT_EXCEEDED" + | "TIFF_IFD_DEPTH_LIMIT_EXCEEDED" + | "TIFF_TRAVERSAL_LIMIT_EXCEEDED" + | "TIFF_CYCLIC_IFD" + | "TIFF_UNSUPPORTED_FIELD_TYPE" + | "TIFF_INVALID_VALUE_RANGE" + | "TIFF_INVALID_VALUE_OFFSET" + | "TIFF_INVALID_POINTER" + | "TIFF_INVALID_RATIONAL"; export interface Diagnostic { readonly severity: DiagnosticSeverity; diff --git a/src/core/types.ts b/src/core/types.ts index 5d88f19..7772761 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -33,7 +33,19 @@ export type MetadataCategory = export type PrivacyRelevance = "sensitive" | "potentially-sensitive" | "non-sensitive" | "unknown"; -export type MetadataValue = string | number | boolean | Uint8Array; +export interface RationalValue { + readonly numerator: number; + readonly denominator: number; +} + +export type MetadataValue = + | string + | number + | boolean + | Uint8Array + | RationalValue + | readonly RationalValue[] + | readonly number[]; export type MetadataContainer = "jpeg-segment" | "png-chunk" | "webp-chunk" | "tiff-ifd" | "unknown"; @@ -45,7 +57,10 @@ export interface MetadataSource { readonly length?: number; readonly jpegMarker?: number; readonly chunkType?: string; - readonly tiffPath?: readonly number[]; + readonly tiffPath?: string; + readonly tiffTag?: number; + readonly tiffType?: number; + readonly tiffCount?: number; } export interface MetadataEntry { @@ -66,6 +81,7 @@ export type InspectionStatus = | "format-only" | "container-inspected" | "container-partial" + | "metadata-partial" | "metadata-inspected"; export interface MetadataReport { diff --git a/src/exif/decode-value.ts b/src/exif/decode-value.ts new file mode 100644 index 0000000..5d98715 --- /dev/null +++ b/src/exif/decode-value.ts @@ -0,0 +1,160 @@ +import type { Diagnostic } from "../core/diagnostics.js"; +import type { MetadataValue, RationalValue } from "../core/types.js"; +import { TIFF_FIELD_TYPE } from "./field-types.js"; +import type { TiffReader } from "./tiff-reader.js"; +import type { TiffTagDefinition } from "./tags.js"; + +const MAX_DECODED_COMPONENTS = 1_024; + +export interface DecodedTiffValue { + readonly value?: MetadataValue; + readonly diagnostics: readonly Diagnostic[]; +} + +function scalarOrArray(values: readonly T[]): T | readonly T[] { + if (values.length === 1) { + const value = values[0]; + if (value !== undefined) { + return value; + } + } + return values; +} + +function ascii(reader: TiffReader, offset: number, count: number): string { + let result = ""; + for (let index = 0; index < count; index += 1) { + const byte = reader.u8(offset + index); + if (byte === 0) { + break; + } + result += byte <= 0x7f ? String.fromCharCode(byte) : "?"; + } + return result; +} + +function unsignedValues( + reader: TiffReader, + offset: number, + count: number, + width: 1 | 2 | 4, +): number | readonly number[] { + const values: number[] = []; + for (let index = 0; index < count; index += 1) { + const componentOffset = offset + index * width; + values.push( + width === 1 + ? reader.u8(componentOffset) + : width === 2 + ? reader.u16(componentOffset) + : reader.u32(componentOffset), + ); + } + return scalarOrArray(values); +} + +function signedLongValues( + reader: TiffReader, + offset: number, + count: number, +): number | readonly number[] { + const values: number[] = []; + for (let index = 0; index < count; index += 1) { + values.push(reader.i32(offset + index * 4)); + } + return scalarOrArray(values); +} + +function rationalValues( + reader: TiffReader, + offset: number, + count: number, + signed: boolean, +): DecodedTiffValue { + const diagnostics: Diagnostic[] = []; + const values: RationalValue[] = []; + for (let index = 0; index < count; index += 1) { + const componentOffset = offset + index * 8; + const numerator = signed + ? reader.i32(componentOffset) + : reader.u32(componentOffset); + const denominator = signed + ? reader.i32(componentOffset + 4) + : reader.u32(componentOffset + 4); + values.push({ numerator, denominator }); + if (denominator === 0) { + diagnostics.push({ + severity: "error", + code: "TIFF_INVALID_RATIONAL", + message: "TIFF rational value has a zero denominator.", + offset: componentOffset + 4, + }); + } + } + return { value: scalarOrArray(values), diagnostics }; +} + +export function decodeTiffValue( + reader: TiffReader, + type: number, + count: number, + valueOffset: number, + definition: TiffTagDefinition, +): DecodedTiffValue { + if (count > MAX_DECODED_COMPONENTS && type !== TIFF_FIELD_TYPE.ASCII) { + return { + diagnostics: [ + { + severity: "error", + code: "TIFF_INVALID_VALUE_RANGE", + message: `TIFF value has too many components to decode (${String(count)}).`, + offset: valueOffset, + }, + ], + }; + } + + if (definition.special === "exif-version") { + return { value: ascii(reader, valueOffset, count), diagnostics: [] }; + } + + if (definition.special === "gps-version") { + const components: number[] = []; + for (let index = 0; index < count; index += 1) { + components.push(reader.u8(valueOffset + index)); + } + return { value: components.join("."), diagnostics: [] }; + } + + switch (type) { + case TIFF_FIELD_TYPE.ASCII: + return { value: ascii(reader, valueOffset, count), diagnostics: [] }; + case TIFF_FIELD_TYPE.BYTE: + case TIFF_FIELD_TYPE.UNDEFINED: + return { + value: unsignedValues(reader, valueOffset, count, 1), + diagnostics: [], + }; + case TIFF_FIELD_TYPE.SHORT: + return { + value: unsignedValues(reader, valueOffset, count, 2), + diagnostics: [], + }; + case TIFF_FIELD_TYPE.LONG: + return { + value: unsignedValues(reader, valueOffset, count, 4), + diagnostics: [], + }; + case TIFF_FIELD_TYPE.SLONG: + return { + value: signedLongValues(reader, valueOffset, count), + diagnostics: [], + }; + case TIFF_FIELD_TYPE.RATIONAL: + return rationalValues(reader, valueOffset, count, false); + case TIFF_FIELD_TYPE.SRATIONAL: + return rationalValues(reader, valueOffset, count, true); + default: + return { diagnostics: [] }; + } +} diff --git a/src/exif/field-types.ts b/src/exif/field-types.ts new file mode 100644 index 0000000..c5b725a --- /dev/null +++ b/src/exif/field-types.ts @@ -0,0 +1,25 @@ +export const TIFF_FIELD_TYPE = { + BYTE: 1, + ASCII: 2, + SHORT: 3, + LONG: 4, + RATIONAL: 5, + UNDEFINED: 7, + SLONG: 9, + SRATIONAL: 10, +} as const; + +const FIELD_TYPE_SIZES: Readonly> = { + [TIFF_FIELD_TYPE.BYTE]: 1, + [TIFF_FIELD_TYPE.ASCII]: 1, + [TIFF_FIELD_TYPE.SHORT]: 2, + [TIFF_FIELD_TYPE.LONG]: 4, + [TIFF_FIELD_TYPE.RATIONAL]: 8, + [TIFF_FIELD_TYPE.UNDEFINED]: 1, + [TIFF_FIELD_TYPE.SLONG]: 4, + [TIFF_FIELD_TYPE.SRATIONAL]: 8, +}; + +export function tiffFieldTypeSize(type: number): number | undefined { + return FIELD_TYPE_SIZES[type]; +} diff --git a/src/exif/index.ts b/src/exif/index.ts new file mode 100644 index 0000000..8bb1389 --- /dev/null +++ b/src/exif/index.ts @@ -0,0 +1,3 @@ +export { parseTiff } from "./tiff.js"; +export type { TiffParseLimits } from "./tiff.js"; +export type { TiffDecodedEntry, TiffParseResult } from "./types.js"; diff --git a/src/exif/metadata.ts b/src/exif/metadata.ts new file mode 100644 index 0000000..26cc66c --- /dev/null +++ b/src/exif/metadata.ts @@ -0,0 +1,44 @@ +import type { Diagnostic } from "../core/diagnostics.js"; +import type { ImageFormat, MetadataEntry } from "../core/types.js"; +import type { TiffParseResult } from "./types.js"; + +export interface TiffMetadataSourceContext { + readonly format: ImageFormat; + readonly baseOffset: number; + readonly idPrefix: string; +} + +export function metadataEntriesFromTiff( + result: TiffParseResult, + context: TiffMetadataSourceContext, +): readonly MetadataEntry[] { + return result.entries.map((entry) => ({ + id: `${context.idPrefix}-${String(entry.entryOffset)}-${entry.tag.toString(16)}`, + namespace: entry.namespace, + name: entry.name, + category: entry.category, + privacy: entry.privacy, + ...(entry.value === undefined ? {} : { value: entry.value }), + source: { + format: context.format, + container: "tiff-ifd", + offset: context.baseOffset + entry.entryOffset, + length: 12, + tiffPath: entry.path, + tiffTag: entry.tag, + tiffType: entry.type, + tiffCount: entry.count, + }, + })); +} + +export function relocateTiffDiagnostics( + diagnostics: readonly Diagnostic[], + baseOffset: number, +): readonly Diagnostic[] { + return diagnostics.map((item) => + item.offset === undefined + ? item + : { ...item, offset: baseOffset + item.offset }, + ); +} diff --git a/src/exif/tags.ts b/src/exif/tags.ts new file mode 100644 index 0000000..98b8fa6 --- /dev/null +++ b/src/exif/tags.ts @@ -0,0 +1,187 @@ +import type { + MetadataCategory, + MetadataNamespace, + PrivacyRelevance, +} from "../core/types.js"; +import type { TiffIfdKind } from "./types.js"; + +export const TIFF_TAG = { + IMAGE_DESCRIPTION: 0x010e, + MAKE: 0x010f, + MODEL: 0x0110, + ORIENTATION: 0x0112, + SOFTWARE: 0x0131, + DATE_TIME: 0x0132, + ARTIST: 0x013b, + COPYRIGHT: 0x8298, + EXIF_IFD_POINTER: 0x8769, + GPS_IFD_POINTER: 0x8825, + EXPOSURE_TIME: 0x829a, + F_NUMBER: 0x829d, + ISO_SPEED: 0x8827, + EXIF_VERSION: 0x9000, + DATE_TIME_ORIGINAL: 0x9003, + DATE_TIME_DIGITIZED: 0x9004, + FOCAL_LENGTH: 0x920a, + MAKER_NOTE: 0x927c, + PIXEL_X_DIMENSION: 0xa002, + PIXEL_Y_DIMENSION: 0xa003, + FOCAL_LENGTH_35MM: 0xa405, +} as const; + +export interface TiffTagDefinition { + readonly name: string; + readonly namespace: MetadataNamespace; + readonly category: MetadataCategory; + readonly privacy: PrivacyRelevance; + readonly special?: "exif-version" | "gps-version"; +} + +const IFD0_TAGS: Readonly> = { + [TIFF_TAG.IMAGE_DESCRIPTION]: { + name: "ImageDescription", + namespace: "exif", + category: "description", + privacy: "potentially-sensitive", + }, + [TIFF_TAG.MAKE]: { + name: "Make", + namespace: "exif", + category: "device", + privacy: "potentially-sensitive", + }, + [TIFF_TAG.MODEL]: { + name: "Model", + namespace: "exif", + category: "device", + privacy: "potentially-sensitive", + }, + [TIFF_TAG.ORIENTATION]: { + name: "Orientation", + namespace: "exif", + category: "technical", + privacy: "non-sensitive", + }, + [TIFF_TAG.SOFTWARE]: { + name: "Software", + namespace: "exif", + category: "software", + privacy: "potentially-sensitive", + }, + [TIFF_TAG.DATE_TIME]: { + name: "DateTime", + namespace: "exif", + category: "timestamp", + privacy: "potentially-sensitive", + }, + [TIFF_TAG.ARTIST]: { + name: "Artist", + namespace: "exif", + category: "identity", + privacy: "sensitive", + }, + [TIFF_TAG.COPYRIGHT]: { + name: "Copyright", + namespace: "exif", + category: "rights", + privacy: "potentially-sensitive", + }, +}; + +const EXIF_TAGS: Readonly> = { + [TIFF_TAG.EXPOSURE_TIME]: technical("ExposureTime"), + [TIFF_TAG.F_NUMBER]: technical("FNumber"), + [TIFF_TAG.ISO_SPEED]: technical("PhotographicSensitivity"), + [TIFF_TAG.EXIF_VERSION]: { + ...technical("ExifVersion"), + special: "exif-version", + }, + [TIFF_TAG.DATE_TIME_ORIGINAL]: timestamp("DateTimeOriginal"), + [TIFF_TAG.DATE_TIME_DIGITIZED]: timestamp("DateTimeDigitized"), + [TIFF_TAG.FOCAL_LENGTH]: technical("FocalLength"), + [TIFF_TAG.PIXEL_X_DIMENSION]: technical("PixelXDimension"), + [TIFF_TAG.PIXEL_Y_DIMENSION]: technical("PixelYDimension"), + [TIFF_TAG.FOCAL_LENGTH_35MM]: technical("FocalLengthIn35mmFilm"), + [TIFF_TAG.MAKER_NOTE]: { + name: "MakerNote", + namespace: "exif", + category: "unknown", + privacy: "potentially-sensitive", + }, +}; + +const GPS_TAGS: Readonly> = { + 0x0000: { + name: "GPSVersionID", + namespace: "gps", + category: "technical", + privacy: "non-sensitive", + special: "gps-version", + }, + 0x0001: location("GPSLatitudeRef"), + 0x0002: location("GPSLatitude"), + 0x0003: location("GPSLongitudeRef"), + 0x0004: location("GPSLongitude"), + 0x0005: location("GPSAltitudeRef"), + 0x0006: location("GPSAltitude"), + 0x0007: { + name: "GPSTimeStamp", + namespace: "gps", + category: "timestamp", + privacy: "potentially-sensitive", + }, + 0x001d: { + name: "GPSDateStamp", + namespace: "gps", + category: "timestamp", + privacy: "potentially-sensitive", + }, +}; + +function technical(name: string): TiffTagDefinition { + return { + name, + namespace: "exif", + category: "technical", + privacy: "non-sensitive", + }; +} + +function timestamp(name: string): TiffTagDefinition { + return { + name, + namespace: "exif", + category: "timestamp", + privacy: "potentially-sensitive", + }; +} + +function location(name: string): TiffTagDefinition { + return { + name, + namespace: "gps", + category: "location", + privacy: "sensitive", + }; +} + +export function tiffTagDefinition( + kind: TiffIfdKind, + tag: number, +): TiffTagDefinition { + const definition = + kind === "gps" + ? GPS_TAGS[tag] + : kind === "exif" + ? EXIF_TAGS[tag] + : IFD0_TAGS[tag]; + + return ( + definition ?? { + name: `Tag0x${tag.toString(16).toUpperCase().padStart(4, "0")}`, + namespace: kind === "gps" ? "gps" : "exif", + category: "unknown", + privacy: "unknown", + } + ); +} diff --git a/src/exif/tiff-reader.ts b/src/exif/tiff-reader.ts new file mode 100644 index 0000000..483d2ec --- /dev/null +++ b/src/exif/tiff-reader.ts @@ -0,0 +1,40 @@ +import { ByteReader } from "../core/binary/index.js"; + +export type TiffByteOrder = "little" | "big"; + +export class TiffReader { + readonly length: number; + readonly #reader: ByteReader; + readonly #littleEndian: boolean; + + constructor(bytes: Uint8Array, byteOrder: TiffByteOrder) { + this.#reader = new ByteReader(bytes); + this.#littleEndian = byteOrder === "little"; + this.length = bytes.byteLength; + } + + has(offset: number, length = 1): boolean { + return this.#reader.has(offset, length); + } + + u8(offset: number): number { + return this.#reader.u8(offset); + } + + u16(offset: number): number { + return this.#littleEndian + ? this.#reader.u16LE(offset) + : this.#reader.u16BE(offset); + } + + u32(offset: number): number { + return this.#littleEndian + ? this.#reader.u32LE(offset) + : this.#reader.u32BE(offset); + } + + i32(offset: number): number { + const value = this.u32(offset); + return value >= 0x8000_0000 ? value - 0x1_0000_0000 : value; + } +} diff --git a/src/exif/tiff.ts b/src/exif/tiff.ts new file mode 100644 index 0000000..9c0cf32 --- /dev/null +++ b/src/exif/tiff.ts @@ -0,0 +1,436 @@ +import { ByteReader } from "../core/binary/index.js"; +import type { Diagnostic, DiagnosticCode } from "../core/diagnostics.js"; +import { decodeTiffValue } from "./decode-value.js"; +import { TIFF_FIELD_TYPE, tiffFieldTypeSize } from "./field-types.js"; +import { TIFF_TAG, tiffTagDefinition } from "./tags.js"; +import { TiffReader, type TiffByteOrder } from "./tiff-reader.js"; +import type { + TiffDecodedEntry, + TiffIfdKind, + TiffParseResult, +} from "./types.js"; + +export interface TiffParseLimits { + readonly maxIfdEntries: number; + readonly maxIfdDepth: number; + readonly maxMetadataEntries: number; + readonly maxStringBytes: number; +} + +interface PendingIfd { + readonly offset: number; + readonly kind: TiffIfdKind; + readonly path: string; + readonly depth: number; +} + +interface TiffState { + readonly entries: TiffDecodedEntry[]; + readonly diagnostics: Diagnostic[]; + complete: boolean; + processedEntries: number; + traversalLimitReported: boolean; +} + +function checkedMultiply(left: number, right: number): number | undefined { + return left <= Math.floor(Number.MAX_SAFE_INTEGER / right) + ? left * right + : undefined; +} + +function emit( + state: TiffState, + code: DiagnosticCode, + message: string, + offset?: number, + severity: Diagnostic["severity"] = "error", +): void { + state.diagnostics.push( + offset === undefined + ? { severity, code, message } + : { severity, code, message, offset }, + ); + if (severity === "error") { + state.complete = false; + } +} + +function initialFailure( + code: DiagnosticCode, + message: string, + offset?: number, +): TiffParseResult { + const diagnostic: Diagnostic = + offset === undefined + ? { severity: "error", code, message } + : { severity: "error", code, message, offset }; + return { complete: false, entries: [], diagnostics: [diagnostic] }; +} + +function byteOrder(bytes: ByteReader): TiffByteOrder | undefined { + if (bytes.matches(0, [0x49, 0x49])) { + return "little"; + } + if (bytes.matches(0, [0x4d, 0x4d])) { + return "big"; + } + return undefined; +} + +function queueTarget( + reader: TiffReader, + state: TiffState, + pending: PendingIfd[], + target: number, + kind: TiffIfdKind, + path: string, + depth: number, + sourceOffset: number, + maxTargets: number, +): void { + if (target === 0) { + return; + } + if (!reader.has(target, 2)) { + emit( + state, + "TIFF_INVALID_POINTER", + `TIFF ${path} pointer targets an invalid IFD offset ${String(target)}.`, + sourceOffset, + ); + return; + } + if (pending.length >= maxTargets) { + if (!state.traversalLimitReported) { + emit( + state, + "TIFF_TRAVERSAL_LIMIT_EXCEEDED", + `TIFF traversal exceeds maxMetadataEntries ${String(maxTargets)}.`, + sourceOffset, + ); + state.traversalLimitReported = true; + } + return; + } + pending.push({ offset: target, kind, path, depth }); +} + +export function parseTiff( + bytes: Uint8Array, + limits: TiffParseLimits, +): TiffParseResult { + const raw = new ByteReader(bytes); + if (!raw.has(0, 8)) { + return initialFailure( + "TIFF_TRUNCATED_HEADER", + "TIFF header requires at least eight bytes.", + 0, + ); + } + + const order = byteOrder(raw); + if (order === undefined) { + return initialFailure( + "TIFF_INVALID_BYTE_ORDER", + "TIFF byte order must be II or MM.", + 0, + ); + } + + const reader = new TiffReader(bytes, order); + if (reader.u16(2) !== 42) { + return { + byteOrder: order, + complete: false, + entries: [], + diagnostics: [ + { + severity: "error", + code: "TIFF_INVALID_MAGIC", + message: "TIFF magic value is not 42.", + offset: 2, + }, + ], + }; + } + + const firstIfdOffset = reader.u32(4); + if (firstIfdOffset === 0) { + return { + byteOrder: order, + complete: true, + entries: [], + diagnostics: [], + }; + } + if (!reader.has(firstIfdOffset, 2)) { + return { + 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, + }, + ], + }; + } + + const state: TiffState = { + entries: [], + diagnostics: [], + complete: true, + processedEntries: 0, + traversalLimitReported: false, + }; + const pending: PendingIfd[] = [ + { offset: firstIfdOffset, kind: "ifd0", path: "IFD0", depth: 1 }, + ]; + const visited = new Set(); + let queueIndex = 0; + + while (queueIndex < pending.length) { + const current = pending[queueIndex]; + queueIndex += 1; + if (current === undefined) { + break; + } + + if (visited.has(current.offset)) { + emit( + state, + "TIFF_CYCLIC_IFD", + `TIFF IFD offset ${String(current.offset)} was already visited.`, + current.offset, + ); + continue; + } + if (current.depth > limits.maxIfdDepth) { + emit( + state, + "TIFF_IFD_DEPTH_LIMIT_EXCEEDED", + `TIFF IFD depth exceeds maxIfdDepth ${String(limits.maxIfdDepth)}.`, + current.offset, + ); + continue; + } + visited.add(current.offset); + + const entryCount = reader.u16(current.offset); + if (entryCount > limits.maxIfdEntries) { + emit( + state, + "TIFF_IFD_ENTRY_LIMIT_EXCEEDED", + `TIFF IFD declares ${String(entryCount)} entries, exceeding maxIfdEntries ${String(limits.maxIfdEntries)}.`, + current.offset, + ); + continue; + } + + const entriesByteLength = checkedMultiply(entryCount, 12); + if (entriesByteLength === undefined) { + emit( + state, + "TIFF_TRUNCATED_IFD", + "TIFF IFD table size exceeds safe integer arithmetic.", + current.offset, + ); + continue; + } + const tableLength = 2 + entriesByteLength + 4; + if (!reader.has(current.offset, tableLength)) { + emit( + state, + "TIFF_TRUNCATED_IFD", + "TIFF IFD table or next-IFD pointer is truncated.", + current.offset, + ); + continue; + } + + const exifTargets: Array<{ target: number; sourceOffset: number }> = []; + const gpsTargets: Array<{ target: number; sourceOffset: number }> = []; + const entriesOffset = current.offset + 2; + + for (let index = 0; index < entryCount; index += 1) { + const entryOffset = entriesOffset + index * 12; + const tag = reader.u16(entryOffset); + const type = reader.u16(entryOffset + 2); + const count = reader.u32(entryOffset + 4); + const valueFieldOffset = entryOffset + 8; + const definition = tiffTagDefinition(current.kind, tag); + const typeSize = tiffFieldTypeSize(type); + + if (typeSize === undefined) { + emit( + state, + "TIFF_UNSUPPORTED_FIELD_TYPE", + `${definition.name} uses unsupported TIFF field type ${String(type)}.`, + entryOffset + 2, + ); + state.entries.push({ + tag, + type, + count, + name: definition.name, + namespace: definition.namespace, + category: definition.category, + privacy: definition.privacy, + path: `${current.path}/${definition.name}`, + entryOffset, + valueOffset: valueFieldOffset, + valueLength: 0, + }); + continue; + } + + const valueByteLength = checkedMultiply(count, typeSize); + if ( + valueByteLength === undefined || + valueByteLength > limits.maxStringBytes + ) { + emit( + state, + "TIFF_INVALID_VALUE_RANGE", + `${definition.name} value size is outside configured decoding limits.`, + entryOffset, + ); + state.entries.push({ + tag, + type, + count, + name: definition.name, + namespace: definition.namespace, + category: definition.category, + privacy: definition.privacy, + path: `${current.path}/${definition.name}`, + entryOffset, + valueOffset: valueFieldOffset, + valueLength: valueByteLength ?? 0, + }); + continue; + } + + const valueOffset = + valueByteLength <= 4 ? valueFieldOffset : reader.u32(valueFieldOffset); + if (!reader.has(valueOffset, valueByteLength)) { + emit( + state, + "TIFF_INVALID_VALUE_OFFSET", + `${definition.name} value range is outside the TIFF payload.`, + valueFieldOffset, + ); + state.entries.push({ + tag, + type, + count, + name: definition.name, + namespace: definition.namespace, + category: definition.category, + privacy: definition.privacy, + path: `${current.path}/${definition.name}`, + entryOffset, + valueOffset, + valueLength: valueByteLength, + }); + continue; + } + + if ( + tag === TIFF_TAG.EXIF_IFD_POINTER || + tag === TIFF_TAG.GPS_IFD_POINTER + ) { + if (type !== TIFF_FIELD_TYPE.LONG || count !== 1) { + emit( + state, + "TIFF_INVALID_POINTER", + `${definition.name} must be LONG with count 1.`, + entryOffset, + ); + continue; + } + const target = reader.u32(valueOffset); + const collection = + tag === TIFF_TAG.EXIF_IFD_POINTER ? exifTargets : gpsTargets; + collection.push({ target, sourceOffset: valueFieldOffset }); + continue; + } + + const isOpaque = + definition.name === "MakerNote" || definition.name.startsWith("Tag0x"); + const decoded = isOpaque + ? { diagnostics: [] as readonly Diagnostic[] } + : decodeTiffValue(reader, type, count, valueOffset, definition); + for (const item of decoded.diagnostics) { + state.diagnostics.push(item); + if (item.severity === "error") { + state.complete = false; + } + } + state.entries.push({ + tag, + type, + count, + name: definition.name, + namespace: definition.namespace, + category: definition.category, + privacy: definition.privacy, + ...(decoded.value === undefined ? {} : { value: decoded.value }), + path: `${current.path}/${definition.name}`, + entryOffset, + valueOffset, + valueLength: valueByteLength, + }); + } + + const nextPointerOffset = entriesOffset + entriesByteLength; + const nextTarget = reader.u32(nextPointerOffset); + for (const target of exifTargets) { + queueTarget( + reader, + state, + pending, + target.target, + "exif", + `${current.path}/ExifIFD`, + current.depth + 1, + target.sourceOffset, + limits.maxMetadataEntries, + ); + } + for (const target of gpsTargets) { + queueTarget( + reader, + state, + pending, + target.target, + "gps", + `${current.path}/GPSIFD`, + current.depth + 1, + target.sourceOffset, + limits.maxMetadataEntries, + ); + } + queueTarget( + reader, + state, + pending, + nextTarget, + "next", + current.kind === "ifd0" ? "IFD1" : `${current.path}/NextIFD`, + current.depth + 1, + nextPointerOffset, + limits.maxMetadataEntries, + ); + } + + return { + byteOrder: order, + complete: state.complete, + entries: state.entries, + diagnostics: state.diagnostics, + }; +} diff --git a/src/exif/types.ts b/src/exif/types.ts new file mode 100644 index 0000000..b6c71d1 --- /dev/null +++ b/src/exif/types.ts @@ -0,0 +1,32 @@ +import type { + MetadataCategory, + MetadataNamespace, + MetadataValue, + PrivacyRelevance, +} from "../core/types.js"; +import type { Diagnostic } from "../core/diagnostics.js"; +import type { TiffByteOrder } from "./tiff-reader.js"; + +export type TiffIfdKind = "ifd0" | "exif" | "gps" | "next"; + +export interface TiffDecodedEntry { + readonly tag: number; + readonly type: number; + readonly count: number; + readonly name: string; + readonly namespace: MetadataNamespace; + readonly category: MetadataCategory; + readonly privacy: PrivacyRelevance; + readonly value?: MetadataValue; + readonly path: string; + readonly entryOffset: number; + readonly valueOffset: number; + readonly valueLength: number; +} + +export interface TiffParseResult { + readonly byteOrder?: TiffByteOrder; + readonly complete: boolean; + readonly entries: readonly TiffDecodedEntry[]; + readonly diagnostics: readonly Diagnostic[]; +} diff --git a/src/index.ts b/src/index.ts index 279a695..ff7b780 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ export type { MetadataSource, MetadataValue, PrivacyRelevance, + RationalValue, VerificationPolicy, VerificationResult, } from "./core/types.js"; diff --git a/src/inspect.ts b/src/inspect.ts index 71f45f2..ff86b51 100644 --- a/src/inspect.ts +++ b/src/inspect.ts @@ -10,11 +10,17 @@ import type { InspectOptions, MetadataReport, } from "./core/types.js"; -import { jpegMetadataEntries } from "./jpeg/metadata.js"; +import { inspectJpegMetadata } from "./jpeg/metadata.js"; import { parseJpeg } from "./jpeg/parser.js"; function effectiveLimit( - name: "maxInputBytes" | "maxSegments", + name: + | "maxInputBytes" + | "maxSegments" + | "maxIfdEntries" + | "maxIfdDepth" + | "maxMetadataEntries" + | "maxStringBytes", configured: number | undefined, ): number { const value = configured ?? DEFAULT_PARSE_LIMITS[name]; @@ -46,14 +52,37 @@ export function inspectMetadata( options?.limits?.maxSegments, ); const jpeg = parseJpeg(reader, maxSegments); + const hasExif = jpeg.segments.some( + ({ metadataKind }) => metadataKind === "exif", + ); + const tiffLimits = { + maxIfdEntries: hasExif + ? effectiveLimit("maxIfdEntries", options?.limits?.maxIfdEntries) + : DEFAULT_PARSE_LIMITS.maxIfdEntries, + maxIfdDepth: hasExif + ? effectiveLimit("maxIfdDepth", options?.limits?.maxIfdDepth) + : DEFAULT_PARSE_LIMITS.maxIfdDepth, + maxMetadataEntries: hasExif + ? effectiveLimit( + "maxMetadataEntries", + options?.limits?.maxMetadataEntries, + ) + : DEFAULT_PARSE_LIMITS.maxMetadataEntries, + maxStringBytes: hasExif + ? effectiveLimit("maxStringBytes", options?.limits?.maxStringBytes) + : DEFAULT_PARSE_LIMITS.maxStringBytes, + }; + const metadata = inspectJpegMetadata(reader, jpeg, tiffLimits); return { format, size: bytes.byteLength, - inspectionStatus: jpeg.complete - ? "container-inspected" - : "container-partial", - entries: jpegMetadataEntries(jpeg), - diagnostics: jpeg.diagnostics, + inspectionStatus: !jpeg.complete + ? "container-partial" + : metadata.attemptedExifDecode + ? "metadata-partial" + : "container-inspected", + entries: metadata.entries, + diagnostics: [...jpeg.diagnostics, ...metadata.diagnostics], }; } diff --git a/src/jpeg/metadata.ts b/src/jpeg/metadata.ts index e432842..1fe77f5 100644 --- a/src/jpeg/metadata.ts +++ b/src/jpeg/metadata.ts @@ -1,7 +1,20 @@ +import { type ByteReader } from "../core/binary/index.js"; +import type { Diagnostic } from "../core/diagnostics.js"; import type { MetadataEntry } from "../core/types.js"; +import { + metadataEntriesFromTiff, + relocateTiffDiagnostics, +} from "../exif/metadata.js"; +import { parseTiff, type TiffParseLimits } from "../exif/tiff.js"; import { JPEG_MARKER } from "./markers.js"; import type { JpegParseResult, JpegSegment } from "./types.js"; +export interface JpegMetadataInspection { + readonly entries: readonly MetadataEntry[]; + readonly diagnostics: readonly Diagnostic[]; + readonly attemptedExifDecode: boolean; +} + function source(segment: JpegSegment): MetadataEntry["source"] { return { format: "jpeg", @@ -12,10 +25,14 @@ function source(segment: JpegSegment): MetadataEntry["source"] { }; } -export function jpegMetadataEntries( +export function inspectJpegMetadata( + reader: ByteReader, result: JpegParseResult, -): readonly MetadataEntry[] { + tiffLimits: TiffParseLimits, +): JpegMetadataInspection { const entries: MetadataEntry[] = []; + const diagnostics: Diagnostic[] = []; + let attemptedExifDecode = false; for (const segment of result.segments) { if (segment.marker === JPEG_MARKER.COM) { @@ -31,7 +48,7 @@ export function jpegMetadataEntries( } switch (segment.metadataKind) { - case "exif": + case "exif": { entries.push({ id: `jpeg-exif-${String(segment.offset)}`, namespace: "exif", @@ -40,7 +57,31 @@ export function jpegMetadataEntries( privacy: "potentially-sensitive", source: source(segment), }); + if ( + segment.payloadOffset === undefined || + segment.payloadLength === undefined + ) { + break; + } + attemptedExifDecode = true; + const tiffOffset = segment.payloadOffset + 6; + const tiffLength = segment.payloadLength - 6; + const tiff = parseTiff( + reader.slice(tiffOffset, tiffLength), + tiffLimits, + ); + entries.push( + ...metadataEntriesFromTiff(tiff, { + format: "jpeg", + baseOffset: tiffOffset, + idPrefix: `jpeg-tiff-${String(segment.offset)}`, + }), + ); + diagnostics.push( + ...relocateTiffDiagnostics(tiff.diagnostics, tiffOffset), + ); break; + } case "xmp": entries.push({ id: `jpeg-xmp-${String(segment.offset)}`, @@ -79,5 +120,5 @@ export function jpegMetadataEntries( } } - return entries; + return { entries, diagnostics, attemptedExifDecode }; } diff --git a/tests/helpers/tiff-builder.ts b/tests/helpers/tiff-builder.ts new file mode 100644 index 0000000..b882c42 --- /dev/null +++ b/tests/helpers/tiff-builder.ts @@ -0,0 +1,146 @@ +import { TIFF_FIELD_TYPE } from "../../src/exif/field-types.js"; + +export type TestByteOrder = "little" | "big"; + +export interface TestIfdEntry { + readonly tag: number; + readonly type: number; + readonly count: number; + readonly value?: number | readonly number[]; + readonly valueOffset?: number; +} + +export class TiffBuilder { + readonly #bytes: Uint8Array; + readonly #view: DataView; + readonly #little: boolean; + #used = 8; + + constructor( + readonly order: TestByteOrder = "little", + size = 1_024, + firstIfdOffset = 8, + ) { + this.#bytes = new Uint8Array(size); + this.#view = new DataView(this.#bytes.buffer); + this.#little = order === "little"; + this.#bytes.set(this.#little ? [0x49, 0x49] : [0x4d, 0x4d], 0); + this.u16(2, 42); + this.u32(4, firstIfdOffset); + } + + u16(offset: number, value: number): this { + this.#view.setUint16(offset, value, this.#little); + this.#used = Math.max(this.#used, offset + 2); + return this; + } + + u32(offset: number, value: number): this { + this.#view.setUint32(offset, value, this.#little); + this.#used = Math.max(this.#used, offset + 4); + return this; + } + + i32(offset: number, value: number): this { + this.#view.setInt32(offset, value, this.#little); + this.#used = Math.max(this.#used, offset + 4); + return this; + } + + bytes(offset: number, values: ArrayLike): this { + this.#bytes.set(Array.from(values), offset); + this.#used = Math.max(this.#used, offset + values.length); + return this; + } + + ascii(offset: number, value: string, nul = true): this { + const bytes = Array.from(value, (character) => character.charCodeAt(0)); + if (nul) { + bytes.push(0); + } + return this.bytes(offset, bytes); + } + + rational( + offset: number, + values: readonly (readonly [number, number])[], + signed = false, + ): this { + for (let index = 0; index < values.length; index += 1) { + const pair = values[index]; + if (pair === undefined) { + continue; + } + const pairOffset = offset + index * 8; + if (signed) { + this.i32(pairOffset, pair[0]); + this.i32(pairOffset + 4, pair[1]); + } else { + this.u32(pairOffset, pair[0]); + this.u32(pairOffset + 4, pair[1]); + } + } + return this; + } + + ifd( + offset: number, + entries: readonly TestIfdEntry[], + nextIfdOffset = 0, + ): this { + this.u16(offset, entries.length); + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry === undefined) { + continue; + } + const entryOffset = offset + 2 + index * 12; + this.u16(entryOffset, entry.tag); + this.u16(entryOffset + 2, entry.type); + this.u32(entryOffset + 4, entry.count); + if (entry.valueOffset !== undefined) { + this.u32(entryOffset + 8, entry.valueOffset); + } else { + this.#inline(entryOffset + 8, entry); + } + } + this.u32(offset + 2 + entries.length * 12, nextIfdOffset); + return this; + } + + finish(length = this.#used): Uint8Array { + return this.#bytes.slice(0, length); + } + + #writeInline(offset: number, type: number, values: readonly number[]): void { + if (type === TIFF_FIELD_TYPE.SHORT) { + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (value !== undefined) { + this.u16(offset + index * 2, value); + } + } + return; + } + if (type === TIFF_FIELD_TYPE.LONG || type === TIFF_FIELD_TYPE.SLONG) { + const value = values[0] ?? 0; + if (type === TIFF_FIELD_TYPE.SLONG) { + this.i32(offset, value); + } else { + this.u32(offset, value); + } + return; + } + this.bytes(offset, values); + } + + #inlineEntry(entry: TestIfdEntry): readonly number[] { + return typeof entry.value === "number" + ? [entry.value] + : (entry.value ?? []); + } + + #inline(offset: number, entry: TestIfdEntry): void { + this.#writeInline(offset, entry.type, this.#inlineEntry(entry)); + } +} diff --git a/tests/malformed/tiff-malformed.test.ts b/tests/malformed/tiff-malformed.test.ts new file mode 100644 index 0000000..9fc0a5a --- /dev/null +++ b/tests/malformed/tiff-malformed.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vitest"; + +import { TIFF_FIELD_TYPE } from "../../src/exif/field-types.js"; +import { parseTiff } from "../../src/exif/tiff.js"; +import { TIFF_TAG } from "../../src/exif/tags.js"; +import { TiffBuilder } from "../helpers/tiff-builder.js"; + +const LIMITS = { + maxIfdEntries: 4_096, + maxIfdDepth: 16, + maxMetadataEntries: 10_000, + maxStringBytes: 4 * 1024 * 1024, +}; + +describe("TIFF traversal limits and cycles", () => { + it("detects an IFD0 next-pointer cycle", () => { + const result = parseTiff(new TiffBuilder().ifd(8, [], 8).finish(), LIMITS); + + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_CYCLIC_IFD" }), + ); + }); + + it("detects an ExifIFD pointer cycle back to IFD0", () => { + const input = new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.EXIF_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 40, + }, + ]) + .ifd(40, [ + { + tag: TIFF_TAG.EXIF_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 8, + }, + ]) + .finish(); + + expect(parseTiff(input, LIMITS).diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_CYCLIC_IFD" }), + ); + }); + + it("enforces root-as-depth-one traversal semantics", () => { + const input = new TiffBuilder() + .ifd(8, [], 40) + .ifd(40, [], 80) + .ifd(80, []) + .finish(); + const result = parseTiff(input, { ...LIMITS, maxIfdDepth: 1 }); + + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_IFD_DEPTH_LIMIT_EXCEEDED" }), + ); + }); + + it("enforces maxIfdEntries before entry iteration", () => { + const input = new TiffBuilder() + .ifd(8, [ + { tag: 1, type: TIFF_FIELD_TYPE.BYTE, count: 1, value: 1 }, + { tag: 2, type: TIFF_FIELD_TYPE.BYTE, count: 1, value: 2 }, + ]) + .finish(); + const result = parseTiff(input, { ...LIMITS, maxIfdEntries: 1 }); + + expect(result.entries).toEqual([]); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_IFD_ENTRY_LIMIT_EXCEEDED" }), + ); + }); + + it("follows a bounded next IFD and gives it a deterministic path", () => { + const input = new TiffBuilder() + .ifd(8, [], 40) + .ifd(40, [ + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 6, + valueOffset: 100, + }, + ]) + .ascii(100, "Canon") + .finish(); + + expect(parseTiff(input, LIMITS).entries[0]).toMatchObject({ + name: "Make", + path: "IFD1/Make", + value: "Canon", + }); + }); +}); + +describe("malformed TIFF IFDs and values", () => { + it.each([ + new TiffBuilder().u16(8, 1).finish(10), + new TiffBuilder().u16(8, 2).bytes(10, new Uint8Array(12)).finish(22), + ])("rejects truncated IFD tables without native exceptions", (input) => { + expect(() => parseTiff(input, LIMITS)).not.toThrow(); + expect(parseTiff(input, LIMITS).diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_TRUNCATED_IFD" }), + ); + }); + + it("recovers from an invalid value offset and decodes a later entry", () => { + const input = new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 6, + valueOffset: 900, + }, + { + tag: TIFF_TAG.ORIENTATION, + type: TIFF_FIELD_TYPE.SHORT, + count: 1, + value: 6, + }, + ]) + .finish(); + const result = parseTiff(input, LIMITS); + + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_INVALID_VALUE_OFFSET" }), + ); + expect( + result.entries.find(({ name }) => name === "Orientation")?.value, + ).toBe(6); + }); + + it("recovers from an unsupported field type", () => { + const input = new TiffBuilder() + .ifd(8, [ + { tag: TIFF_TAG.MAKE, type: 99, count: 1, value: 0 }, + { + tag: TIFF_TAG.ORIENTATION, + type: TIFF_FIELD_TYPE.SHORT, + count: 1, + value: 3, + }, + ]) + .finish(); + const result = parseTiff(input, LIMITS); + + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_UNSUPPORTED_FIELD_TYPE" }), + ); + expect( + result.entries.find(({ name }) => name === "Orientation")?.value, + ).toBe(3); + }); + + it("rejects extreme counts before allocation or value access", () => { + const input = new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 0xffff_ffff, + valueOffset: 100, + }, + ]) + .finish(); + const result = parseTiff(input, LIMITS); + + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_INVALID_VALUE_RANGE" }), + ); + }); + + it("preserves duplicate tags in file order", () => { + const input = new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 6, + valueOffset: 100, + }, + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 6, + valueOffset: 110, + }, + ]) + .ascii(100, "Canon") + .ascii(110, "Nikon") + .finish(); + + expect(parseTiff(input, LIMITS).entries.map(({ value }) => value)).toEqual([ + "Canon", + "Nikon", + ]); + }); + + it("preserves unknown tag structure without inventing a value", () => { + const result = parseTiff( + new TiffBuilder() + .ifd(8, [ + { tag: 0xc4a5, type: TIFF_FIELD_TYPE.SHORT, count: 1, value: 7 }, + ]) + .finish(), + LIMITS, + ); + + expect(result.entries[0]).toMatchObject({ + tag: 0xc4a5, + type: TIFF_FIELD_TYPE.SHORT, + count: 1, + name: "Tag0xC4A5", + category: "unknown", + privacy: "unknown", + }); + expect(result.entries[0]).not.toHaveProperty("value"); + }); + + it("keeps MakerNote opaque and does not recurse", () => { + const input = new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.EXIF_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 40, + }, + ]) + .ifd(40, [ + { + tag: TIFF_TAG.MAKER_NOTE, + type: TIFF_FIELD_TYPE.UNDEFINED, + count: 8, + valueOffset: 100, + }, + ]) + .bytes(100, [0x49, 0x49, 42, 0, 8, 0, 0, 0]) + .finish(); + const result = parseTiff(input, LIMITS); + + expect(result.entries[0]).toMatchObject({ + name: "MakerNote", + valueLength: 8, + }); + expect(result.entries[0]).not.toHaveProperty("value"); + expect(result.diagnostics).toEqual([]); + }); +}); diff --git a/tests/malformed/tiff-traversal-limit.test.ts b/tests/malformed/tiff-traversal-limit.test.ts new file mode 100644 index 0000000..46d20ff --- /dev/null +++ b/tests/malformed/tiff-traversal-limit.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { TIFF_FIELD_TYPE } from "../../src/exif/field-types.js"; +import { parseTiff } from "../../src/exif/tiff.js"; +import { TIFF_TAG } from "../../src/exif/tags.js"; +import { TiffBuilder } from "../helpers/tiff-builder.js"; + +describe("TIFF total traversal limit", () => { + it("bounds total processed entries and queued IFD targets", () => { + const input = new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.ORIENTATION, + type: TIFF_FIELD_TYPE.SHORT, + count: 1, + value: 1, + }, + { + tag: TIFF_TAG.EXIF_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 80, + }, + ]) + .ifd(80, []) + .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" }), + ); + }); +}); diff --git a/tests/unit/jpeg-exif-integration.test.ts b/tests/unit/jpeg-exif-integration.test.ts new file mode 100644 index 0000000..35bac17 --- /dev/null +++ b/tests/unit/jpeg-exif-integration.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; + +import { TIFF_FIELD_TYPE } from "../../src/exif/field-types.js"; +import { TIFF_TAG } from "../../src/exif/tags.js"; +import { inspectMetadata } from "../../src/index.js"; +import { + concat, + EXIF, + JFIF, + jpeg, + MARKER, + segment, +} from "../helpers/jpeg-builder.js"; +import { TiffBuilder, type TestByteOrder } from "../helpers/tiff-builder.js"; + +function integratedTiff(order: TestByteOrder = "little"): Uint8Array { + return new TiffBuilder(order) + .ifd(8, [ + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 6, + valueOffset: 200, + }, + { + tag: TIFF_TAG.MODEL, + type: TIFF_FIELD_TYPE.ASCII, + count: 7, + valueOffset: 210, + }, + { + tag: TIFF_TAG.EXIF_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 80, + }, + { + tag: TIFF_TAG.GPS_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 120, + }, + ]) + .ifd(80, [ + { + tag: TIFF_TAG.DATE_TIME_ORIGINAL, + type: TIFF_FIELD_TYPE.ASCII, + count: 20, + valueOffset: 300, + }, + ]) + .ifd(120, [ + { tag: 0x0001, type: TIFF_FIELD_TYPE.ASCII, count: 2, value: [0x4e, 0] }, + { + tag: 0x0002, + type: TIFF_FIELD_TYPE.RATIONAL, + count: 3, + valueOffset: 240, + }, + { tag: 0x0003, type: TIFF_FIELD_TYPE.ASCII, count: 2, value: [0x45, 0] }, + { + tag: 0x0004, + type: TIFF_FIELD_TYPE.RATIONAL, + count: 3, + valueOffset: 264, + }, + ]) + .ascii(200, "Canon") + .ascii(210, "EOS R5") + .rational(240, [ + [37, 1], + [48, 1], + [30, 1], + ]) + .rational(264, [ + [122, 1], + [24, 1], + [15, 1], + ]) + .ascii(300, "2026:08:24 12:34:56") + .finish(); +} + +function exifJpeg(tiff = integratedTiff()): Uint8Array { + return jpeg( + segment(MARKER.APP0, JFIF), + segment(MARKER.APP1, concat(EXIF, tiff)), + ); +} + +describe("JPEG EXIF/TIFF integration", () => { + it("decodes common IFD0, ExifIFD, and GPS entries", () => { + const report = inspectMetadata(exifJpeg()); + const byName = new Map(report.entries.map((entry) => [entry.name, entry])); + + expect(report.inspectionStatus).toBe("metadata-partial"); + expect(byName.get("EXIF container")).toBeDefined(); + expect(byName.get("Make")).toMatchObject({ + value: "Canon", + category: "device", + privacy: "potentially-sensitive", + }); + expect(byName.get("Model")?.value).toBe("EOS R5"); + expect(byName.get("DateTimeOriginal")).toMatchObject({ + value: "2026:08:24 12:34:56", + category: "timestamp", + privacy: "potentially-sensitive", + }); + expect(byName.get("GPSLatitude")).toMatchObject({ + namespace: "gps", + category: "location", + privacy: "sensitive", + }); + expect(report.diagnostics).toEqual([]); + }); + + it("keeps all TIFF offsets relative to the TIFF header", () => { + const input = exifJpeg(); + const report = inspectMetadata(input); + const make = report.entries.find(({ name }) => name === "Make"); + + // SOI (2) + APP0 (9) + APP1 marker/length (4) + Exif signature (6). + const tiffBase = 21; + expect(make?.source).toMatchObject({ + offset: tiffBase + 10, + tiffPath: "IFD0/Make", + tiffTag: TIFF_TAG.MAKE, + tiffType: TIFF_FIELD_TYPE.ASCII, + tiffCount: 6, + }); + }); + + it("decodes TIFF correctly inside an exact JPEG Uint8Array subview", () => { + const embedded = exifJpeg(); + const backing = concat( + Uint8Array.of(0xaa, 0xbb, 0xcc), + embedded, + Uint8Array.of(0xff, 0xd8, 0x49, 0x49), + ); + const view = new Uint8Array( + backing.buffer, + backing.byteOffset + 3, + embedded.byteLength, + ); + const report = inspectMetadata(view); + + expect(report.entries.find(({ name }) => name === "Make")?.value).toBe( + "Canon", + ); + expect( + report.entries.find(({ name }) => name === "GPSLongitude"), + ).toBeDefined(); + expect(report.diagnostics).toEqual([]); + }); + + it.each(["little", "big"] as const)( + "is deterministic and preserves %s-endian EXIF input", + (order) => { + const input = exifJpeg(integratedTiff(order)); + const before = Uint8Array.from(input); + + expect(inspectMetadata(input)).toEqual(inspectMetadata(input)); + expect(input).toEqual(before); + }, + ); + + it("preserves cyclic malformed EXIF input", () => { + const tiff = new TiffBuilder().ifd(8, [], 8).finish(); + const input = exifJpeg(tiff); + const before = Uint8Array.from(input); + const report = inspectMetadata(input); + + expect(report.diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_CYCLIC_IFD" }), + ); + expect(input).toEqual(before); + }); + + it("honors custom TIFF depth and entry limits through inspectMetadata", () => { + const report = inspectMetadata(exifJpeg(), { + limits: { maxIfdDepth: 1, maxIfdEntries: 3 }, + }); + + expect(report.diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_IFD_ENTRY_LIMIT_EXCEEDED" }), + ); + }); +}); diff --git a/tests/unit/jpeg-inspection.test.ts b/tests/unit/jpeg-inspection.test.ts index 7dd4e01..4c89ea3 100644 --- a/tests/unit/jpeg-inspection.test.ts +++ b/tests/unit/jpeg-inspection.test.ts @@ -168,8 +168,8 @@ describe("JPEG inspection safety and status", () => { expect(report).toMatchObject({ size: embedded.byteLength, - inspectionStatus: "container-inspected", - diagnostics: [], + inspectionStatus: "metadata-partial", + diagnostics: [expect.objectContaining({ code: "TIFF_TRUNCATED_HEADER" })], }); expect(report.entries[0]?.source.offset).toBe(2); }); diff --git a/tests/unit/tiff-gps.test.ts b/tests/unit/tiff-gps.test.ts new file mode 100644 index 0000000..23eb152 --- /dev/null +++ b/tests/unit/tiff-gps.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; + +import { TIFF_FIELD_TYPE } from "../../src/exif/field-types.js"; +import { parseTiff } from "../../src/exif/tiff.js"; +import { TIFF_TAG } from "../../src/exif/tags.js"; +import { TiffBuilder, type TestByteOrder } from "../helpers/tiff-builder.js"; + +const LIMITS = { + maxIfdEntries: 4_096, + maxIfdDepth: 16, + maxMetadataEntries: 10_000, + maxStringBytes: 4 * 1024 * 1024, +}; + +function gpsTiff(order: TestByteOrder): Uint8Array { + return new TiffBuilder(order) + .ifd(8, [ + { + tag: TIFF_TAG.GPS_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 64, + }, + ]) + .ifd(64, [ + { + tag: 0x0000, + type: TIFF_FIELD_TYPE.BYTE, + count: 4, + value: [2, 3, 0, 0], + }, + { tag: 0x0001, type: TIFF_FIELD_TYPE.ASCII, count: 2, value: [0x4e, 0] }, + { + tag: 0x0002, + type: TIFF_FIELD_TYPE.RATIONAL, + count: 3, + valueOffset: 240, + }, + { tag: 0x0003, type: TIFF_FIELD_TYPE.ASCII, count: 2, value: [0x45, 0] }, + { + tag: 0x0004, + type: TIFF_FIELD_TYPE.RATIONAL, + count: 3, + valueOffset: 264, + }, + { tag: 0x0005, type: TIFF_FIELD_TYPE.BYTE, count: 1, value: 0 }, + { + tag: 0x0006, + type: TIFF_FIELD_TYPE.RATIONAL, + count: 1, + valueOffset: 288, + }, + { + tag: 0x0007, + type: TIFF_FIELD_TYPE.RATIONAL, + count: 3, + valueOffset: 296, + }, + { tag: 0x001d, type: TIFF_FIELD_TYPE.ASCII, count: 11, valueOffset: 320 }, + ]) + .rational(240, [ + [37, 1], + [48, 1], + [30, 1], + ]) + .rational(264, [ + [122, 1], + [24, 1], + [15, 1], + ]) + .rational(288, [[15, 2]]) + .rational(296, [ + [12, 1], + [34, 1], + [56, 1], + ]) + .ascii(320, "2026:08:24") + .finish(); +} + +describe("GPS IFD decoding", () => { + it.each(["little", "big"] as const)( + "decodes common GPS tags in %s endian order", + (order) => { + const result = parseTiff(gpsTiff(order), LIMITS); + const byName = new Map( + result.entries.map((entry) => [entry.name, entry]), + ); + + expect(byName.get("GPSVersionID")?.value).toBe("2.3.0.0"); + expect(byName.get("GPSLatitudeRef")?.value).toBe("N"); + expect(byName.get("GPSLatitude")?.value).toEqual([ + { numerator: 37, denominator: 1 }, + { numerator: 48, denominator: 1 }, + { numerator: 30, denominator: 1 }, + ]); + expect(byName.get("GPSLongitudeRef")?.value).toBe("E"); + expect(byName.get("GPSLongitude")?.value).toEqual([ + { numerator: 122, denominator: 1 }, + { numerator: 24, denominator: 1 }, + { numerator: 15, denominator: 1 }, + ]); + expect(byName.get("GPSAltitude")?.value).toEqual({ + numerator: 15, + denominator: 2, + }); + expect(byName.get("GPSTimeStamp")?.value).toHaveLength(3); + expect(byName.get("GPSDateStamp")?.value).toBe("2026:08:24"); + expect(byName.get("GPSLatitude")?.path).toBe("IFD0/GPSIFD/GPSLatitude"); + expect(byName.get("GPSLatitude")).toMatchObject({ + namespace: "gps", + category: "location", + privacy: "sensitive", + }); + expect(result.diagnostics).toEqual([]); + }, + ); + + it("does not derive floating-point coordinates", () => { + const latitude = parseTiff(gpsTiff("little"), LIMITS).entries.find( + ({ name }) => name === "GPSLatitude", + ); + + expect(latitude?.value).toEqual([ + { numerator: 37, denominator: 1 }, + { numerator: 48, denominator: 1 }, + { numerator: 30, denominator: 1 }, + ]); + }); +}); diff --git a/tests/unit/tiff-numeric.test.ts b/tests/unit/tiff-numeric.test.ts new file mode 100644 index 0000000..a28f0e4 --- /dev/null +++ b/tests/unit/tiff-numeric.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { TIFF_FIELD_TYPE } from "../../src/exif/field-types.js"; +import { parseTiff } from "../../src/exif/tiff.js"; +import { TIFF_TAG } from "../../src/exif/tags.js"; +import { TiffBuilder } from "../helpers/tiff-builder.js"; + +const LIMITS = { + maxIfdEntries: 4_096, + maxIfdDepth: 16, + maxMetadataEntries: 10_000, + maxStringBytes: 4 * 1024 * 1024, +}; + +describe("TIFF full-range numeric decoding", () => { + it("preserves full unsigned LONG and signed SLONG ranges", () => { + const input = new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.ORIENTATION, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 0xffff_ffff, + }, + { + tag: TIFF_TAG.IMAGE_DESCRIPTION, + type: TIFF_FIELD_TYPE.SLONG, + count: 1, + value: -0x8000_0000, + }, + ]) + .finish(); + const values = parseTiff(input, LIMITS).entries.map(({ value }) => value); + + expect(values).toEqual([4_294_967_295, -2_147_483_648]); + }); + + it("preserves exact signed rational components", () => { + const input = new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.EXIF_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 40, + }, + ]) + .ifd(40, [ + { + tag: TIFF_TAG.F_NUMBER, + type: TIFF_FIELD_TYPE.SRATIONAL, + count: 1, + valueOffset: 100, + }, + ]) + .rational(100, [[-3, 2]], true) + .finish(); + + expect(parseTiff(input, LIMITS).entries[0]?.value).toEqual({ + numerator: -3, + denominator: 2, + }); + }); +}); + +describe("TIFF pointer validation", () => { + it("rejects pointer tags with the wrong type", () => { + const input = new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.EXIF_IFD_POINTER, + type: TIFF_FIELD_TYPE.SHORT, + count: 1, + value: 40, + }, + ]) + .finish(); + + expect(parseTiff(input, LIMITS).diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_INVALID_POINTER" }), + ); + }); + + it("rejects pointer targets outside the TIFF view", () => { + const input = new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.GPS_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 900, + }, + ]) + .finish(); + + expect(parseTiff(input, LIMITS).diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_INVALID_POINTER" }), + ); + }); +}); diff --git a/tests/unit/tiff.test.ts b/tests/unit/tiff.test.ts new file mode 100644 index 0000000..1450e3c --- /dev/null +++ b/tests/unit/tiff.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; + +import { TIFF_FIELD_TYPE } from "../../src/exif/field-types.js"; +import { parseTiff } from "../../src/exif/tiff.js"; +import { TIFF_TAG } from "../../src/exif/tags.js"; +import { TiffBuilder } from "../helpers/tiff-builder.js"; + +const LIMITS = { + maxIfdEntries: 4_096, + maxIfdDepth: 16, + maxMetadataEntries: 10_000, + maxStringBytes: 4 * 1024 * 1024, +}; + +describe("TIFF header and empty IFD", () => { + it.each(["little", "big"] as const)( + "parses an empty %s-endian IFD", + (order) => { + const result = parseTiff( + new TiffBuilder(order).ifd(8, []).finish(), + LIMITS, + ); + + expect(result).toEqual({ + byteOrder: order, + complete: true, + entries: [], + diagnostics: [], + }); + }, + ); + + it("accepts a zero first-IFD offset as no IFD", () => { + const result = parseTiff(new TiffBuilder("little", 32, 0).finish(), LIMITS); + + expect(result).toMatchObject({ + complete: true, + entries: [], + diagnostics: [], + }); + }); + + it.each([ + [new Uint8Array(), "TIFF_TRUNCATED_HEADER"], + [Uint8Array.of(0x49, 0x49, 0x2a), "TIFF_TRUNCATED_HEADER"], + ] as const)("rejects a truncated TIFF header", (input, code) => { + expect(parseTiff(input, LIMITS).diagnostics).toContainEqual( + expect.objectContaining({ code }), + ); + }); + + it("rejects unsupported byte order", () => { + const input = new TiffBuilder().ifd(8, []).finish(); + input[0] = 0x58; + + expect(parseTiff(input, LIMITS).diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_INVALID_BYTE_ORDER" }), + ); + }); + + it("rejects invalid magic", () => { + const input = new TiffBuilder().ifd(8, []).finish(); + input[2] = 0; + input[3] = 0; + + expect(parseTiff(input, LIMITS).diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_INVALID_MAGIC" }), + ); + }); + + it("rejects a first IFD offset outside the TIFF view", () => { + const input = new TiffBuilder("little", 32, 100).finish(); + + expect(parseTiff(input, LIMITS).diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_INVALID_FIRST_IFD_OFFSET" }), + ); + }); +}); + +describe("TIFF inline and offset values", () => { + it.each(["little", "big"] as const)( + "decodes an inline SHORT in %s endian order", + (order) => { + const input = new TiffBuilder(order) + .ifd(8, [ + { + tag: TIFF_TAG.ORIENTATION, + type: TIFF_FIELD_TYPE.SHORT, + count: 1, + value: 6, + }, + ]) + .finish(); + const result = parseTiff(input, LIMITS); + + expect(result.entries[0]).toMatchObject({ + name: "Orientation", + value: 6, + path: "IFD0/Orientation", + }); + }, + ); + + it.each(["little", "big"] as const)( + "decodes two inline SHORT values in %s endian order", + (order) => { + const input = new TiffBuilder(order) + .ifd(8, [ + { + tag: TIFF_TAG.ORIENTATION, + type: TIFF_FIELD_TYPE.SHORT, + count: 2, + value: [1, 8], + }, + ]) + .finish(); + + expect(parseTiff(input, LIMITS).entries[0]?.value).toEqual([1, 8]); + }, + ); + + it("decodes an inline BYTE", () => { + const input = new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.IMAGE_DESCRIPTION, + type: TIFF_FIELD_TYPE.BYTE, + count: 1, + value: 7, + }, + ]) + .finish(); + + expect(parseTiff(input, LIMITS).entries[0]?.value).toBe(7); + }); + + it.each(["little", "big"] as const)( + "decodes bounded offset ASCII and trims at NUL in %s endian order", + (order) => { + const input = new TiffBuilder(order) + .ifd(8, [ + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 8, + valueOffset: 100, + }, + ]) + .ascii(100, "Canon", true) + .bytes(106, [0x58, 0x59]) + .finish(); + const entry = parseTiff(input, LIMITS).entries[0]; + + expect(entry).toMatchObject({ + name: "Make", + value: "Canon", + path: "IFD0/Make", + valueOffset: 100, + }); + }, + ); +}); + +describe("ExifIFD and rational decoding", () => { + function exposure(denominator: number): Uint8Array { + return new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.EXIF_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 40, + }, + ]) + .ifd(40, [ + { + tag: TIFF_TAG.EXPOSURE_TIME, + type: TIFF_FIELD_TYPE.RATIONAL, + count: 1, + valueOffset: 100, + }, + ]) + .rational(100, [[1, denominator]]) + .finish(); + } + + it("traverses ExifIFD and preserves exact rational values", () => { + const result = parseTiff(exposure(125), LIMITS); + + expect(result.entries).toEqual([ + expect.objectContaining({ + name: "ExposureTime", + path: "IFD0/ExifIFD/ExposureTime", + value: { numerator: 1, denominator: 125 }, + }), + ]); + }); + + it("preserves zero-denominator rationals with a diagnostic", () => { + const result = parseTiff(exposure(0), LIMITS); + + expect(result.entries[0]?.value).toEqual({ numerator: 1, denominator: 0 }); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "TIFF_INVALID_RATIONAL" }), + ); + }); +});