diff --git a/CHANGELOG.md b/CHANGELOG.md index ae02bf6..a079d43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,17 @@ All notable changes will be documented here. The project intends to follow seman ## Unreleased -- Repository foundation. -- TypeScript library scaffold. +### Added + +- Bounded unsigned byte and endian-aware integer reads. +- Checked binary range validation with typed library errors. +- No-copy normalization for `Uint8Array` and `ArrayBuffer` inputs. +- Signature-based JPEG, PNG, and WebP format detection. +- Explicit format-only inspection reports with input-size enforcement. +- Binary boundary, sliced-view, format, and malformed-input tests. + +### Foundation + +- Repository and TypeScript library scaffold. - Public API skeleton. - Security and architecture documentation. diff --git a/README.md b/README.md index 04cf8cf..4f30eb8 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,13 @@ `secure-metadata` is a pre-release TypeScript library for deterministic, security-conscious inspection, cleaning, and verification of metadata in binary image formats. It is being built for privacy-first, entirely local use with no analytics, telemetry, network access, runtime CDN, or pixel decoding. -> **Development status:** Sprint 0 establishes the repository and API foundation. JPEG, PNG, WebP, EXIF, TIFF, XMP, IPTC, and ICC parsing and all cleaning behavior are not implemented yet. +## Development status + +The current implementation provides a bounded binary input core and signature-based JPEG, PNG, and WebP format detection. `inspectMetadata` returns an explicit `format-only` report; metadata decoding, cleaning, and verification are not implemented. ## Format status -JPEG, WebP, and PNG are planned, in that order. See [format support](docs/format-support.md) for the intended progression. +JPEG is detected from `FF D8`, PNG from its complete eight-byte signature, and WebP from `RIFF` plus `WEBP` identifiers. Detection identifies a likely container only. It does not yet validate JPEG segments, PNG chunks, WebP RIFF sizes or chunks, or any metadata. See [format support](docs/format-support.md). ## Installation @@ -14,7 +16,7 @@ The package is not published. Installation instructions will be added for the fi ## Public API -The future top-level API is deliberately small: +The top-level API is deliberately small: ```ts import { @@ -24,15 +26,17 @@ import { } from "secure-metadata"; ``` -All three functions currently throw a typed `NotImplementedError`. Public binary inputs are `Uint8Array | ArrayBuffer`; Node.js `Buffer` values work structurally as `Uint8Array` but are not part of the public contract. +`inspectMetadata` currently normalizes `Uint8Array | ArrayBuffer` input without copying it, enforces `maxInputBytes`, detects the container signature, and returns an empty-entry report marked `inspectionStatus: "format-only"`. That status means metadata has not been decoded; it does not claim metadata is absent. + +`cleanMetadata` and `verifyMetadata` still throw a typed `NotImplementedError`. Node.js `Buffer` values work structurally as `Uint8Array` but are not part of the public contract. ## Security philosophy -Every byte is untrusted. Future binary reads will use bounded primitives, traversal will have hard limits, and malformed input must not crash a parser. Cleaning will preserve unknown structures and ICC/color information by default, and cleaner output will be independently inspectable. See the [security model](docs/security-model.md), [architecture](docs/architecture.md), and [cleaning policy](docs/cleaning-policy.md). +Every byte is untrusted. Binary reads use centrally checked ranges, parser input views retain their original boundaries, and malformed or tiny inputs are ordinary data. Cleaning will preserve unknown structures and ICC/color information by default, and cleaner output will be independently inspectable. 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 metadata 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 decoded metadata is never proof that an image contains no private information. ## Secure Tools ecosystem diff --git a/docs/architecture.md b/docs/architecture.md index 133c18a..de7140c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,34 +3,41 @@ `secure-metadata` is organized as a side-effect-free binary library. Its planned data flow is: ```text -Input bytes +Input bytes implemented ↓ -Format detection +Safe binary view / bounded reads implemented ↓ -Container parser +Format detection implemented ↓ -Metadata decoder +Container parser planned ↓ -Metadata normalization/classification +Metadata decoder planned ↓ -Inspector / policy engine +Metadata normalization/classification planned ↓ -Cleaner +Inspector / policy engine planned ↓ -Output bytes +Cleaner planned ↓ -Re-inspection / verification +Output bytes planned + ↓ +Re-inspection / verification planned ``` +## Binary core + +All future format parsers build on `src/core/binary`. 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 in both endian orders, subarray views, and allocation-free signature matching. + +`ByteReader` is an internal implementation primitive, not part of the stable package exports. Its `DataView` is constrained to the input view's `byteOffset` and `byteLength`, and accesses occur only after project-owned bounds validation. + ## Layer boundaries -- **Container parsing** identifies and bounds JPEG segments, PNG chunks, or WebP RIFF chunks without decoding pixels. -- **Metadata decoding** interprets known metadata payloads. EXIF/TIFF will be one shared decoder reused by JPEG, PNG, and WebP. +- **Format detection** identifies PNG, JPEG, and WebP signatures in that explicit order. It does not imply structural validity. +- **Container parsing** will identify and bound JPEG segments, PNG chunks, or WebP RIFF chunks without decoding pixels. +- **Metadata decoding** will interpret known metadata payloads. EXIF/TIFF will be one shared decoder reused by JPEG, PNG, and WebP. - **Normalization and classification** maps format-specific fields to stable namespaces and semantic categories. - **Privacy relevance** is an independent description of whether an entry can concern privacy. It is not a contextual risk score. - **Cleaning policy** decides which proven structures to remove while preserving required, color, rendering, image-payload, and unknown data by default. - **Verification** independently re-inspects cleaner output and compares it with an explicit expectation. -Format packages will depend on bounded primitives in `src/core/binary`. Decoders and policy code must not perform ad hoc binary reads. Public APIs accept bytes and return values without filesystem, network, browser-global, or other environmental side effects. - -Sprint 0 establishes interfaces and boundaries only. Format detection, binary primitives, parsers, decoders, cleaning, and verification logic are intentionally not implemented. +`inspectMetadata` currently stops after detection and returns `inspectionStatus: "format-only"`. Empty entries therefore mean “not decoded,” not “confirmed absent.” Public APIs remain free of filesystem, network, browser-global, and other environmental side effects. diff --git a/docs/format-support.md b/docs/format-support.md index 94235cb..31a0f7d 100644 --- a/docs/format-support.md +++ b/docs/format-support.md @@ -1,21 +1,29 @@ # Format Support -No image format is parsed in Sprint 0. Planned implementation priority is: +Sprint 1 implements deterministic container signature detection only. Planned parser implementation priority remains: 1. JPEG 2. WebP 3. PNG +Detection order is explicitly PNG, JPEG, WebP, then unknown. The supported signatures are distinct, so the ordering does not create heuristic ambiguity. + ## JPEG -JPEG is first because its segment model provides the initial foundation for marker parsing and bounded traversal. Future work covers APP segments, EXIF, shared TIFF IFD and GPS decoding, XMP, IPTC, comments, and the distinction between privacy metadata and ICC profiles. +**Implemented:** detection when the first two bytes are `FF D8`. + +This does not require an EOI marker and does not validate or traverse markers or segments. APP segments, EXIF, shared TIFF IFD and GPS decoding, XMP, IPTC, comments, and ICC distinctions remain future work. Malformed trailing bytes do not change a matching Sprint 1 signature classification. ## WebP -WebP support will add bounded RIFF chunk parsing, EXIF, XMP, ICCP, image and animation payload distinctions, and consistent handling of VP8X feature flags when metadata chunks change. +**Implemented:** detection of `RIFF` at offset 0 and `WEBP` at offset 8, requiring at least 12 bytes. + +The four RIFF size bytes are deliberately ignored. RIFF size validation, chunk traversal, EXIF, XMP, ICCP, image and animation payload distinctions, and VP8X consistency handling remain future work. ## PNG -PNG support will add chunk parsing, textual metadata, eXIf, XMP, ICC and color chunks, privacy-relevant ancillary chunks, and detection of compressed metadata. Compressed metadata decompression is not part of Sprint 0. +**Implemented:** detection of the complete eight-byte PNG signature `89 50 4E 47 0D 0A 1A 0A`. + +A truncated or corrupted signature is unknown. IHDR and chunk structure are not inspected. Textual metadata, eXIf, XMP, ICC and color chunks, privacy-relevant ancillary chunks, CRC checking, and compressed metadata remain future work. -Format claims will track implemented and tested behavior; planned items are not advertised as supported. +`inspectionStatus: "format-only"` records this boundary in API results. A detected signature is not a claim that a file is structurally valid or that its metadata has been inspected. diff --git a/docs/security-model.md b/docs/security-model.md index b24ded5..d54bc78 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -5,7 +5,7 @@ Binary metadata parsing processes attacker-controlled structure, sizes, offsets, ## Invariants 1. All binary input is untrusted. -2. All binary reads must eventually go through bounded primitives. +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. @@ -19,12 +19,18 @@ Binary metadata parsing processes attacker-controlled structure, sizes, offsets, 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 are checked with `length <= inputLength - offset`, avoiding overflow-prone addition during validation. Invalid offsets, invalid lengths, and out-of-bounds ranges throw typed library errors before `DataView` access. Signature mismatches and insufficient signature bytes return `false` rather than throwing. + +Normalization does not copy whole inputs. A supplied `Uint8Array` retains its exact offset and length, so bytes elsewhere in its backing buffer are inaccessible to the reader. An `ArrayBuffer` receives a no-copy byte view. The inspector never writes through either representation. + ## Hard limits -Default limits bound input size, container counts, metadata entries, TIFF depth and entry counts, strings, future decompressed data, and diagnostics. The defaults are exported as `DEFAULT_PARSE_LIMITS`. They are conservative operational safeguards, not permanent API guarantees, and may evolve during `0.x` development. +`inspectMetadata` enforces the effective `maxInputBytes` before detection and allocation-intensive parsing. Other default limits remain reserved for the parsers that will use them. The defaults are exported as `DEFAULT_PARSE_LIMITS`; they are conservative safeguards, not permanent API guarantees, and may evolve during `0.x` development. Limits complement bounds checks; they do not replace them. Future parsers must fail safely or produce bounded diagnostics rather than crash on malformed input. ## 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 starts with 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. Development tools are not part of the shipped runtime. diff --git a/src/core/binary/bounds.ts b/src/core/binary/bounds.ts new file mode 100644 index 0000000..8339d48 --- /dev/null +++ b/src/core/binary/bounds.ts @@ -0,0 +1,34 @@ +import { BinaryBoundsError } from "../errors.js"; + +export function hasValidRange( + inputLength: number, + offset: number, + length: number, +): boolean { + return ( + Number.isSafeInteger(offset) && + Number.isSafeInteger(length) && + offset >= 0 && + length >= 0 && + offset <= inputLength && + length <= inputLength - offset + ); +} + +export function assertValidRange( + inputLength: number, + offset: number, + length: number, +): void { + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new BinaryBoundsError("INVALID_OFFSET", inputLength, offset, length); + } + + if (!Number.isSafeInteger(length) || length < 0) { + throw new BinaryBoundsError("INVALID_LENGTH", inputLength, offset, length); + } + + if (offset > inputLength || length > inputLength - offset) { + throw new BinaryBoundsError("OUT_OF_BOUNDS", inputLength, offset, length); + } +} diff --git a/src/core/binary/byte-reader.ts b/src/core/binary/byte-reader.ts new file mode 100644 index 0000000..bb560f2 --- /dev/null +++ b/src/core/binary/byte-reader.ts @@ -0,0 +1,64 @@ +import { assertValidRange, hasValidRange } from "./bounds.js"; + +/** Read-only access to a caller-supplied byte view through checked ranges. */ +export class ByteReader { + readonly length: number; + + readonly #bytes: Uint8Array; + readonly #view: DataView; + + constructor(bytes: Uint8Array) { + this.#bytes = bytes; + this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + this.length = bytes.byteLength; + } + + has(offset: number, length = 1): boolean { + return hasValidRange(this.length, offset, length); + } + + u8(offset: number): number { + assertValidRange(this.length, offset, 1); + return this.#view.getUint8(offset); + } + + u16LE(offset: number): number { + assertValidRange(this.length, offset, 2); + return this.#view.getUint16(offset, true); + } + + u16BE(offset: number): number { + assertValidRange(this.length, offset, 2); + return this.#view.getUint16(offset, false); + } + + u32LE(offset: number): number { + assertValidRange(this.length, offset, 4); + return this.#view.getUint32(offset, true); + } + + u32BE(offset: number): number { + assertValidRange(this.length, offset, 4); + return this.#view.getUint32(offset, false); + } + + /** Returns a bounded view, not a copy, after validating the complete range. */ + slice(offset: number, length: number): Uint8Array { + assertValidRange(this.length, offset, length); + return this.#bytes.subarray(offset, offset + length); + } + + matches(offset: number, signature: readonly number[]): boolean { + if (!this.has(offset, signature.length)) { + return false; + } + + for (let index = 0; index < signature.length; index += 1) { + if (this.#bytes[offset + index] !== signature[index]) { + return false; + } + } + + return true; + } +} diff --git a/src/core/binary/index.ts b/src/core/binary/index.ts new file mode 100644 index 0000000..785232b --- /dev/null +++ b/src/core/binary/index.ts @@ -0,0 +1,3 @@ +export { ByteReader } from "./byte-reader.js"; +export { assertValidRange, hasValidRange } from "./bounds.js"; +export { toUint8Array } from "./input.js"; diff --git a/src/core/binary/input.ts b/src/core/binary/input.ts new file mode 100644 index 0000000..97f3e45 --- /dev/null +++ b/src/core/binary/input.ts @@ -0,0 +1,6 @@ +import type { BinaryInput } from "../types.js"; + +/** Normalizes supported input without copying or widening a Uint8Array view. */ +export function toUint8Array(input: BinaryInput): Uint8Array { + return input instanceof Uint8Array ? input : new Uint8Array(input); +} diff --git a/src/core/detect-format.ts b/src/core/detect-format.ts new file mode 100644 index 0000000..1c1f1d8 --- /dev/null +++ b/src/core/detect-format.ts @@ -0,0 +1,24 @@ +import { type ByteReader } from "./binary/index.js"; +import type { ImageFormat } from "./types.js"; + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const JPEG_SIGNATURE = [0xff, 0xd8]; +const RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46]; +const WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50]; + +/** Identifies a container signature without validating its internal structure. */ +export function detectFormat(reader: ByteReader): ImageFormat { + if (reader.matches(0, PNG_SIGNATURE)) { + return "png"; + } + + if (reader.matches(0, JPEG_SIGNATURE)) { + return "jpeg"; + } + + if (reader.matches(0, RIFF_SIGNATURE) && reader.matches(8, WEBP_SIGNATURE)) { + return "webp"; + } + + return "unknown"; +} diff --git a/src/core/errors.ts b/src/core/errors.ts index 0a48acd..e7c0d07 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,4 +1,10 @@ -export type SecureMetadataErrorCode = "NOT_IMPLEMENTED"; +export type SecureMetadataErrorCode = + | "NOT_IMPLEMENTED" + | "INVALID_OFFSET" + | "INVALID_LENGTH" + | "OUT_OF_BOUNDS" + | "INVALID_LIMIT" + | "INPUT_LIMIT_EXCEEDED"; export class SecureMetadataError extends Error { override readonly name: string = "SecureMetadataError"; @@ -22,3 +28,50 @@ export class NotImplementedError extends SecureMetadataError { ); } } + +export type BinaryBoundsErrorCode = + "INVALID_OFFSET" | "INVALID_LENGTH" | "OUT_OF_BOUNDS"; + +export class BinaryBoundsError extends SecureMetadataError { + override readonly name: string = "BinaryBoundsError"; + + constructor( + code: BinaryBoundsErrorCode, + readonly inputLength: number, + readonly offset: number, + readonly requestedLength: number, + ) { + super( + `Invalid binary range: offset ${String(offset)}, length ${String(requestedLength)}, input length ${String(inputLength)}.`, + code, + ); + } +} + +export class InvalidParseLimitError extends SecureMetadataError { + override readonly name: string = "InvalidParseLimitError"; + + constructor( + readonly limitName: string, + readonly value: number, + ) { + super( + `Parse limit ${limitName} must be a non-negative safe integer; received ${String(value)}.`, + "INVALID_LIMIT", + ); + } +} + +export class InputLimitExceededError extends SecureMetadataError { + override readonly name: string = "InputLimitExceededError"; + + constructor( + readonly inputLength: number, + readonly maximumLength: number, + ) { + super( + `Input length ${String(inputLength)} exceeds maxInputBytes ${String(maximumLength)}.`, + "INPUT_LIMIT_EXCEEDED", + ); + } +} diff --git a/src/core/types.ts b/src/core/types.ts index 0c034f4..b1b7858 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -62,8 +62,12 @@ export interface InspectOptions { readonly limits?: Partial; } +export type InspectionStatus = "format-only" | "metadata-inspected"; + export interface MetadataReport { readonly format: ImageFormat; + readonly size: number; + readonly inspectionStatus: InspectionStatus; readonly entries: readonly MetadataEntry[]; readonly diagnostics: readonly Diagnostic[]; } diff --git a/src/index.ts b/src/index.ts index 187f778..279a695 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,13 @@ export { inspectMetadata } from "./inspect.js"; export { cleanMetadata } from "./policy/clean.js"; export { verifyMetadata } from "./verify/verify.js"; -export { NotImplementedError, SecureMetadataError } from "./core/errors.js"; +export { + BinaryBoundsError, + InputLimitExceededError, + InvalidParseLimitError, + NotImplementedError, + SecureMetadataError, +} from "./core/errors.js"; export { DEFAULT_PARSE_LIMITS } from "./core/limits.js"; export type { @@ -10,13 +16,17 @@ export type { DiagnosticCode, DiagnosticSeverity, } from "./core/diagnostics.js"; -export type { SecureMetadataErrorCode } from "./core/errors.js"; +export type { + BinaryBoundsErrorCode, + SecureMetadataErrorCode, +} from "./core/errors.js"; export type { ParseLimits } from "./core/limits.js"; export type { BinaryInput, CleaningPolicy, CleanResult, ImageFormat, + InspectionStatus, InspectOptions, MetadataCategory, MetadataContainer, diff --git a/src/inspect.ts b/src/inspect.ts index 1dc4f35..b5d5c34 100644 --- a/src/inspect.ts +++ b/src/inspect.ts @@ -1,4 +1,10 @@ -import { NotImplementedError } from "./core/errors.js"; +import { ByteReader, toUint8Array } from "./core/binary/index.js"; +import { detectFormat } from "./core/detect-format.js"; +import { + InputLimitExceededError, + InvalidParseLimitError, +} from "./core/errors.js"; +import { DEFAULT_PARSE_LIMITS } from "./core/limits.js"; import type { BinaryInput, InspectOptions, @@ -9,7 +15,23 @@ export function inspectMetadata( input: BinaryInput, options?: InspectOptions, ): MetadataReport { - void input; - void options; - throw new NotImplementedError("inspectMetadata"); + const bytes = toUint8Array(input); + const maxInputBytes = + options?.limits?.maxInputBytes ?? DEFAULT_PARSE_LIMITS.maxInputBytes; + + if (!Number.isSafeInteger(maxInputBytes) || maxInputBytes < 0) { + throw new InvalidParseLimitError("maxInputBytes", maxInputBytes); + } + + if (bytes.byteLength > maxInputBytes) { + throw new InputLimitExceededError(bytes.byteLength, maxInputBytes); + } + + return { + format: detectFormat(new ByteReader(bytes)), + size: bytes.byteLength, + inspectionStatus: "format-only", + entries: [], + diagnostics: [], + }; } diff --git a/tests/malformed/deterministic-inputs.test.ts b/tests/malformed/deterministic-inputs.test.ts new file mode 100644 index 0000000..049f9c2 --- /dev/null +++ b/tests/malformed/deterministic-inputs.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { inspectMetadata } from "../../src/index.js"; + +const MALFORMED_INPUTS = [ + Uint8Array.of(), + Uint8Array.of(0x00), + Uint8Array.of(0xff), + Uint8Array.of(0xff, 0xd8), + Uint8Array.of(0x01, 0x02, 0x03), + new Uint8Array(16), + new Uint8Array(16).fill(0xff), + Uint8Array.of(0x52, 0x49, 0x46, 0x46, 0x00), + Uint8Array.of(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57), + Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a), + Uint8Array.of(0x89, 0x50, 0x00, 0x47), +] as const; + +describe("deterministic malformed inputs", () => { + it.each(MALFORMED_INPUTS)("is ordinary inspection input: %j", (input) => { + expect(() => inspectMetadata(input)).not.toThrow(); + expect(inspectMetadata(input)).toEqual(inspectMetadata(input)); + }); +}); diff --git a/tests/unit/byte-reader.test.ts b/tests/unit/byte-reader.test.ts new file mode 100644 index 0000000..73fd597 --- /dev/null +++ b/tests/unit/byte-reader.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { ByteReader } from "../../src/core/binary/byte-reader.js"; +import { BinaryBoundsError } from "../../src/core/errors.js"; + +describe("ByteReader valid reads", () => { + const bytes = Uint8Array.from([0x01, 0x02, 0x03, 0x80, 0xff]); + const reader = new ByteReader(bytes); + + it("reads the first and last byte", () => { + expect(reader.u8(0)).toBe(0x01); + expect(reader.u8(reader.length - 1)).toBe(0xff); + }); + + it("reads 16-bit values in both endian orders", () => { + expect(reader.u16LE(0)).toBe(0x0201); + expect(reader.u16BE(0)).toBe(0x0102); + }); + + it("reads 32-bit values in both endian orders", () => { + expect(reader.u32LE(0)).toBe(0x80030201); + expect(reader.u32BE(0)).toBe(0x01020380); + }); + + it("returns the full unsigned 32-bit range", () => { + const unsigned = new ByteReader(Uint8Array.of(0xff, 0xff, 0xff, 0xff)); + + expect(unsigned.u32LE(0)).toBe(4_294_967_295); + expect(unsigned.u32BE(0)).toBe(4_294_967_295); + }); + + it("accepts zero-length and exact-to-EOF ranges", () => { + expect(reader.has(reader.length, 0)).toBe(true); + expect(reader.slice(reader.length, 0)).toHaveLength(0); + expect(reader.slice(1, reader.length - 1)).toEqual(bytes.subarray(1)); + }); + + it("returns a bounded subarray view", () => { + const result = reader.slice(1, 2); + + expect(result).toEqual(Uint8Array.of(0x02, 0x03)); + expect(result.buffer).toBe(bytes.buffer); + expect(result.byteOffset).toBe(bytes.byteOffset + 1); + }); + + it("matches signatures without allocating slices", () => { + expect(reader.matches(1, [0x02, 0x03, 0x80])).toBe(true); + expect(reader.matches(1, [0x02, 0x04])).toBe(false); + expect(reader.matches(4, [0xff, 0x00])).toBe(false); + }); +}); + +describe("ByteReader invalid reads", () => { + const reader = new ByteReader(Uint8Array.of(0x01, 0x02, 0x03, 0x04)); + + it.each([ + ["one byte past EOF", () => reader.u8(4), "OUT_OF_BOUNDS"], + ["multi-byte read crossing EOF", () => reader.u16BE(3), "OUT_OF_BOUNDS"], + ["negative offset", () => reader.u8(-1), "INVALID_OFFSET"], + ["fractional offset", () => reader.u8(0.5), "INVALID_OFFSET"], + ["NaN offset", () => reader.u8(Number.NaN), "INVALID_OFFSET"], + [ + "infinite offset", + () => reader.u8(Number.POSITIVE_INFINITY), + "INVALID_OFFSET", + ], + [ + "unsafe offset", + () => reader.u8(Number.MAX_SAFE_INTEGER + 1), + "INVALID_OFFSET", + ], + ["negative length", () => reader.slice(0, -1), "INVALID_LENGTH"], + ["fractional length", () => reader.slice(0, 1.5), "INVALID_LENGTH"], + [ + "unsafe length", + () => reader.slice(0, Number.MAX_SAFE_INTEGER + 1), + "INVALID_LENGTH", + ], + [ + "huge in-range integer length", + () => reader.slice(0, Number.MAX_SAFE_INTEGER), + "OUT_OF_BOUNDS", + ], + ] as const)("rejects %s predictably", (_, operation, code) => { + expect(operation).toThrowError(BinaryBoundsError); + expect(operation).toThrowError(expect.objectContaining({ code })); + }); + + it("reports invalid ranges as absent", () => { + expect(reader.has(-1)).toBe(false); + expect(reader.has(0, -1)).toBe(false); + expect(reader.has(0.5)).toBe(false); + expect(reader.has(Number.NaN)).toBe(false); + expect(reader.has(Number.POSITIVE_INFINITY)).toBe(false); + expect(reader.has(4, 1)).toBe(false); + expect(reader.matches(-1, [0x01])).toBe(false); + }); + + it("does not leak native DataView RangeError", () => { + try { + reader.u32BE(1); + expect.unreachable("the range should have failed"); + } catch (error: unknown) { + expect(error).toBeInstanceOf(BinaryBoundsError); + expect(error).not.toBeInstanceOf(RangeError); + } + }); +}); diff --git a/tests/unit/format-detection.test.ts b/tests/unit/format-detection.test.ts new file mode 100644 index 0000000..bab1dc0 --- /dev/null +++ b/tests/unit/format-detection.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { inspectMetadata } from "../../src/index.js"; + +const PNG_SIGNATURE = Uint8Array.of( + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, +); + +function webpHeader(size = [0, 0, 0, 0]): Uint8Array { + return Uint8Array.of(0x52, 0x49, 0x46, 0x46, ...size, 0x57, 0x45, 0x42, 0x50); +} + +describe("JPEG format detection", () => { + it.each([ + [Uint8Array.of(0xff, 0xd8), "exact signature"], + [Uint8Array.of(0xff, 0xd8, 0xff, 0xe0), "normal prefix"], + [Uint8Array.of(0xff, 0xd8, 0x00, 0xff), "malformed trailing data"], + ])("detects JPEG from the required start signature: %s", (input) => { + expect(inspectMetadata(input).format).toBe("jpeg"); + }); + + it.each([ + Uint8Array.of(0xff), + Uint8Array.of(0xd8, 0xff), + Uint8Array.of(0x00, 0x01), + ])("does not misclassify non-matching bytes", (input) => { + expect(inspectMetadata(input).format).toBe("unknown"); + }); +}); + +describe("PNG format detection", () => { + it("detects the exact eight-byte signature", () => { + expect(inspectMetadata(PNG_SIGNATURE).format).toBe("png"); + }); + + it("detects the signature with trailing bytes", () => { + const input = Uint8Array.from([...PNG_SIGNATURE, 0x00, 0x01]); + + expect(inspectMetadata(input).format).toBe("png"); + }); + + it("treats every truncated signature as unknown", () => { + for (let length = 0; length < PNG_SIGNATURE.length; length += 1) { + expect(inspectMetadata(PNG_SIGNATURE.slice(0, length)).format).toBe( + "unknown", + ); + } + }); + + it("rejects a one-byte signature corruption", () => { + const corrupted = Uint8Array.from(PNG_SIGNATURE); + corrupted[4] = 0xff; + + expect(inspectMetadata(corrupted).format).toBe("unknown"); + }); + + it("respects a Uint8Array view into a larger backing buffer", () => { + const backing = Uint8Array.of(0xaa, 0xbb, ...PNG_SIGNATURE, 0xcc); + const view = new Uint8Array( + backing.buffer, + backing.byteOffset + 2, + PNG_SIGNATURE.length, + ); + + expect(inspectMetadata(view)).toMatchObject({ format: "png", size: 8 }); + }); +}); + +describe("WebP format detection", () => { + it("detects the minimal RIFF....WEBP header", () => { + expect(inspectMetadata(webpHeader()).format).toBe("webp"); + }); + + it("accepts arbitrary RIFF size bytes and trailing data", () => { + const input = Uint8Array.from([ + ...webpHeader([0xff, 0x00, 0x80, 0x7f]), + 0x01, + 0x02, + ]); + + expect(inspectMetadata(input).format).toBe("webp"); + }); + + it.each([ + Uint8Array.from([0x52, 0x49, 0x46, 0x46]), + Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]), + Uint8Array.from(webpHeader().slice(0, 11)), + Uint8Array.from([ + 0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x41, 0x56, 0x45, + ]), + ])("treats incomplete or unrelated RIFF input as unknown", (input) => { + expect(inspectMetadata(input).format).toBe("unknown"); + }); +}); diff --git a/tests/unit/input.test.ts b/tests/unit/input.test.ts new file mode 100644 index 0000000..e92d40e --- /dev/null +++ b/tests/unit/input.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { toUint8Array } from "../../src/core/binary/input.js"; + +describe("binary input normalization", () => { + it("creates a no-copy view over an ArrayBuffer", () => { + const buffer = Uint8Array.of(1, 2, 3).buffer; + const normalized = toUint8Array(buffer); + + expect(normalized.buffer).toBe(buffer); + expect(normalized).toEqual(Uint8Array.of(1, 2, 3)); + }); + + it("preserves the exact range of a Uint8Array view", () => { + const backing = Uint8Array.of(0xaa, 1, 2, 3, 0xbb); + const view = new Uint8Array(backing.buffer, backing.byteOffset + 1, 3); + const normalized = toUint8Array(view); + + expect(normalized).toBe(view); + expect(normalized.byteOffset).toBe(view.byteOffset); + expect(normalized.byteLength).toBe(3); + expect(normalized).toEqual(Uint8Array.of(1, 2, 3)); + }); +}); diff --git a/tests/unit/inspection.test.ts b/tests/unit/inspection.test.ts new file mode 100644 index 0000000..5a0fb51 --- /dev/null +++ b/tests/unit/inspection.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { + InputLimitExceededError, + InvalidParseLimitError, + inspectMetadata, +} from "../../src/index.js"; + +describe("format-only inspection", () => { + it("returns an explicit format-only report for empty input", () => { + expect(inspectMetadata(new Uint8Array())).toEqual({ + format: "unknown", + size: 0, + inspectionStatus: "format-only", + entries: [], + diagnostics: [], + }); + }); + + it("accepts input at the configured maximum", () => { + const input = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a); + + expect( + inspectMetadata(input, { limits: { maxInputBytes: 8 } }).format, + ).toBe("png"); + }); + + it("rejects input above the configured maximum", () => { + const operation = (): unknown => + inspectMetadata(new Uint8Array(9), { limits: { maxInputBytes: 8 } }); + + expect(operation).toThrowError(InputLimitExceededError); + expect(operation).toThrowError( + expect.objectContaining({ + code: "INPUT_LIMIT_EXCEEDED", + inputLength: 9, + maximumLength: 8, + }), + ); + }); + + it.each([ + -1, + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ])("rejects invalid maxInputBytes value %s", (maxInputBytes) => { + const operation = (): unknown => + inspectMetadata(new Uint8Array(), { limits: { maxInputBytes } }); + + expect(operation).toThrowError(InvalidParseLimitError); + expect(operation).toThrowError( + expect.objectContaining({ code: "INVALID_LIMIT" }), + ); + }); + + it("is structurally deterministic", () => { + const input = Uint8Array.of(0xff, 0xd8, 0x00); + + expect(inspectMetadata(input)).toEqual(inspectMetadata(input)); + }); + + it("does not mutate Uint8Array input", () => { + const input = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a); + const before = Uint8Array.from(input); + + inspectMetadata(input); + expect(input).toEqual(before); + }); + + it("accepts ArrayBuffer input without changing the contract", () => { + const input = Uint8Array.of(0xff, 0xd8).buffer; + + expect(inspectMetadata(input)).toMatchObject({ + format: "jpeg", + size: 2, + inspectionStatus: "format-only", + }); + }); +}); diff --git a/tests/unit/public-api.test.ts b/tests/unit/public-api.test.ts index 64f9fcb..37ede02 100644 --- a/tests/unit/public-api.test.ts +++ b/tests/unit/public-api.test.ts @@ -17,11 +17,10 @@ describe("public API", () => { }); it.each([ - ["inspectMetadata", inspectMetadata], ["cleanMetadata", cleanMetadata], ["verifyMetadata", verifyMetadata], ] as const)( - "returns deterministic unimplemented behavior from %s", + "keeps deterministic unimplemented behavior for %s", (_, operation) => { expect(() => operation(new Uint8Array())).toThrowError( NotImplementedError, @@ -32,14 +31,17 @@ describe("public API", () => { }, ); - it("accepts Uint8Array and ArrayBuffer as public binary input types", () => { + it("accepts Uint8Array and ArrayBuffer as public inspection inputs", () => { const inputs: readonly BinaryInput[] = [ new Uint8Array(), new ArrayBuffer(0), ]; for (const input of inputs) { - expect(() => inspectMetadata(input)).toThrowError(NotImplementedError); + expect(inspectMetadata(input)).toMatchObject({ + format: "unknown", + inspectionStatus: "format-only", + }); } });