diff --git a/CHANGELOG.md b/CHANGELOG.md index c86d7cf..3e9d30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - Started the v2 cycle with a production Image category and local Image Converter. - Added Image Resize with pixel and percentage modes, aspect-ratio preservation, optional enlargement, Original/JPEG/PNG/WebP output, and local batch ZIP saving. - Added Image Compressor with Original/JPEG/PNG/WebP output, truthful format-specific quality behavior, unchanged dimensions, and local batch ZIP saving. +- Added the single-file Image Metadata Inspector & Cleaner for JPEG, PNG, and WebP with honest partial/opaque reporting, authoritative Privacy Clean, ICC preservation, and fail-closed verification before save. +- Pinned the immutable `secure-metadata v0.1.0` browser Release artifact as a same-origin dependency with exact provenance and SHA-256 release-gate coverage. - Added per-file and aggregate compression metrics that distinguish byte savings from larger generated results. - Added per-image output dimension/pixel checks and a 200-megapixel aggregate resize-output workload limit. - Added JPEG, PNG, and WebP input/output, lossy quality controls for JPEG/WebP, deterministic white JPEG transparency, metadata-stripping canvas re-encoding, collision-safe Unicode names, and ZIP batch output. diff --git a/README.md b/README.md index 85509ab..e25aac6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ The v1 baseline is recorded in the [changelog](./CHANGELOG.md) and historical [v - [Image Converter](./tools/image/converter/) — convert batches of JPEG, PNG, and WebP images locally with predictable names and ZIP output. - [Image Resize](./tools/image/resize/) — resize image batches by pixels or percentage while preserving aspect ratio by default. - [Image Compressor](./tools/image/compress/) — quality-compress image batches locally and compare original and result sizes. +- [Image Metadata Inspector & Cleaner](./tools/image/metadata/) — inspect supported metadata and save a fail-closed, verified cleaned copy without pixel re-encoding. - [Images to PDF](./tools/pdf/images-to-pdf/) — arrange JPEG, PNG, and WebP images and save them as one PDF. - [Merge PDF](./tools/pdf/merge/) — validate, order, and combine PDF pages without rasterizing them. - [Split PDF](./tools/pdf/split/) — extract ordered page ranges or create predictable per-page and fixed-interval archives. @@ -74,7 +75,10 @@ The hub is a static site built with semantic HTML, CSS, and Vanilla JavaScript E │ ├── ja.js │ ├── es.js │ ├── de.js -│ └── fr.js +│ ├── fr.js +│ ├── image-resize.js +│ ├── image-compressor.js +│ └── image-metadata.js ├── tools/ │ ├── shared/ │ │ ├── file.js @@ -92,7 +96,10 @@ The hub is a static site built with semantic HTML, CSS, and Vanilla JavaScript E │ │ └── metadata/ │ ├── image/ │ │ ├── index.html -│ │ └── converter/ +│ │ ├── converter/ +│ │ ├── resize/ +│ │ ├── compress/ +│ │ └── metadata/ │ ├── privacy/ │ ├── scan/ │ ├── media/ @@ -102,12 +109,14 @@ The hub is a static site built with semantic HTML, CSS, and Vanilla JavaScript E │ ├── jspdf/ │ ├── jszip/ │ ├── pdf-lib/ -│ └── pdfjs/ +│ ├── pdfjs/ +│ └── secure-metadata/ └── tests/ ├── ci-foundation.test.mjs ├── home-structure.test.mjs ├── image-to-pdf.test.mjs ├── image-converter.test.mjs + ├── image-metadata.test.mjs ├── pdf-merge-and-categories.test.mjs ├── pdf-split.test.mjs ├── pdf-metadata.test.mjs @@ -168,6 +177,20 @@ Image Compressor is available at `/tools/image/compress/` on the v2 integration - Uses collision-safe Unicode `_compressed` names and saves multiple outputs as `compressed_images.zip` through the same-origin JSZip dependency. - Reuses the established input, queue, dimension, 50-megapixel per-image, and 200-megapixel aggregate decoded-work limits. Queues remain available after recoverable failures or save cancellation. - Performs no target-size search, resizing, cropping, metadata editing, upload, analytics, telemetry, remote codec, or runtime network request. +## Image Metadata Inspector & Cleaner + +Image Metadata Inspector & Cleaner is available at `/tools/image/metadata/` on the v2 integration branch. + +- Accepts exactly one signature-validated JPEG, PNG, or WebP file and enforces the application’s 50 MiB limit before full inspection. +- Uses the manually pinned, same-origin `secure-metadata v0.1.0` browser artifact. No npm package, CDN, runtime GitHub request, or automatic version check is used. +- Separates decoded values from opaque detected containers and presents `metadata-partial` as successful but non-exhaustive. “No supported metadata detected” is not a claim that the file contains no metadata. +- Privacy Clean calls the library’s authoritative default policy: supported EXIF, XMP, IPTC, comments, PNG text metadata, and timestamps are removed while ICC color profiles are preserved. +- Keeps source bytes unchanged and never decodes pixels, creates Canvas, resizes, converts, changes quality, or re-encodes the image. +- Calls `verifyMetadata` on cleaned bytes and requires a valid result with every policy check passing before saving. Invalid, incomplete, truncated, or mismatched results fail closed with no output write. +- Derives MIME and the normalized `_clean` filename from the detected image format, not the supplied MIME type or extension. + +Detailed wording boundaries and provenance are recorded in [Image Metadata privacy and verification](./docs/image-metadata-privacy.md). + ## Images to PDF @@ -293,6 +316,17 @@ All processing libraries are pinned and served as same-origin static files. Prod - Main module and worker: same-origin files under `assets/vendor/pdfjs/` - Details and hashes: [assets/vendor/pdfjs/README.md](./assets/vendor/pdfjs/README.md) +### secure-metadata + +- Version/tag: `v0.1.0` +- Release commit: `352258ec413a838dfe8b9146370505f125b5ae10` +- Purpose: local JPEG, PNG, and WebP metadata inspection, Privacy Clean, and fail-closed verification +- Browser artifact SHA-256: `8d0b8a1addf904760aa1f52378fb05eed6540520cb05fe2320d77011cba69c28` +- License: MIT +- Runtime dependencies: 0 +- Integration: manually pinned same-origin GitHub Release artifact; not an npm runtime dependency +- Details and provenance: [assets/vendor/secure-metadata/README.md](./assets/vendor/secure-metadata/README.md) + Each dependency keeps its license and package metadata beside the vendored browser build. ## Local development @@ -311,7 +345,7 @@ Run the complete local and CI validation entry point with: node tests/run-all.mjs ``` -It checks JavaScript syntax and runs Image Converter, Images to PDF, PDF Merge, PDF Split, PDF Organizer, PDF to Images, PDF Metadata, category-first homepage, system typography, CJK wrapping, long-copy layout, six-language catalog parity and placeholders, locale detection and persistence, static resource, privacy/network, security-hardening, dependency-integrity, save-path, ZIP, and CI workflow regression coverage. Test fixtures are generated deterministically; CI never processes real user files. +It checks JavaScript syntax and runs Image Converter, Image Resize, Image Compressor, Image Metadata, Images to PDF, PDF Merge, PDF Split, PDF Organizer, PDF to Images, PDF Metadata, category-first homepage, system typography, CJK wrapping, long-copy layout, six-language catalog parity and placeholders, locale detection and persistence, static resource, privacy/network, security-hardening, dependency-integrity, save-path, ZIP, and CI workflow regression coverage. Test fixtures are generated deterministically; CI never processes real user files. ## Production security controls @@ -371,7 +405,6 @@ The historical prototype is absent from the deployed tree. External URLs in docu ## Deferred work - Broader PDF modification, compression, and encryption workflows -- Image resizing, compression, and dedicated metadata inspection/cleaning - Broader XMP and structural PDF metadata sanitization - Scan/OCR and media tools - Offline/PWA support diff --git a/assets/vendor/secure-metadata/LICENSE b/assets/vendor/secure-metadata/LICENSE new file mode 100644 index 0000000..4eae717 --- /dev/null +++ b/assets/vendor/secure-metadata/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Secure Tools Project contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/vendor/secure-metadata/README.md b/assets/vendor/secure-metadata/README.md new file mode 100644 index 0000000..0158ad3 --- /dev/null +++ b/assets/vendor/secure-metadata/README.md @@ -0,0 +1,19 @@ +# secure-metadata + +Manually pinned browser artifact for local image metadata inspection, cleaning, and verification. + +- Library: `secure-metadata` +- Version: `0.1.0` +- Repository: `SecureToolsProject/Secure_Metadata` +- Release tag: `v0.1.0` +- Release commit: `352258ec413a838dfe8b9146370505f125b5ae10` +- Artifact: `secure-metadata-0.1.0.browser.js` +- SHA-256: `8d0b8a1addf904760aa1f52378fb05eed6540520cb05fe2320d77011cba69c28` +- License: MIT; see `LICENSE` +- Runtime dependencies: 0 +- Integration: manually pinned, same-origin +- Upgrade policy: explicit reviewed replacement only + +The browser artifact was downloaded from the GitHub `v0.1.0` Release and checked locally against both the published `SHA256SUMS` manifest and the approved hash above. Its bytes are unchanged: it was not rebuilt, minified, reformatted, concatenated, or stripped. `LICENSE` and `package.json` were copied from the immutable `v0.1.0` tag. + +Secure Tools imports this file only through `tools/image/metadata/metadata.js`. Production pages do not load secure-metadata from npm, a CDN, GitHub, or another runtime origin. Updates require a new explicit provenance and hash review. diff --git a/assets/vendor/secure-metadata/package.json b/assets/vendor/secure-metadata/package.json new file mode 100644 index 0000000..4709432 --- /dev/null +++ b/assets/vendor/secure-metadata/package.json @@ -0,0 +1,74 @@ +{ + "name": "secure-metadata", + "version": "0.1.0", + "description": "Deterministic, security-conscious metadata tooling for binary image formats.", + "license": "MIT", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./browser": { + "types": "./dist/index.d.ts", + "import": "./dist/browser/secure-metadata.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsup && tsup --config tsup.browser.config.ts", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest", + "fuzz:smoke": "npm run build --silent && node scripts/fuzz.mjs --seed 20260825 --runs 250 --max-bytes 512", + "fuzz": "npm run build --silent && node scripts/fuzz.mjs", + "typecheck": "tsc --noEmit", + "browser:smoke": "node scripts/browser-smoke.mjs", + "package:audit": "node scripts/release/audit-package.mjs", + "license:audit": "node scripts/release/audit-licenses.mjs", + "version:check": "node scripts/release/check-version.mjs", + "release:build": "node scripts/release/build-artifacts.mjs", + "release:repro": "node scripts/release/check-reproducibility.mjs", + "release:verify": "node scripts/release/verify-hashes.mjs", + "release:check": "node scripts/release/check-rc.mjs" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.13.3", + "eslint": "^10.9.0", + "fast-check": "^4.9.0", + "playwright": "^1.62.1", + "prettier": "^3.9.6", + "tsup": "^8.5.1", + "typescript": "5.9.3", + "typescript-eslint": "^8.67.0", + "vitest": "^4.1.11" + }, + "overrides": { + "esbuild": "0.28.2" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/SecureToolsProject/Secure_Metadata.git" + }, + "homepage": "https://github.com/SecureToolsProject/Secure_Metadata#readme", + "bugs": { + "url": "https://github.com/SecureToolsProject/Secure_Metadata/issues" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/assets/vendor/secure-metadata/secure-metadata-0.1.0.browser.js b/assets/vendor/secure-metadata/secure-metadata-0.1.0.browser.js new file mode 100644 index 0000000..915bdd9 --- /dev/null +++ b/assets/vendor/secure-metadata/secure-metadata-0.1.0.browser.js @@ -0,0 +1,3008 @@ +// src/core/errors.ts +var SecureMetadataError = class extends Error { + constructor(message, code, options) { + super(message, options); + this.code = code; + } + code; + name = "SecureMetadataError"; +}; +var BinaryBoundsError = class extends SecureMetadataError { + constructor(code, inputLength, offset, requestedLength) { + super( + `Invalid binary range: offset ${String(offset)}, length ${String(requestedLength)}, input length ${String(inputLength)}.`, + code + ); + this.inputLength = inputLength; + this.offset = offset; + this.requestedLength = requestedLength; + } + inputLength; + offset; + requestedLength; + name = "BinaryBoundsError"; +}; +var InvalidParseLimitError = class extends SecureMetadataError { + constructor(limitName, value) { + super( + `Parse limit ${limitName} must be a non-negative safe integer; received ${String(value)}.`, + "INVALID_LIMIT" + ); + this.limitName = limitName; + this.value = value; + } + limitName; + value; + name = "InvalidParseLimitError"; +}; +var InputLimitExceededError = class extends SecureMetadataError { + constructor(inputLength, maximumLength) { + super( + `Input length ${String(inputLength)} exceeds maxInputBytes ${String(maximumLength)}.`, + "INPUT_LIMIT_EXCEEDED" + ); + this.inputLength = inputLength; + this.maximumLength = maximumLength; + } + inputLength; + maximumLength; + name = "InputLimitExceededError"; +}; +var UnsupportedFormatError = class extends SecureMetadataError { + constructor(operation, format) { + super( + `${operation} does not support ${format} input.`, + "UNSUPPORTED_FORMAT" + ); + this.operation = operation; + this.format = format; + } + operation; + format; + name = "UnsupportedFormatError"; +}; +var IncompleteJpegError = class extends SecureMetadataError { + constructor(operation, diagnostics) { + super( + `${operation} requires a structurally complete JPEG ending at EOI.`, + "INCOMPLETE_JPEG" + ); + this.operation = operation; + this.diagnostics = diagnostics; + } + operation; + diagnostics; + name = "IncompleteJpegError"; +}; +var IncompleteWebPError = class extends SecureMetadataError { + constructor(operation, diagnostics) { + super( + `${operation} requires a structurally complete WebP RIFF container.`, + "INCOMPLETE_WEBP" + ); + this.operation = operation; + this.diagnostics = diagnostics; + } + operation; + diagnostics; + name = "IncompleteWebPError"; +}; +var IncompletePngError = class extends SecureMetadataError { + constructor(operation, diagnostics) { + super( + `${operation} requires a structurally complete PNG ending at IEND.`, + "INCOMPLETE_PNG" + ); + this.operation = operation; + this.diagnostics = diagnostics; + } + operation; + diagnostics; + name = "IncompletePngError"; +}; + +// src/core/binary/bounds.ts +function hasValidRange(inputLength, offset, length) { + return Number.isSafeInteger(offset) && Number.isSafeInteger(length) && offset >= 0 && length >= 0 && offset <= inputLength && length <= inputLength - offset; +} +function assertValidRange(inputLength, offset, length) { + 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); + } +} + +// src/core/binary/byte-reader.ts +var ByteReader = class { + length; + #bytes; + #view; + constructor(bytes) { + this.#bytes = bytes; + this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + this.length = bytes.byteLength; + } + has(offset, length = 1) { + return hasValidRange(this.length, offset, length); + } + u8(offset) { + assertValidRange(this.length, offset, 1); + return this.#view.getUint8(offset); + } + u16LE(offset) { + assertValidRange(this.length, offset, 2); + return this.#view.getUint16(offset, true); + } + u16BE(offset) { + assertValidRange(this.length, offset, 2); + return this.#view.getUint16(offset, false); + } + u32LE(offset) { + assertValidRange(this.length, offset, 4); + return this.#view.getUint32(offset, true); + } + u32BE(offset) { + 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, length) { + assertValidRange(this.length, offset, length); + return this.#bytes.subarray(offset, offset + length); + } + matches(offset, signature) { + 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; + } +}; + +// src/core/binary/input.ts +function toUint8Array(input) { + return input instanceof Uint8Array ? input : new Uint8Array(input); +} + +// src/core/detect-format.ts +var PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; +var JPEG_SIGNATURE = [255, 216]; +var RIFF_SIGNATURE = [82, 73, 70, 70]; +var WEBP_SIGNATURE = [87, 69, 66, 80]; +function detectFormat(reader) { + 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"; +} + +// src/core/limits.ts +var DEFAULT_PARSE_LIMITS = Object.freeze({ + maxInputBytes: 100 * 1024 * 1024, + maxSegments: 4096, + maxChunks: 4096, + maxMetadataEntries: 1e4, + maxIfdDepth: 16, + maxIfdEntries: 4096, + maxStringBytes: 4 * 1024 * 1024, + maxDecompressedBytes: 16 * 1024 * 1024, + maxDiagnostics: 256 +}); +function resolveParseLimit(name, configured) { + const value = configured ?? DEFAULT_PARSE_LIMITS[name]; + if (!Number.isSafeInteger(value) || value < 0) { + throw new InvalidParseLimitError(name, value); + } + return value; +} + +// src/exif/metadata.ts +function metadataEntriesFromTiff(result, context) { + 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 === void 0 ? {} : { 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 + } + })); +} +function relocateTiffDiagnostics(diagnostics, baseOffset) { + return diagnostics.map( + (item) => item.offset === void 0 ? item : { ...item, offset: baseOffset + item.offset } + ); +} + +// src/exif/field-types.ts +var TIFF_FIELD_TYPE = { + BYTE: 1, + ASCII: 2, + SHORT: 3, + LONG: 4, + RATIONAL: 5, + UNDEFINED: 7, + SLONG: 9, + SRATIONAL: 10 +}; +var FIELD_TYPE_SIZES = { + [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 +}; +function tiffFieldTypeSize(type) { + return FIELD_TYPE_SIZES[type]; +} + +// src/exif/decode-value.ts +var MAX_DECODED_COMPONENTS = 1024; +function scalarOrArray(values) { + if (values.length === 1) { + const value = values[0]; + if (value !== void 0) { + return value; + } + } + return values; +} +function ascii(reader, offset, count) { + let result = ""; + for (let index = 0; index < count; index += 1) { + const byte = reader.u8(offset + index); + if (byte === 0) { + break; + } + result += byte <= 127 ? String.fromCharCode(byte) : "?"; + } + return result; +} +function unsignedValues(reader, offset, count, width) { + const values = []; + 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, offset, count) { + const values = []; + for (let index = 0; index < count; index += 1) { + values.push(reader.i32(offset + index * 4)); + } + return scalarOrArray(values); +} +function rationalValues(reader, offset, count, signed) { + const diagnostics = []; + const values = []; + 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 }; +} +function decodeTiffValue(reader, type, count, valueOffset, definition) { + 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 = []; + 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: [] }; + } +} + +// src/exif/tags.ts +var TIFF_TAG = { + IMAGE_DESCRIPTION: 270, + MAKE: 271, + MODEL: 272, + ORIENTATION: 274, + SOFTWARE: 305, + DATE_TIME: 306, + ARTIST: 315, + COPYRIGHT: 33432, + EXIF_IFD_POINTER: 34665, + GPS_IFD_POINTER: 34853, + EXPOSURE_TIME: 33434, + F_NUMBER: 33437, + ISO_SPEED: 34855, + EXIF_VERSION: 36864, + DATE_TIME_ORIGINAL: 36867, + DATE_TIME_DIGITIZED: 36868, + FOCAL_LENGTH: 37386, + MAKER_NOTE: 37500, + PIXEL_X_DIMENSION: 40962, + PIXEL_Y_DIMENSION: 40963, + FOCAL_LENGTH_35MM: 41989 +}; +var IFD0_TAGS = { + [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" + } +}; +var EXIF_TAGS = { + [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" + } +}; +var GPS_TAGS = { + 0: { + name: "GPSVersionID", + namespace: "gps", + category: "technical", + privacy: "non-sensitive", + special: "gps-version" + }, + 1: location("GPSLatitudeRef"), + 2: location("GPSLatitude"), + 3: location("GPSLongitudeRef"), + 4: location("GPSLongitude"), + 5: location("GPSAltitudeRef"), + 6: location("GPSAltitude"), + 7: { + name: "GPSTimeStamp", + namespace: "gps", + category: "timestamp", + privacy: "potentially-sensitive" + }, + 29: { + name: "GPSDateStamp", + namespace: "gps", + category: "timestamp", + privacy: "potentially-sensitive" + } +}; +function technical(name) { + return { + name, + namespace: "exif", + category: "technical", + privacy: "non-sensitive" + }; +} +function timestamp(name) { + return { + name, + namespace: "exif", + category: "timestamp", + privacy: "potentially-sensitive" + }; +} +function location(name) { + return { + name, + namespace: "gps", + category: "location", + privacy: "sensitive" + }; +} +function tiffTagDefinition(kind, tag) { + 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" + }; +} + +// src/exif/tiff-reader.ts +var TiffReader = class { + length; + #reader; + #littleEndian; + constructor(bytes, byteOrder2) { + this.#reader = new ByteReader(bytes); + this.#littleEndian = byteOrder2 === "little"; + this.length = bytes.byteLength; + } + has(offset, length = 1) { + return this.#reader.has(offset, length); + } + u8(offset) { + return this.#reader.u8(offset); + } + u16(offset) { + return this.#littleEndian ? this.#reader.u16LE(offset) : this.#reader.u16BE(offset); + } + u32(offset) { + return this.#littleEndian ? this.#reader.u32LE(offset) : this.#reader.u32BE(offset); + } + i32(offset) { + const value = this.u32(offset); + return value >= 2147483648 ? value - 4294967296 : value; + } +}; + +// src/exif/tiff.ts +function checkedMultiply(left, right) { + return left <= Math.floor(Number.MAX_SAFE_INTEGER / right) ? left * right : void 0; +} +function emit(state, code, message, offset, severity = "error") { + if (state.diagnostics.length < state.maxDiagnostics) { + state.diagnostics.push( + offset === void 0 ? { severity, code, message } : { severity, code, message, offset } + ); + } + if (severity === "error") { + state.complete = false; + } +} +function initialFailure(code, message, maxDiagnostics, offset) { + const diagnostic4 = offset === void 0 ? { severity: "error", code, message } : { severity: "error", code, message, offset }; + return { + complete: false, + entries: [], + diagnostics: maxDiagnostics === 0 ? [] : [diagnostic4] + }; +} +function byteOrder(bytes) { + if (bytes.matches(0, [73, 73])) { + return "little"; + } + if (bytes.matches(0, [77, 77])) { + return "big"; + } + return void 0; +} +function queueTarget(reader, state, pending, target, kind, path, depth, sourceOffset, maxTargets) { + 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 }); +} +function parseTiff(bytes, limits) { + const maxDiagnostics = limits.maxDiagnostics ?? DEFAULT_PARSE_LIMITS.maxDiagnostics; + const raw = new ByteReader(bytes); + if (!raw.has(0, 8)) { + return initialFailure( + "TIFF_TRUNCATED_HEADER", + "TIFF header requires at least eight bytes.", + maxDiagnostics, + 0 + ); + } + const order = byteOrder(raw); + if (order === void 0) { + return initialFailure( + "TIFF_INVALID_BYTE_ORDER", + "TIFF byte order must be II or MM.", + maxDiagnostics, + 0 + ); + } + const reader = new TiffReader(bytes, order); + if (reader.u16(2) !== 42) { + return { + byteOrder: order, + complete: false, + entries: [], + diagnostics: maxDiagnostics === 0 ? [] : [ + { + 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: maxDiagnostics === 0 ? [] : [ + { + severity: "error", + code: "TIFF_INVALID_FIRST_IFD_OFFSET", + message: "TIFF first IFD offset is outside the TIFF payload.", + offset: 4 + } + ] + }; + } + const state = { + entries: [], + diagnostics: [], + complete: true, + processedEntries: 0, + traversalLimitReported: false, + maxDiagnostics + }; + const pending = [ + { offset: firstIfdOffset, kind: "ifd0", path: "IFD0", depth: 1 } + ]; + const visited = /* @__PURE__ */ new Set(); + let queueIndex = 0; + while (queueIndex < pending.length) { + const current = pending[queueIndex]; + queueIndex += 1; + if (current === void 0) { + 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 === void 0) { + 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 = []; + const gpsTargets = []; + const entriesOffset = current.offset + 2; + for (let index = 0; index < entryCount; index += 1) { + if (state.processedEntries >= limits.maxMetadataEntries) { + if (!state.traversalLimitReported) { + emit( + state, + "TIFF_TRAVERSAL_LIMIT_EXCEEDED", + `TIFF traversal exceeds maxMetadataEntries ${String(limits.maxMetadataEntries)}.`, + entriesOffset + index * 12 + ); + state.traversalLimitReported = true; + } + break; + } + state.processedEntries += 1; + const entryOffset = entriesOffset + index * 12; + const tag = reader.u16(entryOffset); + const type = reader.u16(entryOffset + 2); + const count = reader.u32(entryOffset + 4); + const valueFieldOffset = entryOffset + 8; + const definition = tiffTagDefinition(current.kind, tag); + const typeSize = tiffFieldTypeSize(type); + if (typeSize === void 0) { + 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 === void 0 || 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: [] } : decodeTiffValue(reader, type, count, valueOffset, definition); + for (const item of decoded.diagnostics) { + if (state.diagnostics.length < state.maxDiagnostics) { + 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 === void 0 ? {} : { 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, + ...state.traversalLimitReported ? { entryLimitExceeded: true } : {} + }; +} + +// src/jpeg/markers.ts +var JPEG_MARKER = { + TEM: 1, + SOF0: 192, + SOF1: 193, + SOF2: 194, + DHT: 196, + SOI: 216, + EOI: 217, + SOS: 218, + DQT: 219, + DRI: 221, + COM: 254 +}; +var MARKER_NAMES = { + [JPEG_MARKER.TEM]: "TEM", + [JPEG_MARKER.SOF0]: "SOF0", + [JPEG_MARKER.SOF1]: "SOF1", + [JPEG_MARKER.SOF2]: "SOF2", + [JPEG_MARKER.DHT]: "DHT", + [JPEG_MARKER.SOI]: "SOI", + [JPEG_MARKER.EOI]: "EOI", + [JPEG_MARKER.SOS]: "SOS", + [JPEG_MARKER.DQT]: "DQT", + [JPEG_MARKER.DRI]: "DRI", + [JPEG_MARKER.COM]: "COM" +}; +function isApplicationMarker(marker) { + return marker >= 224 && marker <= 239; +} +function isRestartMarker(marker) { + return marker >= 208 && marker <= 215; +} +function isStandaloneMarker(marker) { + return marker === JPEG_MARKER.TEM || marker === JPEG_MARKER.SOI || marker === JPEG_MARKER.EOI || isRestartMarker(marker); +} +function isValidMarkerCode(marker) { + return marker === JPEG_MARKER.TEM || marker >= 192 && marker <= 254; +} +function markerName(marker) { + if (isApplicationMarker(marker)) { + return `APP${String(marker - 224)}`; + } + if (isRestartMarker(marker)) { + return `RST${String(marker - 208)}`; + } + return MARKER_NAMES[marker] ?? `UNKNOWN_${marker.toString(16).toUpperCase()}`; +} + +// src/jpeg/metadata.ts +function source(segment) { + return { + format: "jpeg", + container: "jpeg-segment", + offset: segment.offset, + length: segment.length, + jpegMarker: segment.marker + }; +} +function inspectJpegMetadata(reader, result, tiffLimits, maxMetadataEntries) { + const entries = []; + const diagnostics = []; + let attemptedExifDecode = false; + let entryLimitExceeded = false; + const add = (entry) => { + if (entries.length >= maxMetadataEntries) { + entryLimitExceeded = true; + return false; + } + entries.push(entry); + return true; + }; + for (const segment of result.segments) { + if (segment.marker === JPEG_MARKER.COM) { + add({ + id: `jpeg-comment-${String(segment.offset)}`, + namespace: "jpeg-comment", + name: "JPEG comment", + category: "description", + privacy: "potentially-sensitive", + source: source(segment) + }); + continue; + } + switch (segment.metadataKind) { + case "exif": { + if (!add({ + id: `jpeg-exif-${String(segment.offset)}`, + namespace: "exif", + name: "EXIF container", + category: "unknown", + privacy: "potentially-sensitive", + source: source(segment) + }) || segment.payloadOffset === void 0 || segment.payloadLength === void 0) { + break; + } + attemptedExifDecode = true; + const tiffOffset = segment.payloadOffset + 6; + const tiffLength = segment.payloadLength - 6; + const tiff = parseTiff(reader.slice(tiffOffset, tiffLength), { + ...tiffLimits, + maxMetadataEntries: maxMetadataEntries - entries.length, + maxDiagnostics: (tiffLimits.maxDiagnostics ?? DEFAULT_PARSE_LIMITS.maxDiagnostics) - diagnostics.length + }); + entries.push( + ...metadataEntriesFromTiff(tiff, { + format: "jpeg", + baseOffset: tiffOffset, + idPrefix: `jpeg-tiff-${String(segment.offset)}` + }) + ); + diagnostics.push( + ...relocateTiffDiagnostics(tiff.diagnostics, tiffOffset) + ); + entryLimitExceeded ||= tiff.entryLimitExceeded === true; + break; + } + case "xmp": + add({ + id: `jpeg-xmp-${String(segment.offset)}`, + namespace: "xmp", + name: segment.metadataSubtype === "extended-xmp" ? "Extended XMP container" : "XMP container", + category: "unknown", + privacy: "potentially-sensitive", + source: source(segment) + }); + break; + case "icc": + add({ + id: `jpeg-icc-${String(segment.offset)}`, + namespace: "icc", + name: "ICC profile container", + category: "color", + privacy: "non-sensitive", + source: source(segment) + }); + break; + case "iptc": + add({ + id: `jpeg-iptc-${String(segment.offset)}`, + namespace: "iptc", + name: "Photoshop/IPTC container", + category: "unknown", + privacy: "potentially-sensitive", + source: source(segment) + }); + break; + } + } + return { + entries, + diagnostics, + attemptedExifDecode, + entryLimitExceeded + }; +} + +// src/jpeg/classify.ts +var JFIF_SIGNATURE = [74, 70, 73, 70, 0]; +var JFXX_SIGNATURE = [74, 70, 88, 88, 0]; +var EXIF_SIGNATURE = [69, 120, 105, 102, 0, 0]; +var XMP_SIGNATURE = [ + 104, + 116, + 116, + 112, + 58, + 47, + 47, + 110, + 115, + 46, + 97, + 100, + 111, + 98, + 101, + 46, + 99, + 111, + 109, + 47, + 120, + 97, + 112, + 47, + 49, + 46, + 48, + 47, + 0 +]; +var EXTENDED_XMP_SIGNATURE = [ + 104, + 116, + 116, + 112, + 58, + 47, + 47, + 110, + 115, + 46, + 97, + 100, + 111, + 98, + 101, + 46, + 99, + 111, + 109, + 47, + 120, + 109, + 112, + 47, + 101, + 120, + 116, + 101, + 110, + 115, + 105, + 111, + 110, + 47, + 0 +]; +var ICC_SIGNATURE = [ + 73, + 67, + 67, + 95, + 80, + 82, + 79, + 70, + 73, + 76, + 69, + 0 +]; +var PHOTOSHOP_SIGNATURE = [ + 80, + 104, + 111, + 116, + 111, + 115, + 104, + 111, + 112, + 32, + 51, + 46, + 48, + 0 +]; +var ADOBE_SIGNATURE = [65, 100, 111, 98, 101]; +function matchesPayload(reader, payloadOffset, payloadLength, signature) { + return signature.length <= payloadLength && reader.matches(payloadOffset, signature); +} +function classifySegmentKind(marker) { + if (marker >= 224 && marker <= 239) { + return "application"; + } + if (marker === JPEG_MARKER.COM) { + return "comment"; + } + if (marker === JPEG_MARKER.SOS) { + return "scan"; + } + if (marker === JPEG_MARKER.TEM || marker === JPEG_MARKER.SOI || marker === JPEG_MARKER.EOI || marker >= 208 && marker <= 215) { + return "standalone"; + } + if (marker >= 192 && marker <= 223) { + return "image-structure"; + } + return "unknown"; +} +function classifyApplicationSegment(reader, marker, payloadOffset, payloadLength) { + if (marker === 224) { + if (matchesPayload(reader, payloadOffset, payloadLength, JFIF_SIGNATURE)) { + return { metadataKind: "jfif", metadataSubtype: "jfif" }; + } + if (matchesPayload(reader, payloadOffset, payloadLength, JFXX_SIGNATURE)) { + return { metadataKind: "jfif", metadataSubtype: "jfxx" }; + } + } + if (marker === 225) { + if (matchesPayload(reader, payloadOffset, payloadLength, EXIF_SIGNATURE)) { + return { metadataKind: "exif" }; + } + if (matchesPayload(reader, payloadOffset, payloadLength, XMP_SIGNATURE)) { + return { metadataKind: "xmp", metadataSubtype: "standard-xmp" }; + } + if (matchesPayload( + reader, + payloadOffset, + payloadLength, + EXTENDED_XMP_SIGNATURE + )) { + return { metadataKind: "xmp", metadataSubtype: "extended-xmp" }; + } + } + if (marker === 226 && matchesPayload(reader, payloadOffset, payloadLength, ICC_SIGNATURE)) { + return { metadataKind: "icc" }; + } + if (marker === 237 && matchesPayload(reader, payloadOffset, payloadLength, PHOTOSHOP_SIGNATURE)) { + return { metadataKind: "iptc", metadataSubtype: "photoshop" }; + } + if (marker === 238 && matchesPayload(reader, payloadOffset, payloadLength, ADOBE_SIGNATURE)) { + return { metadataKind: "adobe" }; + } + return { metadataKind: "unknown" }; +} + +// src/jpeg/parser.ts +function addDiagnostic(state, ...items) { + const remaining = state.maxDiagnostics - state.diagnostics.length; + if (remaining > 0) { + state.diagnostics.push(...items.slice(0, remaining)); + } +} +function diagnostic(severity, code, message, offset) { + return offset === void 0 ? { severity, code, message } : { severity, code, message, offset }; +} +function readMarker(reader, offset) { + if (reader.u8(offset) !== 255) { + return diagnostic( + "error", + "JPEG_INVALID_MARKER", + "Expected a JPEG marker prefix.", + offset + ); + } + let cursor = offset; + while (reader.has(cursor) && reader.u8(cursor) === 255) { + cursor += 1; + } + if (!reader.has(cursor)) { + return diagnostic( + "error", + "JPEG_TRUNCATED_MARKER", + "JPEG input ends within marker fill bytes.", + offset + ); + } + const marker = reader.u8(cursor); + if (marker === 0 || !isValidMarkerCode(marker)) { + return diagnostic( + "error", + "JPEG_INVALID_MARKER", + `Invalid JPEG marker code 0x${marker.toString(16).padStart(2, "0")}.`, + cursor + ); + } + return { + marker, + markerOffset: cursor - 1, + rangeOffset: offset, + afterMarker: cursor + 1 + }; +} +function addSegment(state, segment, maxSegments) { + if (state.segments.length >= maxSegments) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_SEGMENT_LIMIT_EXCEEDED", + `JPEG marker count exceeds maxSegments ${String(maxSegments)}.`, + segment.offset + ) + ); + return false; + } + state.segments.push(segment); + return true; +} +function incompleteResult(state, sawSoi) { + return { + segments: state.segments, + complete: false, + sawSoi, + sawEoi: false, + diagnostics: state.diagnostics + }; +} +function skipScanData(reader, scanOffset, state, maxSegments) { + let cursor = scanOffset; + while (reader.has(cursor)) { + if (reader.u8(cursor) !== 255) { + cursor += 1; + continue; + } + const fillStart = cursor; + cursor += 1; + while (reader.has(cursor) && reader.u8(cursor) === 255) { + cursor += 1; + } + if (!reader.has(cursor)) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_TRUNCATED_SCAN", + "JPEG entropy-coded scan ends within marker fill bytes.", + fillStart + ) + ); + return void 0; + } + const marker = reader.u8(cursor); + if (marker === 0) { + cursor += 1; + continue; + } + if (isRestartMarker(marker)) { + const markerOffset = cursor - 1; + if (!addSegment( + state, + { + marker, + markerName: markerName(marker), + offset: markerOffset, + length: 2, + rangeOffset: fillStart, + rangeLength: cursor + 1 - fillStart, + kind: "standalone" + }, + maxSegments + )) { + return void 0; + } + cursor += 1; + continue; + } + return cursor - 1; + } + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_TRUNCATED_SCAN", + "JPEG entropy-coded scan reaches EOF before a terminating marker.", + scanOffset + ) + ); + return void 0; +} +function parseJpeg(reader, maxSegments, maxDiagnostics = DEFAULT_PARSE_LIMITS.maxDiagnostics) { + const state = { segments: [], diagnostics: [], maxDiagnostics }; + if (!reader.matches(0, [255, JPEG_MARKER.SOI])) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_INVALID_SOI", + "JPEG input does not begin with the SOI marker.", + 0 + ) + ); + return incompleteResult(state, false); + } + if (!addSegment( + state, + { + marker: JPEG_MARKER.SOI, + markerName: "SOI", + offset: 0, + length: 2, + rangeOffset: 0, + rangeLength: 2, + kind: "standalone" + }, + maxSegments + )) { + return incompleteResult(state, true); + } + let offset = 2; + while (reader.has(offset)) { + const markerResult = readMarker(reader, offset); + if ("severity" in markerResult) { + addDiagnostic(state, markerResult); + return incompleteResult(state, true); + } + const { marker, markerOffset, rangeOffset, afterMarker } = markerResult; + if (marker === JPEG_MARKER.SOI) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_INVALID_MARKER", + "Unexpected SOI marker inside JPEG container.", + markerOffset + ) + ); + return incompleteResult(state, true); + } + if (isStandaloneMarker(marker)) { + if (!addSegment( + state, + { + marker, + markerName: markerName(marker), + offset: markerOffset, + length: 2, + rangeOffset, + rangeLength: afterMarker - rangeOffset, + kind: "standalone" + }, + maxSegments + )) { + return incompleteResult(state, true); + } + offset = afterMarker; + if (marker === JPEG_MARKER.EOI) { + if (offset < reader.length) { + addDiagnostic( + state, + diagnostic( + "warning", + "JPEG_TRAILING_DATA", + `JPEG contains ${String(reader.length - offset)} trailing byte(s) after EOI.`, + offset + ) + ); + } + return { + segments: state.segments, + complete: true, + sawSoi: true, + sawEoi: true, + diagnostics: state.diagnostics + }; + } + continue; + } + if (!reader.has(afterMarker, 2)) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_TRUNCATED_SEGMENT_LENGTH", + `${markerName(marker)} is missing its two-byte segment length.`, + afterMarker + ) + ); + return incompleteResult(state, true); + } + const declaredLength = reader.u16BE(afterMarker); + if (declaredLength < 2) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_INVALID_SEGMENT_LENGTH", + `${markerName(marker)} declares invalid length ${String(declaredLength)}.`, + afterMarker + ) + ); + return incompleteResult(state, true); + } + if (!reader.has(afterMarker, declaredLength)) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_TRUNCATED_SEGMENT", + `${markerName(marker)} extends beyond the JPEG input.`, + markerOffset + ) + ); + return incompleteResult(state, true); + } + const payloadOffset = afterMarker + 2; + const payloadLength = declaredLength - 2; + const segmentEnd = afterMarker + declaredLength; + const classification = isApplicationMarker(marker) ? classifyApplicationSegment(reader, marker, payloadOffset, payloadLength) : void 0; + const segment = { + marker, + markerName: markerName(marker), + offset: markerOffset, + length: declaredLength + 2, + rangeOffset, + rangeLength: segmentEnd - rangeOffset, + payloadOffset, + payloadLength, + kind: classifySegmentKind(marker), + ...classification ?? {} + }; + if (!addSegment(state, segment, maxSegments)) { + return incompleteResult(state, true); + } + offset = segmentEnd; + if (marker === JPEG_MARKER.SOS) { + const nextMarkerOffset = skipScanData( + reader, + segmentEnd, + state, + maxSegments + ); + if (nextMarkerOffset === void 0) { + return incompleteResult(state, true); + } + offset = nextMarkerOffset; + } + } + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_MISSING_EOI", + "JPEG input ends before an EOI marker.", + reader.length + ) + ); + return incompleteResult(state, true); +} + +// src/png/metadata.ts +function source2(chunk) { + return { + format: "png", + container: "png-chunk", + offset: chunk.offset, + length: chunk.totalLength, + chunkType: chunk.fourCC + }; +} +function inspectPngMetadata(reader, result, tiffLimits, maxMetadataEntries) { + const entries = []; + const diagnostics = []; + let attemptedExifDecode = false; + let entryLimitExceeded = false; + const add = (entry) => { + if (entries.length >= maxMetadataEntries) { + entryLimitExceeded = true; + return false; + } + entries.push(entry); + return true; + }; + for (const chunk of result.chunks) { + switch (chunk.metadataKind) { + case "exif": { + if (!add({ + id: `png-exif-${String(chunk.offset)}`, + namespace: "exif", + name: "PNG EXIF container", + category: "unknown", + privacy: "potentially-sensitive", + source: source2(chunk) + })) { + break; + } + attemptedExifDecode = true; + const tiff = parseTiff( + reader.slice(chunk.dataOffset, chunk.dataLength), + { + ...tiffLimits, + maxMetadataEntries: maxMetadataEntries - entries.length, + maxDiagnostics: (tiffLimits.maxDiagnostics ?? DEFAULT_PARSE_LIMITS.maxDiagnostics) - diagnostics.length + } + ); + entries.push( + ...metadataEntriesFromTiff(tiff, { + format: "png", + baseOffset: chunk.dataOffset, + idPrefix: `png-tiff-${String(chunk.offset)}` + }) + ); + diagnostics.push( + ...relocateTiffDiagnostics(tiff.diagnostics, chunk.dataOffset) + ); + entryLimitExceeded ||= tiff.entryLimitExceeded === true; + break; + } + case "xmp": + add({ + id: `png-xmp-${String(chunk.offset)}`, + namespace: "xmp", + name: "PNG XMP iTXt container", + category: "unknown", + privacy: "potentially-sensitive", + source: source2(chunk) + }); + break; + case "text": + add({ + id: `png-text-${String(chunk.offset)}`, + namespace: "png-text", + name: chunk.keyword === void 0 ? `${chunk.fourCC} metadata` : `${chunk.fourCC} metadata (${chunk.keyword})`, + category: "description", + privacy: "potentially-sensitive", + source: source2(chunk) + }); + break; + case "timestamp": + add({ + id: `png-time-${String(chunk.offset)}`, + namespace: "png-time", + name: "PNG modification time", + category: "timestamp", + privacy: "potentially-sensitive", + source: source2(chunk) + }); + break; + case "icc": + add({ + id: `png-icc-${String(chunk.offset)}`, + namespace: "icc", + name: "PNG ICC profile container", + category: "color", + privacy: "non-sensitive", + source: source2(chunk) + }); + break; + } + } + return { + entries, + diagnostics, + attemptedExifDecode, + entryLimitExceeded + }; +} + +// src/png/crc32.ts +function pngCrc32(bytes) { + let crc = 4294967295; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = crc >>> 1 ^ (crc & 1 ? 3988292384 : 0); + } + } + return (crc ^ 4294967295) >>> 0; +} + +// src/png/parser.ts +var PNG_SIGNATURE2 = [137, 80, 78, 71, 13, 10, 26, 10]; +var XMP_KEYWORD = "XML:com.adobe.xmp"; +function addDiagnostic2(diagnostics, maximum, ...items) { + const remaining = maximum - diagnostics.length; + if (remaining > 0) { + diagnostics.push(...items.slice(0, remaining)); + } +} +function diagnostic2(severity, code, message, offset) { + return offset === void 0 ? { severity, code, message } : { severity, code, message, offset }; +} +function failure(diagnostics, chunks = [], containerLength = 0) { + return { + chunks, + complete: false, + sawIend: false, + containerLength, + diagnostics + }; +} +function fourCC(reader, offset) { + return String.fromCharCode( + reader.u8(offset), + reader.u8(offset + 1), + reader.u8(offset + 2), + reader.u8(offset + 3) + ); +} +function isAsciiLetter(value) { + return value >= 65 && value <= 90 || value >= 97 && value <= 122; +} +function classifyChunk(fourCC3, ancillary) { + switch (fourCC3) { + case "IDAT": + return { kind: "image" }; + case "IHDR": + case "PLTE": + case "IEND": + return { kind: "critical" }; + case "eXIf": + return { kind: "metadata", metadataKind: "exif" }; + case "iCCP": + return { kind: "metadata", metadataKind: "icc" }; + case "tIME": + return { kind: "metadata", metadataKind: "timestamp" }; + case "tEXt": + case "zTXt": + case "iTXt": + return { kind: "metadata", metadataKind: "text" }; + case "gAMA": + case "cHRM": + case "sRGB": + case "sBIT": + case "pHYs": + return { kind: "color" }; + case "acTL": + case "fcTL": + case "fdAT": + return { kind: "animation" }; + default: + return { kind: ancillary ? "unknown" : "critical" }; + } +} +function readKeyword(reader, dataOffset, dataLength, maxStringBytes, diagnostics, maxDiagnostics, fourCC3) { + const keywordLimit = Math.min(maxStringBytes, 79); + const scanLength = Math.min(dataLength, keywordLimit + 1); + for (let index = 0; index < scanLength; index += 1) { + if (reader.u8(dataOffset + index) !== 0) { + continue; + } + if (index === 0) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "warning", + "PNG_INVALID_TEXT", + `${fourCC3} has an empty text keyword.`, + dataOffset + ) + ); + return void 0; + } + const characters = []; + for (let keywordIndex = 0; keywordIndex < index; keywordIndex += 1) { + characters.push(reader.u8(dataOffset + keywordIndex)); + } + return { + value: String.fromCharCode(...characters), + afterKeyword: dataOffset + index + 1 + }; + } + addDiagnostic2( + diagnostics, + maxDiagnostics, + dataLength > keywordLimit && keywordLimit === maxStringBytes ? diagnostic2( + "warning", + "PNG_TEXT_LIMIT_EXCEEDED", + `${fourCC3} keyword exceeds maxStringBytes ${String(maxStringBytes)}.`, + dataOffset + ) : diagnostic2( + "warning", + "PNG_INVALID_TEXT", + `${fourCC3} text keyword is not NUL-terminated.`, + dataOffset + ) + ); + return void 0; +} +function parsePng(reader, maxChunks, maxStringBytes, maxDiagnostics = DEFAULT_PARSE_LIMITS.maxDiagnostics) { + const diagnostics = []; + const chunks = []; + if (!reader.matches(0, PNG_SIGNATURE2)) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_INVALID_SIGNATURE", + "PNG input does not contain the complete eight-byte signature.", + 0 + ) + ); + return failure(diagnostics); + } + let offset = 8; + while (offset < reader.length) { + if (chunks.length >= maxChunks) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_CHUNK_LIMIT_EXCEEDED", + `PNG chunk count exceeds maxChunks ${String(maxChunks)}.`, + offset + ) + ); + return failure(diagnostics, chunks, offset); + } + const remaining = reader.length - offset; + if (remaining < 4) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_TRUNCATED_CHUNK_LENGTH", + "PNG input ends within a chunk length field.", + offset + ) + ); + return failure(diagnostics, chunks, offset); + } + if (remaining < 8) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_TRUNCATED_CHUNK_TYPE", + "PNG input ends within a chunk type field.", + offset + 4 + ) + ); + return failure(diagnostics, chunks, offset); + } + const dataLength = reader.u32BE(offset); + const typeOffset = offset + 4; + for (let index = 0; index < 4; index += 1) { + if (!isAsciiLetter(reader.u8(typeOffset + index))) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_INVALID_CHUNK_TYPE", + "PNG chunk types must contain four ASCII letters.", + typeOffset + ) + ); + return failure(diagnostics, chunks, offset); + } + } + const type = fourCC(reader, typeOffset); + const dataOffset = offset + 8; + const available = reader.length - dataOffset; + if (dataLength > available) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_TRUNCATED_CHUNK_DATA", + `${type} data extends beyond the supplied input.`, + offset + ) + ); + return failure(diagnostics, chunks, offset); + } + if (available - dataLength < 4) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_MISSING_CRC", + `${type} is missing its complete CRC field.`, + dataOffset + dataLength + ) + ); + return failure(diagnostics, chunks, offset); + } + const crcOffset = dataOffset + dataLength; + const totalLength = 12 + dataLength; + if (!Number.isSafeInteger(totalLength) || totalLength > remaining) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_TRUNCATED_CHUNK_DATA", + `${type} physical chunk range is invalid.`, + offset + ) + ); + return failure(diagnostics, chunks, offset); + } + const ancillary = (reader.u8(typeOffset) & 32) !== 0; + const classification = classifyChunk(type, ancillary); + let keyword; + let textCompressed; + if (type === "tEXt" || type === "zTXt" || type === "iTXt") { + const parsedKeyword = readKeyword( + reader, + dataOffset, + dataLength, + maxStringBytes, + diagnostics, + maxDiagnostics, + type + ); + keyword = parsedKeyword?.value; + if (type === "zTXt") { + textCompressed = true; + if (parsedKeyword !== void 0 && parsedKeyword.afterKeyword >= dataOffset + dataLength) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "warning", + "PNG_INVALID_TEXT", + "zTXt is missing its compression method byte.", + parsedKeyword.afterKeyword + ) + ); + } + } else if (type === "iTXt" && parsedKeyword !== void 0) { + if (dataOffset + dataLength - parsedKeyword.afterKeyword < 2) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "warning", + "PNG_INVALID_TEXT", + "iTXt is missing compression flag or method bytes.", + parsedKeyword.afterKeyword + ) + ); + } else { + const flag = reader.u8(parsedKeyword.afterKeyword); + textCompressed = flag === 1; + if (flag > 1) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "warning", + "PNG_INVALID_TEXT", + "iTXt compression flag must be zero or one.", + parsedKeyword.afterKeyword + ) + ); + } + } + } + } + const expectedCrc = reader.u32BE(crcOffset); + const actualCrc = pngCrc32(reader.slice(typeOffset, 4 + dataLength)); + const crcValid = expectedCrc === actualCrc; + if (!crcValid) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "warning", + "PNG_INVALID_CRC", + `${type} CRC does not match its type and data.`, + crcOffset + ) + ); + } + const chunk = { + fourCC: type, + offset, + dataOffset, + dataLength, + totalLength, + ancillary, + ...classification, + ...type === "iTXt" && keyword === XMP_KEYWORD ? { metadataKind: "xmp" } : {}, + ...keyword === void 0 ? {} : { keyword }, + ...textCompressed === void 0 ? {} : { textCompressed }, + crcValid + }; + chunks.push(chunk); + offset += totalLength; + if (type === "IEND") { + if (dataLength !== 0) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_INVALID_IEND", + "IEND must have an empty data field.", + chunk.dataOffset + ) + ); + return failure(diagnostics, chunks, offset); + } + if (offset < reader.length) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "warning", + "PNG_TRAILING_DATA", + `PNG contains ${String(reader.length - offset)} trailing byte(s) after IEND.`, + offset + ) + ); + } + return { + chunks, + complete: true, + sawIend: true, + containerLength: offset, + diagnostics + }; + } + } + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_MISSING_IEND", + "PNG input ends before an IEND chunk.", + reader.length + ) + ); + return failure(diagnostics, chunks, reader.length); +} + +// src/webp/metadata.ts +function inspectWebPMetadata(result, maxMetadataEntries) { + const entries = []; + let entryLimitExceeded = false; + for (const chunk of result.chunks) { + if (chunk.metadataKind === void 0) { + continue; + } + if (entries.length >= maxMetadataEntries) { + entryLimitExceeded = true; + continue; + } + const source3 = { + format: "webp", + container: "webp-chunk", + offset: chunk.offset, + length: chunk.totalLength, + chunkType: chunk.fourCC + }; + switch (chunk.metadataKind) { + case "exif": + entries.push({ + id: `webp-exif-${String(chunk.offset)}`, + namespace: "exif", + name: "WebP EXIF container", + category: "unknown", + privacy: "potentially-sensitive", + source: source3 + }); + break; + case "xmp": + entries.push({ + id: `webp-xmp-${String(chunk.offset)}`, + namespace: "xmp", + name: "WebP XMP container", + category: "unknown", + privacy: "potentially-sensitive", + source: source3 + }); + break; + case "icc": + entries.push({ + id: `webp-icc-${String(chunk.offset)}`, + namespace: "icc", + name: "WebP ICC profile container", + category: "color", + privacy: "non-sensitive", + source: source3 + }); + break; + } + } + return { entries, entryLimitExceeded }; +} + +// src/webp/chunks.ts +var WEBP_VP8X_FLAG = Object.freeze({ + icc: 32, + alpha: 16, + exif: 8, + xmp: 4, + animation: 2 +}); +var WEBP_VP8X_METADATA_MASK = WEBP_VP8X_FLAG.icc | WEBP_VP8X_FLAG.exif | WEBP_VP8X_FLAG.xmp; +function classifyWebPChunk(fourCC3) { + switch (fourCC3) { + case "VP8 ": + case "VP8L": + return { kind: "image" }; + case "ALPH": + return { kind: "alpha" }; + case "VP8X": + return { kind: "extended" }; + case "ANIM": + case "ANMF": + return { kind: "animation" }; + case "EXIF": + return { kind: "metadata", metadataKind: "exif" }; + case "XMP ": + return { kind: "metadata", metadataKind: "xmp" }; + case "ICCP": + return { kind: "metadata", metadataKind: "icc" }; + default: + return { kind: "unknown" }; + } +} + +// src/webp/parser.ts +var RIFF = [82, 73, 70, 70]; +var WEBP = [87, 69, 66, 80]; +function diagnostic3(severity, code, message, offset) { + return offset === void 0 ? { severity, code, message } : { severity, code, message, offset }; +} +function failure2(diagnostics, chunks = [], containerLength = 0) { + return { chunks, complete: false, containerLength, diagnostics }; +} +function fourCC2(reader, offset) { + return String.fromCharCode( + reader.u8(offset), + reader.u8(offset + 1), + reader.u8(offset + 2), + reader.u8(offset + 3) + ); +} +function parseWebP(reader, maxChunks, maxDiagnostics = DEFAULT_PARSE_LIMITS.maxDiagnostics) { + const diagnostics = []; + const chunks = []; + let hasStructuralError = false; + const addDiagnostic3 = (...items) => { + hasStructuralError ||= items.some(({ severity }) => severity === "error"); + const remaining = maxDiagnostics - diagnostics.length; + if (remaining > 0) { + diagnostics.push(...items.slice(0, remaining)); + } + }; + if (!reader.has(0, 12) || !reader.matches(0, RIFF) || !reader.matches(8, WEBP)) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_INVALID_RIFF_HEADER", + "WebP input requires a 12-byte RIFF....WEBP header.", + 0 + ) + ); + return failure2(diagnostics); + } + const declaredRiffSize = reader.u32LE(4); + const containerLength = declaredRiffSize + 8; + if (declaredRiffSize < 4 || !Number.isSafeInteger(containerLength) || containerLength < 12) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_INVALID_RIFF_SIZE", + "WebP RIFF size does not include the WEBP form type.", + 4 + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + if (containerLength > reader.length) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_TRUNCATED_RIFF", + "WebP RIFF size extends beyond the supplied input.", + 4 + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + if (containerLength < reader.length) { + addDiagnostic3( + diagnostic3( + "warning", + "WEBP_TRAILING_DATA", + `WebP contains ${String(reader.length - containerLength)} trailing byte(s) after the RIFF container.`, + containerLength + ) + ); + } + let offset = 12; + let vp8xCount = 0; + while (offset < containerLength) { + if (chunks.length >= maxChunks) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_CHUNK_LIMIT_EXCEEDED", + `WebP chunk count exceeds maxChunks ${String(maxChunks)}.`, + offset + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + if (containerLength - offset < 8) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_TRUNCATED_CHUNK_HEADER", + "WebP RIFF ends within a chunk header.", + offset + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + const type = fourCC2(reader, offset); + const payloadLength = reader.u32LE(offset + 4); + const payloadOffset = offset + 8; + const payloadEnd = payloadOffset + payloadLength; + if (!Number.isSafeInteger(payloadEnd) || payloadEnd > containerLength) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_TRUNCATED_CHUNK", + `${type} payload extends beyond the RIFF boundary.`, + offset + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + const padding = payloadLength % 2; + if (padding === 1 && payloadEnd === containerLength) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_INVALID_PADDING", + `${type} has an odd payload without its required padding byte.`, + payloadEnd + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + const totalLength = 8 + payloadLength + padding; + if (!Number.isSafeInteger(totalLength) || totalLength > containerLength - offset) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_TRUNCATED_CHUNK", + `${type} physical chunk range exceeds the RIFF boundary.`, + offset + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + const classification = classifyWebPChunk(type); + let vp8xFlags; + if (type === "VP8X") { + vp8xCount += 1; + if (vp8xCount > 1) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_DUPLICATE_VP8X", + "WebP contains more than one VP8X chunk.", + offset + ) + ); + } + if (payloadLength !== 10) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_INVALID_VP8X", + "VP8X payload must be exactly 10 bytes.", + offset + ) + ); + } else { + vp8xFlags = reader.u8(payloadOffset); + } + } + chunks.push({ + fourCC: type, + offset, + payloadOffset, + payloadLength, + totalLength, + ...classification, + ...vp8xFlags === void 0 ? {} : { vp8xFlags } + }); + offset += totalLength; + } + const vp8x = chunks.find(({ fourCC: type }) => type === "VP8X"); + if (!hasStructuralError && vp8x?.vp8xFlags !== void 0) { + const observedFlags = (chunks.some(({ metadataKind }) => metadataKind === "icc") ? WEBP_VP8X_FLAG.icc : 0) | (chunks.some(({ metadataKind }) => metadataKind === "exif") ? WEBP_VP8X_FLAG.exif : 0) | (chunks.some(({ metadataKind }) => metadataKind === "xmp") ? WEBP_VP8X_FLAG.xmp : 0); + if ((vp8x.vp8xFlags & WEBP_VP8X_METADATA_MASK) !== observedFlags) { + addDiagnostic3( + diagnostic3( + "warning", + "WEBP_INCONSISTENT_FEATURE_FLAGS", + "VP8X metadata flags do not match observed metadata chunks.", + vp8x.payloadOffset + ) + ); + } + } + return { + chunks, + complete: !hasStructuralError, + containerLength, + diagnostics + }; +} + +// src/inspect.ts +function resolveTiffLimits(limits, enabled) { + return { + maxIfdEntries: enabled ? resolveParseLimit("maxIfdEntries", limits?.maxIfdEntries) : DEFAULT_PARSE_LIMITS.maxIfdEntries, + maxIfdDepth: enabled ? resolveParseLimit("maxIfdDepth", limits?.maxIfdDepth) : DEFAULT_PARSE_LIMITS.maxIfdDepth, + maxMetadataEntries: enabled ? resolveParseLimit("maxMetadataEntries", limits?.maxMetadataEntries) : DEFAULT_PARSE_LIMITS.maxMetadataEntries, + maxStringBytes: enabled ? resolveParseLimit("maxStringBytes", limits?.maxStringBytes) : DEFAULT_PARSE_LIMITS.maxStringBytes, + maxDiagnostics: enabled ? resolveParseLimit("maxDiagnostics", limits?.maxDiagnostics) : DEFAULT_PARSE_LIMITS.maxDiagnostics + }; +} +function boundedDiagnostics(diagnostics, entryLimitExceeded, maxDiagnostics) { + const withEntryLimit = entryLimitExceeded ? [ + { + severity: "warning", + code: "METADATA_ENTRY_LIMIT_EXCEEDED", + message: "Metadata report exceeds the configured maxMetadataEntries limit." + }, + ...diagnostics + ] : diagnostics; + return withEntryLimit.slice(0, maxDiagnostics); +} +function inspectMetadata(input, options) { + const bytes = toUint8Array(input); + const maxInputBytes = resolveParseLimit( + "maxInputBytes", + options?.limits?.maxInputBytes + ); + if (bytes.byteLength > maxInputBytes) { + throw new InputLimitExceededError(bytes.byteLength, maxInputBytes); + } + const reader = new ByteReader(bytes); + const format = detectFormat(reader); + if (format === "jpeg") { + const maxMetadataEntries = resolveParseLimit( + "maxMetadataEntries", + options?.limits?.maxMetadataEntries + ); + const maxDiagnostics = resolveParseLimit( + "maxDiagnostics", + options?.limits?.maxDiagnostics + ); + const jpeg = parseJpeg( + reader, + resolveParseLimit("maxSegments", options?.limits?.maxSegments), + maxDiagnostics + ); + const hasExif = jpeg.segments.some( + ({ metadataKind }) => metadataKind === "exif" + ); + const metadata = inspectJpegMetadata( + reader, + jpeg, + resolveTiffLimits(options?.limits, hasExif), + maxMetadataEntries + ); + return { + format, + size: bytes.byteLength, + inspectionStatus: !jpeg.complete ? "container-partial" : metadata.attemptedExifDecode ? "metadata-partial" : "container-inspected", + entries: metadata.entries, + ...metadata.entryLimitExceeded ? { metadataTruncated: true } : {}, + diagnostics: boundedDiagnostics( + [...jpeg.diagnostics, ...metadata.diagnostics], + metadata.entryLimitExceeded, + maxDiagnostics + ) + }; + } + if (format === "webp") { + const maxMetadataEntries = resolveParseLimit( + "maxMetadataEntries", + options?.limits?.maxMetadataEntries + ); + const maxDiagnostics = resolveParseLimit( + "maxDiagnostics", + options?.limits?.maxDiagnostics + ); + const webp = parseWebP( + reader, + resolveParseLimit("maxChunks", options?.limits?.maxChunks), + maxDiagnostics + ); + const metadata = inspectWebPMetadata(webp, maxMetadataEntries); + return { + format, + size: bytes.byteLength, + inspectionStatus: webp.complete ? "container-inspected" : "container-partial", + entries: metadata.entries, + ...metadata.entryLimitExceeded ? { metadataTruncated: true } : {}, + diagnostics: boundedDiagnostics( + webp.diagnostics, + metadata.entryLimitExceeded, + maxDiagnostics + ) + }; + } + if (format === "png") { + const maxMetadataEntries = resolveParseLimit( + "maxMetadataEntries", + options?.limits?.maxMetadataEntries + ); + const maxDiagnostics = resolveParseLimit( + "maxDiagnostics", + options?.limits?.maxDiagnostics + ); + const png = parsePng( + reader, + resolveParseLimit("maxChunks", options?.limits?.maxChunks), + resolveParseLimit("maxStringBytes", options?.limits?.maxStringBytes), + maxDiagnostics + ); + const hasExif = png.chunks.some( + ({ metadataKind }) => metadataKind === "exif" + ); + const metadata = inspectPngMetadata( + reader, + png, + resolveTiffLimits(options?.limits, hasExif), + maxMetadataEntries + ); + return { + format, + size: bytes.byteLength, + inspectionStatus: !png.complete ? "container-partial" : metadata.attemptedExifDecode ? "metadata-partial" : "container-inspected", + entries: metadata.entries, + ...metadata.entryLimitExceeded ? { metadataTruncated: true } : {}, + diagnostics: boundedDiagnostics( + [...png.diagnostics, ...metadata.diagnostics], + metadata.entryLimitExceeded, + maxDiagnostics + ) + }; + } + return { + format, + size: bytes.byteLength, + inspectionStatus: "format-only", + entries: [], + diagnostics: [] + }; +} + +// src/policy/normalize.ts +var DEFAULT_CLEANING_POLICY = Object.freeze({ + removeExif: true, + removeXmp: true, + removeIptc: true, + removeComments: true, + removeTextMetadata: true, + removeTimestamps: true, + preserveIcc: true +}); +function normalizeCleaningPolicy(policy) { + return Object.freeze({ + removeExif: policy?.removeExif ?? DEFAULT_CLEANING_POLICY.removeExif, + removeXmp: policy?.removeXmp ?? DEFAULT_CLEANING_POLICY.removeXmp, + removeIptc: policy?.removeIptc ?? DEFAULT_CLEANING_POLICY.removeIptc, + removeComments: policy?.removeComments ?? DEFAULT_CLEANING_POLICY.removeComments, + removeTextMetadata: policy?.removeTextMetadata ?? DEFAULT_CLEANING_POLICY.removeTextMetadata, + removeTimestamps: policy?.removeTimestamps ?? DEFAULT_CLEANING_POLICY.removeTimestamps, + preserveIcc: policy?.preserveIcc ?? policy?.preserveColorProfiles ?? DEFAULT_CLEANING_POLICY.preserveIcc + }); +} + +// src/png/clean.ts +var DEFAULT_PNG_CLEANING_POLICY = DEFAULT_CLEANING_POLICY; +function shouldRemove(chunk, policy) { + switch (chunk.metadataKind) { + case "exif": + return policy.removeExif; + case "xmp": + return policy.removeXmp; + case "text": + return policy.removeTextMetadata; + case "timestamp": + return policy.removeTimestamps; + case "icc": + return !policy.preserveIcc; + default: + return false; + } +} +function changeFor(chunk, action) { + let namespace = "unknown"; + let name = `Unknown ${chunk.fourCC} chunk`; + switch (chunk.metadataKind) { + case "exif": + namespace = "exif"; + name = "PNG EXIF container"; + break; + case "xmp": + namespace = "xmp"; + name = "PNG XMP iTXt container"; + break; + case "text": + namespace = "png-text"; + name = `${chunk.fourCC} metadata`; + break; + case "timestamp": + namespace = "png-time"; + name = "PNG modification time"; + break; + case "icc": + namespace = "icc"; + name = "PNG ICC profile container"; + break; + } + return { + namespace, + action, + name, + source: { + format: "png", + container: "png-chunk", + offset: chunk.offset, + length: chunk.totalLength, + chunkType: chunk.fourCC + } + }; +} +function outputError(message) { + return new SecureMetadataError(message, "CLEAN_OUTPUT_SIZE_INVALID"); +} +function cleanPng(bytes, policy) { + const parsed = parsePng( + new ByteReader(bytes), + resolveParseLimit("maxChunks", policy?.limits?.maxChunks), + resolveParseLimit("maxStringBytes", policy?.limits?.maxStringBytes), + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics) + ); + if (!parsed.complete) { + throw new IncompletePngError( + "cleanMetadata", + parsed.diagnostics.slice( + 0, + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics) + ) + ); + } + const resolved = normalizeCleaningPolicy(policy); + const removals = parsed.chunks.filter( + (chunk) => shouldRemove(chunk, resolved) + ); + const retained = parsed.chunks.filter( + (chunk) => !shouldRemove(chunk, resolved) + ); + let containerLength = 8; + for (const chunk of retained) { + containerLength += chunk.totalLength; + if (!Number.isSafeInteger(containerLength) || containerLength > parsed.containerLength) { + throw outputError("PNG cleaner output container size is invalid."); + } + } + const trailingLength = bytes.byteLength - parsed.containerLength; + const outputLength = containerLength + trailingLength; + if (!Number.isSafeInteger(outputLength) || outputLength < 8 || outputLength > bytes.byteLength) { + throw outputError("PNG cleaner output size is invalid."); + } + const output = new Uint8Array(outputLength); + output.set(bytes.subarray(0, 8)); + let outputOffset = 8; + for (const chunk of retained) { + output.set( + bytes.subarray(chunk.offset, chunk.offset + chunk.totalLength), + outputOffset + ); + outputOffset += chunk.totalLength; + } + output.set(bytes.subarray(parsed.containerLength), outputOffset); + const report = inspectMetadata( + output, + policy?.limits === void 0 ? void 0 : { limits: policy.limits } + ); + if (report.inspectionStatus === "container-partial") { + throw new IncompletePngError("cleanMetadata", report.diagnostics); + } + return { + output, + format: "png", + report, + removed: removals.map((chunk) => changeFor(chunk, "removed")), + preserved: retained.filter( + ({ kind, metadataKind }) => kind === "unknown" || metadataKind !== void 0 + ).map((chunk) => changeFor(chunk, "preserved")), + diagnostics: report.diagnostics + }; +} + +// src/webp/clean.ts +var DEFAULT_WEBP_CLEANING_POLICY = DEFAULT_CLEANING_POLICY; +function shouldRemove2(chunk, policy) { + switch (chunk.metadataKind) { + case "exif": + return policy.removeExif; + case "xmp": + return policy.removeXmp; + case "icc": + return !policy.preserveIcc; + default: + return false; + } +} +function changeFor2(chunk, action) { + let namespace = "unknown"; + let name = `Unknown ${chunk.fourCC} chunk`; + if (chunk.metadataKind !== void 0) { + namespace = chunk.metadataKind; + name = chunk.metadataKind === "icc" ? "WebP ICC profile container" : `WebP ${chunk.metadataKind.toUpperCase()} container`; + } + return { + namespace, + action, + name, + source: { + format: "webp", + container: "webp-chunk", + offset: chunk.offset, + length: chunk.totalLength, + chunkType: chunk.fourCC + } + }; +} +function outputError2(message) { + return new SecureMetadataError(message, "CLEAN_OUTPUT_SIZE_INVALID"); +} +function cleanWebP(bytes, policy) { + const parsed = parseWebP( + new ByteReader(bytes), + resolveParseLimit("maxChunks", policy?.limits?.maxChunks), + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics) + ); + if (!parsed.complete) { + throw new IncompleteWebPError( + "cleanMetadata", + parsed.diagnostics.slice( + 0, + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics) + ) + ); + } + const resolved = normalizeCleaningPolicy(policy); + const removals = parsed.chunks.filter( + (chunk) => shouldRemove2(chunk, resolved) + ); + const retained = parsed.chunks.filter( + (chunk) => !shouldRemove2(chunk, resolved) + ); + let containerLength = 12; + for (const chunk of retained) { + containerLength += chunk.totalLength; + if (!Number.isSafeInteger(containerLength) || containerLength > parsed.containerLength) { + throw outputError2("WebP cleaner output RIFF size is invalid."); + } + } + const trailingLength = bytes.byteLength - parsed.containerLength; + const outputLength = containerLength + trailingLength; + if (!Number.isSafeInteger(outputLength) || outputLength < 12 || outputLength > bytes.byteLength) { + throw outputError2("WebP cleaner output size is invalid."); + } + const hasIcc = retained.some(({ metadataKind }) => metadataKind === "icc"); + const hasExif = retained.some(({ metadataKind }) => metadataKind === "exif"); + const hasXmp = retained.some(({ metadataKind }) => metadataKind === "xmp"); + const metadataFlags = (hasIcc ? WEBP_VP8X_FLAG.icc : 0) | (hasExif ? WEBP_VP8X_FLAG.exif : 0) | (hasXmp ? WEBP_VP8X_FLAG.xmp : 0); + const output = new Uint8Array(outputLength); + output.set(bytes.subarray(0, 12)); + new DataView(output.buffer).setUint32(4, containerLength - 8, true); + let outputOffset = 12; + for (const chunk of retained) { + output.set( + bytes.subarray(chunk.offset, chunk.offset + chunk.totalLength), + outputOffset + ); + if (chunk.vp8xFlags !== void 0) { + output[outputOffset + 8] = chunk.vp8xFlags & ~WEBP_VP8X_METADATA_MASK | metadataFlags; + } + outputOffset += chunk.totalLength; + } + output.set(bytes.subarray(parsed.containerLength), outputOffset); + const report = inspectMetadata( + output, + policy?.limits === void 0 ? void 0 : { limits: policy.limits } + ); + if (report.inspectionStatus === "container-partial") { + throw new IncompleteWebPError("cleanMetadata", report.diagnostics); + } + return { + output, + format: "webp", + report, + removed: removals.map((chunk) => changeFor2(chunk, "removed")), + preserved: retained.filter( + ({ kind, metadataKind }) => kind === "unknown" || metadataKind !== void 0 + ).map((chunk) => changeFor2(chunk, "preserved")), + diagnostics: report.diagnostics + }; +} + +// src/policy/clean.ts +var DEFAULT_JPEG_CLEANING_POLICY = DEFAULT_CLEANING_POLICY; +function shouldRemove3(segment, policy) { + if (segment.kind === "comment") { + return policy.removeComments; + } + switch (segment.metadataKind) { + case "exif": + return policy.removeExif; + case "xmp": + return policy.removeXmp; + case "iptc": + return policy.removeIptc; + case "icc": + return !policy.preserveIcc; + default: + return false; + } +} +function changeFor3(segment, action) { + let namespace = "container"; + let name = segment.markerName; + if (segment.kind === "comment") { + namespace = "jpeg-comment"; + name = "JPEG comment"; + } else { + switch (segment.metadataKind) { + case "exif": + namespace = "exif"; + name = "EXIF container"; + break; + case "xmp": + namespace = "xmp"; + name = segment.metadataSubtype === "extended-xmp" ? "Extended XMP container" : "XMP container"; + break; + case "iptc": + namespace = "iptc"; + name = "Photoshop/IPTC container"; + break; + case "icc": + namespace = "icc"; + name = "ICC profile container"; + break; + case "jfif": + name = segment.metadataSubtype === "jfxx" ? "JFXX application segment" : "JFIF application segment"; + break; + case "adobe": + name = "Adobe application segment"; + break; + case "unknown": + namespace = "unknown"; + name = `Unknown ${segment.markerName} application segment`; + break; + } + } + return { + namespace, + action, + name, + source: { + format: "jpeg", + container: "jpeg-segment", + offset: segment.offset, + length: segment.length, + jpegMarker: segment.marker + } + }; +} +function copyWithoutSegments(input, removals) { + const retained = []; + let inputOffset = 0; + let outputLength = 0; + for (const segment of removals) { + const end = segment.rangeOffset + segment.rangeLength; + if (!Number.isSafeInteger(segment.rangeOffset) || !Number.isSafeInteger(segment.rangeLength) || segment.rangeLength <= 0 || !Number.isSafeInteger(end) || segment.rangeOffset < inputOffset || end > input.byteLength) { + throw new SecureMetadataError( + "JPEG cleaner produced an invalid removal range.", + "CLEAN_OUTPUT_SIZE_INVALID" + ); + } + const length = segment.rangeOffset - inputOffset; + retained.push({ offset: inputOffset, length }); + outputLength += length; + if (!Number.isSafeInteger(outputLength) || outputLength > input.byteLength) { + throw new SecureMetadataError( + "JPEG cleaner output size is invalid.", + "CLEAN_OUTPUT_SIZE_INVALID" + ); + } + inputOffset = end; + } + const tailLength = input.byteLength - inputOffset; + retained.push({ offset: inputOffset, length: tailLength }); + outputLength += tailLength; + if (!Number.isSafeInteger(outputLength) || outputLength < 0 || outputLength > input.byteLength) { + throw new SecureMetadataError( + "JPEG cleaner output size is invalid.", + "CLEAN_OUTPUT_SIZE_INVALID" + ); + } + const output = new Uint8Array(outputLength); + let outputOffset = 0; + for (const range of retained) { + output.set( + input.subarray(range.offset, range.offset + range.length), + outputOffset + ); + outputOffset += range.length; + } + return output; +} +function cleanMetadata(input, policy) { + const bytes = toUint8Array(input); + const maxInputBytes = resolveParseLimit( + "maxInputBytes", + policy?.limits?.maxInputBytes + ); + if (bytes.byteLength > maxInputBytes) { + throw new InputLimitExceededError(bytes.byteLength, maxInputBytes); + } + const reader = new ByteReader(bytes); + const format = detectFormat(reader); + if (format === "png") { + return cleanPng(bytes, policy); + } + if (format === "webp") { + return cleanWebP(bytes, policy); + } + if (format !== "jpeg") { + throw new UnsupportedFormatError("cleanMetadata", format); + } + const jpeg = parseJpeg( + reader, + resolveParseLimit("maxSegments", policy?.limits?.maxSegments), + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics) + ); + if (!jpeg.complete) { + throw new IncompleteJpegError( + "cleanMetadata", + jpeg.diagnostics.slice( + 0, + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics) + ) + ); + } + const resolved = normalizeCleaningPolicy(policy); + const removals = jpeg.segments.filter( + (segment) => shouldRemove3(segment, resolved) + ); + const removed = removals.map((segment) => changeFor3(segment, "removed")); + const preserved = jpeg.segments.filter( + (segment) => (segment.kind === "application" || segment.kind === "comment") && !shouldRemove3(segment, resolved) + ).map((segment) => changeFor3(segment, "preserved")); + const output = copyWithoutSegments(bytes, removals); + const report = inspectMetadata( + output, + policy?.limits === void 0 ? void 0 : { limits: policy.limits } + ); + if (report.inspectionStatus === "container-partial") { + throw new IncompleteJpegError("cleanMetadata", report.diagnostics); + } + return { + output, + format: "jpeg", + report, + removed, + preserved, + diagnostics: report.diagnostics + }; +} + +// src/verify/verify.ts +var DEFAULT_JPEG_VERIFICATION_POLICY = Object.freeze({ + exif: "absent", + xmp: "absent", + iptc: "absent", + comments: "absent", + icc: "ignore" +}); +var DEFAULT_WEBP_VERIFICATION_POLICY = Object.freeze({ + exif: "absent", + xmp: "absent", + icc: "ignore" +}); +var DEFAULT_PNG_VERIFICATION_POLICY = Object.freeze({ + exif: "absent", + xmp: "absent", + textMetadata: "absent", + timestamps: "absent", + icc: "ignore" +}); +function verifyMetadata(input, expectation) { + const report = inspectMetadata( + input, + expectation?.limits === void 0 ? void 0 : { limits: expectation.limits } + ); + if (report.format === "jpeg") { + if (report.inspectionStatus === "container-partial") { + throw new IncompleteJpegError("verifyMetadata", report.diagnostics); + } + } else if (report.format === "webp") { + if (report.inspectionStatus === "container-partial") { + throw new IncompleteWebPError("verifyMetadata", report.diagnostics); + } + } else if (report.format === "png") { + if (report.inspectionStatus === "container-partial") { + throw new IncompletePngError("verifyMetadata", report.diagnostics); + } + } else { + throw new UnsupportedFormatError("verifyMetadata", report.format); + } + if (report.metadataTruncated === true) { + return { + valid: false, + checks: [], + report, + diagnostics: report.diagnostics + }; + } + const privacyDefault = expectation?.requireNoPrivacyRelevantMetadata === false ? "ignore" : "absent"; + let expected; + if (report.format === "jpeg") { + expected = { + exif: expectation?.exif ?? privacyDefault, + xmp: expectation?.xmp ?? privacyDefault, + iptc: expectation?.iptc ?? privacyDefault, + "jpeg-comment": expectation?.comments ?? privacyDefault, + icc: expectation?.icc ?? "ignore" + }; + } else if (report.format === "webp") { + expected = { + exif: expectation?.exif ?? privacyDefault, + xmp: expectation?.xmp ?? privacyDefault, + icc: expectation?.icc ?? "ignore" + }; + } else { + expected = { + exif: expectation?.exif ?? privacyDefault, + xmp: expectation?.xmp ?? privacyDefault, + "png-text": expectation?.textMetadata ?? privacyDefault, + "png-time": expectation?.timestamps ?? privacyDefault, + icc: expectation?.icc ?? "ignore" + }; + } + const checks = []; + for (const [namespace, wanted] of Object.entries(expected)) { + if (wanted === "ignore") { + continue; + } + const present = report.entries.some( + (entry) => entry.namespace === namespace + ); + const actual = present ? "present" : "absent"; + checks.push({ + namespace, + expected: wanted, + actual, + passed: actual === wanted + }); + } + return { + valid: checks.every(({ passed }) => passed), + checks, + report, + diagnostics: report.diagnostics + }; +} +export { + BinaryBoundsError, + DEFAULT_CLEANING_POLICY, + DEFAULT_JPEG_CLEANING_POLICY, + DEFAULT_JPEG_VERIFICATION_POLICY, + DEFAULT_PARSE_LIMITS, + DEFAULT_PNG_CLEANING_POLICY, + DEFAULT_PNG_VERIFICATION_POLICY, + DEFAULT_WEBP_CLEANING_POLICY, + DEFAULT_WEBP_VERIFICATION_POLICY, + IncompleteJpegError, + IncompletePngError, + IncompleteWebPError, + InputLimitExceededError, + InvalidParseLimitError, + SecureMetadataError, + UnsupportedFormatError, + cleanMetadata, + inspectMetadata, + verifyMetadata +}; +//# sourceMappingURL=secure-metadata.js.map \ No newline at end of file diff --git a/docs/image-metadata-privacy.md b/docs/image-metadata-privacy.md new file mode 100644 index 0000000..e2874e9 --- /dev/null +++ b/docs/image-metadata-privacy.md @@ -0,0 +1,21 @@ +# Image Metadata privacy and verification + +The Image Metadata Inspector & Cleaner at `/tools/image/metadata/` processes one signature-validated JPEG, PNG, or WebP file in browser memory. The application enforces its existing 50 MiB per-image limit before reading the full file. It does not upload the image, decode pixels, use Canvas, resize, convert, or re-encode it. + +Inspection reports only structures supported by `secure-metadata v0.1.0`. Decoded values and opaque detected containers are presented differently. A `metadata-partial` result is a successful but non-exhaustive inspection; it is not evidence that every possible metadata structure was decoded. “No supported metadata detected” does not mean that the image contains no metadata or hidden information. + +Privacy Clean uses the library’s exported `DEFAULT_CLEANING_POLICY` directly. It removes supported EXIF, XMP, IPTC, comments, ordinary PNG text metadata, and standalone timestamps while preserving ICC color profiles. Unknown structures are not guessed away. The original source bytes remain unchanged. + +The produced bytes are passed to `verifyMetadata` before any write or download. Every returned policy check must pass, the verification result must be valid, and inspection of the result must not be partial or truncated. Otherwise the operation fails closed and no output bytes are saved. This verifies only the metadata categories targeted by the supported policy; it does not establish anonymity, complete privacy, provenance, pixel privacy, steganography detection, or malware safety. + +## Pinned dependency + +- Library: `secure-metadata` +- Version/tag: `v0.1.0` +- Release commit: `352258ec413a838dfe8b9146370505f125b5ae10` +- Browser artifact: `secure-metadata-0.1.0.browser.js` +- SHA-256: `8d0b8a1addf904760aa1f52378fb05eed6540520cb05fe2320d77011cba69c28` +- License: MIT +- Runtime dependencies: 0 + +The immutable GitHub Release artifact was verified against its published checksum manifest and is served from `assets/vendor/secure-metadata/`. Secure Tools does not install or load the npm package at runtime, use a CDN, contact GitHub for processing, or check automatically for updates. Replacement requires a new explicit provenance and hash review. diff --git a/js/i18n.js b/js/i18n.js index 7a27a2c..eb8bc91 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -6,14 +6,18 @@ import { de } from "./locales/de.js"; import { fr } from "./locales/fr.js"; import { imageResizeLocales } from "./locales/image-resize.js"; import { imageCompressorLocales } from "./locales/image-compressor.js"; +import { imageMetadataLocales } from "./locales/image-metadata.js"; const STORAGE_KEY = "secure-tools-language"; const baseTranslations = { en, ko, ja, es, de, fr }; export const translations = Object.fromEntries(Object.entries(baseTranslations).map(([language, catalog]) => [language, { ...catalog, - metadata: { ...catalog.metadata, imageResize: imageResizeLocales[language].metadata, imageCompressor: imageCompressorLocales[language].metadata }, + metadata: { ...catalog.metadata, imageResize: imageResizeLocales[language].metadata, imageCompressor: imageCompressorLocales[language].metadata, imageMetadata: imageMetadataLocales[language].metadata }, + tools: { ...catalog.tools, imageMetadata: imageMetadataLocales[language].toolName }, + categories: { ...catalog.categories, image: { ...catalog.categories.image, metadata: imageMetadataLocales[language].categoryDescription } }, imageResize: imageResizeLocales[language].copy, imageCompressor: imageCompressorLocales[language].copy, + imageMetadata: imageMetadataLocales[language].copy, }])); export function resolveLanguage(value, availableLanguages = Object.keys(translations)) { diff --git a/js/locales/de.js b/js/locales/de.js index 351e134..e7e6896 100644 --- a/js/locales/de.js +++ b/js/locales/de.js @@ -122,6 +122,7 @@ export const de = { "imageConverter": "Bildkonverter", "imageCompressor": "Bildkompressor", "imageResizer": "Bild-Resizer", + "imageMetadata": "Bildmetadaten-Prüfer & Bereiniger", "metadataInspector": "Metadaten-Inspektor", "metadataCleaner": "Metadaten-Reiniger", "available": "Verfügbar", @@ -153,7 +154,8 @@ export const de = { "description": "Bereiten Sie alltägliche Bilder privat in Ihrem Browser vor.", "convert": "Konvertieren Sie gängige Bildformate lokal.", "compress": "Reduzieren Sie die Dateigröße mit klaren Qualitätskontrollen.", - "resize": "Bilder auf exakte Pixelabmessungen skalieren." + "resize": "Bilder auf exakte Pixelabmessungen skalieren.", + "metadata": "Datenschutzrelevante Metadaten prüfen und eine verifizierte Kopie speichern." }, "privacy": { "title": "Datenschutz-Tools", diff --git a/js/locales/en.js b/js/locales/en.js index 5c39a47..392edfd 100644 --- a/js/locales/en.js +++ b/js/locales/en.js @@ -36,7 +36,7 @@ export const en = { categoryDescriptions: { pdf: "Create, combine, and organize documents.", image: "Convert and prepare everyday images.", privacy: "Inspect and clean hidden file details.", scan: "Turn scans into useful documents.", media: "Work with audio and video locally." }, browseCategory: "Browse category →", imagesToPdf: "Images to PDF", mergePdf: "Merge PDF", splitPdf: "Split PDF", organizePdf: "Organize PDF", pdfToImages: "PDF to Images", pdfMetadata: "PDF Metadata", imageConverter: "Image Converter", - imageCompressor: "Image Compressor", imageResizer: "Image Resizer", metadataInspector: "Metadata Inspector", metadataCleaner: "Metadata Cleaner", + imageCompressor: "Image Compressor", imageResizer: "Image Resizer", imageMetadata: "Image Metadata Inspector & Cleaner", metadataInspector: "Metadata Inspector", metadataCleaner: "Metadata Cleaner", available: "Available", comingSoon: "Coming soon", }, categories: { @@ -54,7 +54,7 @@ export const en = { }, image: { title: "Image tools", description: "Prepare everyday images privately in your browser.", - convert: "Convert common image formats locally.", compress: "Reduce file size with clear quality controls.", resize: "Resize images to exact pixel dimensions.", + convert: "Convert common image formats locally.", compress: "Reduce file size with clear quality controls.", resize: "Resize images to exact pixel dimensions.", metadata: "Inspect privacy-relevant metadata and save a verified cleaned copy.", }, privacy: { title: "Privacy tools", description: "Inspect and remove hidden file details without sharing the source file.", diff --git a/js/locales/es.js b/js/locales/es.js index 3d50a9e..16ed51d 100644 --- a/js/locales/es.js +++ b/js/locales/es.js @@ -122,6 +122,7 @@ export const es = { "imageConverter": "Convertidor de imágenes", "imageCompressor": "Compresor de imagen", "imageResizer": "Cambio de tamaño de imagen", + "imageMetadata": "Inspector y limpiador de metadatos", "metadataInspector": "Inspector de metadatos", "metadataCleaner": "Limpiador de metadatos", "available": "Disponible", @@ -153,7 +154,8 @@ export const es = { "description": "Prepare imágenes cotidianas de forma privada en su navegador.", "convert": "Convierte formatos de imagen comunes localmente.", "compress": "Reduzca el tamaño del archivo con controles de calidad claros.", - "resize": "Cambia el tamaño de las imágenes a las dimensiones exactas en píxeles." + "resize": "Cambia el tamaño de las imágenes a las dimensiones exactas en píxeles.", + "metadata": "Revisa metadatos de privacidad y guarda una copia verificada." }, "privacy": { "title": "Herramientas de privacidad", diff --git a/js/locales/fr.js b/js/locales/fr.js index 3e9334b..e8b8c4d 100644 --- a/js/locales/fr.js +++ b/js/locales/fr.js @@ -122,6 +122,7 @@ export const fr = { "imageConverter": "Convertisseur d'images", "imageCompressor": "Compresseur d'images", "imageResizer": "Redimensionneur d'image", + "imageMetadata": "Inspecteur et nettoyeur de métadonnées", "metadataInspector": "Inspecteur de métadonnées", "metadataCleaner": "Nettoyeur de métadonnées", "available": "Disponible", @@ -153,7 +154,8 @@ export const fr = { "description": "Préparez des images de tous les jours en privé dans votre navigateur.", "convert": "Convertissez localement les formats d'image courants.", "compress": "Réduisez la taille des fichiers avec des contrôles de qualité clairs.", - "resize": "Redimensionnez les images aux dimensions exactes en pixels." + "resize": "Redimensionnez les images aux dimensions exactes en pixels.", + "metadata": "Examinez les métadonnées privées et enregistrez une copie vérifiée." }, "privacy": { "title": "Outils de confidentialité", diff --git a/js/locales/image-metadata.js b/js/locales/image-metadata.js new file mode 100644 index 0000000..e8b9776 --- /dev/null +++ b/js/locales/image-metadata.js @@ -0,0 +1,62 @@ +const shared = { + groups: { location: "Location", device: "Device & camera", time: "Dates & time", technical: "Image & EXIF", software: "Software", author: "Author & identity", rights: "Copyright & rights", descriptive: "Description & text", xmp: "XMP", iptc: "IPTC", color: "Color profile", rendering: "Rendering", other: "Other & unknown" }, + values: { opaque: "Detected, payload not decoded", bytes: "Binary payload ({count} bytes)", true: "True", false: "False", unknown: "Unknown metadata" }, + coverage: { "container-inspected": "Container inspection completed for the supported structures.", "metadata-inspected": "Metadata inspection completed for the supported structures.", "metadata-partial": "Inspection succeeded, but metadata coverage is partial and non-exhaustive.", "container-partial": "The image container is incomplete. Cleaning is disabled because safe verification is not possible." }, +}; + +export const imageMetadataLocales = { + en: { + metadata: { title: "Image Metadata Inspector & Cleaner — Secure Tools", description: "Inspect and remove JPEG, PNG, and WebP metadata locally in your browser." }, + toolName: "Image Metadata Inspector & Cleaner", categoryDescription: "Inspect privacy-relevant metadata and save a verified cleaned copy.", + copy: { ...shared, eyebrow: "Image privacy tool", title: "Image Metadata Inspector & Cleaner", description: "Inspect metadata in one JPEG, PNG, or WebP image, then create a verified cleaned copy without uploading it.", scope: { title: "Honest inspection scope", body: "The tool reports decoded metadata plus opaque containers it can identify. “Partial” means a successful but non-exhaustive inspection, not that no metadata exists." }, drop: { title: "Add one image", description: "Drop a JPEG, PNG, or WebP image here or use the picker. Choosing another file replaces the current source.", choose: "Choose image", localTitle: "Processed locally.", localBody: "Your image never leaves this device.", privacyLink: "How privacy works" }, source: { title: "Source image", empty: "No image selected yet.", summary: "{name} · {size} · {format} · {count} metadata entries" }, inspector: { title: "Detected metadata", empty: "No metadata entries were decoded or identified in the supported scope.", namespace: "Namespace: {namespace}", source: "Structure: {source}", opaque: "Opaque container", truncated: "Display shortened for safety.", diagnostics: "Parser diagnostics" }, coverage: { "container-inspected": "Container inspection completed for the supported structures.", "metadata-inspected": "Metadata inspection completed for the supported structures.", "metadata-partial": "Inspection succeeded, but this format’s metadata coverage is partial and non-exhaustive.", "container-partial": "The image container is incomplete. Cleaning is disabled because safe verification is not possible." }, clean: { title: "Privacy Clean", description: "Creates a new file using the library’s authoritative privacy policy. Pixels are not decoded or re-encoded; the original stays unchanged.", policy: "Removes supported EXIF, XMP, IPTC, comments, text metadata, and timestamps. ICC color profiles are preserved.", button: "Privacy Clean and save", imageFile: "Cleaned image" }, result: { title: "Verified clean result", summary: "Verified {count} policy checks before saving {name}.", removed: "Removed", preserved: "Preserved", none: "None reported", diagnostics: "Cleaning diagnostics" }, actions: { clear: "Clear image" }, status: { reading: "Inspecting metadata locally…", loaded: "Inspection complete: {count} entries found.", noneFound: "Inspection complete. No supported metadata entries were found.", cleaning: "Cleaning and verifying metadata locally…", saved: "Verified and saved {name}.", downloaded: "Verified and downloaded {name}.", cancelled: "Save cancelled. The source image remains loaded.", cleared: "Source image cleared." }, errors: { oneFile: "Choose exactly one image.", noFile: "Choose a non-empty image.", tooLarge: "The image exceeds the 50 MiB limit.", signature: "The file signature is not a supported JPEG, PNG, or WebP image.", unsupported: "This image metadata structure is unsupported.", incomplete: "The image container is incomplete or damaged, so it cannot be cleaned safely.", limit: "Metadata inspection exceeded a safety limit.", inspection: "Metadata inspection failed. The image may be damaged or unsupported.", notCleanable: "This inspection is incomplete, so cleaning is disabled.", cleaning: "Metadata cleaning failed. No output was saved.", verification: "The cleaned copy did not pass every verification check. No output was saved.", save: "The verified copy could not be saved." } }, + }, + ko: { + metadata: { title: "이미지 메타데이터 검사 및 정리 — Secure Tools", description: "JPEG, PNG, WebP 메타데이터를 브라우저에서 로컬로 검사하고 제거합니다." }, toolName: "이미지 메타데이터 검사 및 정리", categoryDescription: "개인정보 관련 메타데이터를 확인하고 검증된 정리본을 저장합니다.", + copy: { ...shared, eyebrow: "이미지 개인정보 도구", title: "이미지 메타데이터 검사 및 정리", description: "JPEG, PNG 또는 WebP 이미지 한 개의 메타데이터를 확인하고 업로드 없이 검증된 정리본을 만듭니다.", scope: { title: "정직한 검사 범위", body: "해석된 메타데이터와 식별 가능한 불투명 컨테이너를 표시합니다. ‘부분적’은 검사가 성공했지만 모든 항목을 다룬다는 뜻은 아닙니다." }, drop: { title: "이미지 한 개 추가", description: "JPEG, PNG 또는 WebP를 놓거나 선택하세요. 다른 파일을 고르면 현재 원본이 교체됩니다.", choose: "이미지 선택", localTitle: "로컬에서 처리됩니다.", localBody: "이미지는 이 기기를 떠나지 않습니다.", privacyLink: "개인정보 보호 방식" }, source: { title: "원본 이미지", empty: "선택한 이미지가 없습니다.", summary: "{name} · {size} · {format} · 메타데이터 {count}개" }, inspector: { title: "감지된 메타데이터", empty: "지원 범위에서 해석되거나 식별된 메타데이터가 없습니다.", namespace: "네임스페이스: {namespace}", source: "구조: {source}", opaque: "불투명 컨테이너", truncated: "안전을 위해 표시를 줄였습니다.", diagnostics: "파서 진단" }, coverage: { "container-inspected": "지원 구조의 컨테이너 검사가 완료되었습니다.", "metadata-inspected": "지원 구조의 메타데이터 검사가 완료되었습니다.", "metadata-partial": "검사는 성공했지만 이 형식의 메타데이터 범위는 부분적이며 완전하지 않습니다.", "container-partial": "이미지 컨테이너가 불완전합니다. 안전한 검증이 불가능해 정리를 비활성화했습니다." }, clean: { title: "개인정보 정리", description: "라이브러리의 공식 개인정보 정책으로 새 파일을 만듭니다. 픽셀을 디코딩하거나 재인코딩하지 않으며 원본은 유지됩니다.", policy: "지원되는 EXIF, XMP, IPTC, 주석, 텍스트 메타데이터와 타임스탬프를 제거하고 ICC 색상 프로필은 보존합니다.", button: "개인정보 정리 후 저장", imageFile: "정리된 이미지" }, result: { title: "검증된 정리 결과", summary: "{name} 저장 전에 정책 검사 {count}개를 확인했습니다.", removed: "제거됨", preserved: "보존됨", none: "보고된 항목 없음", diagnostics: "정리 진단" }, actions: { clear: "이미지 지우기" }, status: { reading: "로컬에서 메타데이터 검사 중…", loaded: "검사 완료: {count}개 항목을 찾았습니다.", noneFound: "검사 완료. 지원되는 메타데이터가 없습니다.", cleaning: "로컬에서 메타데이터 정리 및 검증 중…", saved: "{name}을 검증하고 저장했습니다.", downloaded: "{name}을 검증하고 다운로드했습니다.", cancelled: "저장을 취소했습니다. 원본 이미지는 유지됩니다.", cleared: "원본 이미지를 지웠습니다." }, errors: { oneFile: "이미지 한 개만 선택하세요.", noFile: "비어 있지 않은 이미지를 선택하세요.", tooLarge: "이미지가 50 MiB 제한을 초과합니다.", signature: "지원되는 JPEG, PNG 또는 WebP 서명이 아닙니다.", unsupported: "지원되지 않는 이미지 메타데이터 구조입니다.", incomplete: "이미지 컨테이너가 불완전하거나 손상되어 안전하게 정리할 수 없습니다.", limit: "메타데이터 검사가 안전 제한을 초과했습니다.", inspection: "메타데이터 검사에 실패했습니다.", notCleanable: "검사가 불완전하여 정리를 사용할 수 없습니다.", cleaning: "메타데이터 정리에 실패했습니다. 출력은 저장되지 않았습니다.", verification: "정리본이 모든 검사를 통과하지 못했습니다. 출력은 저장되지 않았습니다.", save: "검증된 사본을 저장할 수 없습니다." } }, + }, + ja: { + metadata: { title: "画像メタデータ検査・クリーナー — Secure Tools", description: "JPEG、PNG、WebPのメタデータをブラウザー内で検査・削除します。" }, toolName: "画像メタデータ検査・クリーナー", categoryDescription: "プライバシー関連メタデータを確認し、検証済みコピーを保存します。", + copy: { ...shared, eyebrow: "画像プライバシーツール", title: "画像メタデータ検査・クリーナー", description: "JPEG、PNG、WebP画像1件のメタデータを確認し、アップロードせず検証済みコピーを作成します。", scope: { title: "検査範囲", body: "解読したメタデータと識別可能な不透明コンテナを表示します。「部分的」は成功した非網羅的な検査を意味します。" }, drop: { title: "画像を1件追加", description: "JPEG、PNG、WebPをドロップまたは選択します。別のファイルを選ぶと置き換わります。", choose: "画像を選択", localTitle: "ローカル処理。", localBody: "画像は端末外へ送信されません。", privacyLink: "プライバシーの仕組み" }, source: { title: "元画像", empty: "画像が未選択です。", summary: "{name} · {size} · {format} · メタデータ{count}件" }, inspector: { title: "検出したメタデータ", empty: "対応範囲でメタデータは検出されませんでした。", namespace: "名前空間: {namespace}", source: "構造: {source}", opaque: "不透明コンテナ", truncated: "安全のため表示を短縮しました。", diagnostics: "解析診断" }, coverage: shared.coverage, clean: { title: "プライバシークリーン", description: "公式ポリシーで新しいファイルを作成します。ピクセルのデコード・再エンコードはせず、元画像は変更しません。", policy: "対応するEXIF、XMP、IPTC、コメント、テキスト、日時を削除し、ICCカラープロファイルは保持します。", button: "クリーンして保存", imageFile: "クリーン済み画像" }, result: { title: "検証済み結果", summary: "{name}の保存前に{count}件のポリシーチェックを確認しました。", removed: "削除", preserved: "保持", none: "報告なし", diagnostics: "処理診断" }, actions: { clear: "画像をクリア" }, status: { reading: "ローカルで検査中…", loaded: "検査完了: {count}件。", noneFound: "検査完了。対応メタデータはありません。", cleaning: "クリーンと検証を実行中…", saved: "{name}を検証して保存しました。", downloaded: "{name}を検証してダウンロードしました。", cancelled: "保存をキャンセルしました。", cleared: "元画像をクリアしました。" }, errors: { oneFile: "画像を1件だけ選んでください。", noFile: "空でない画像を選んでください。", tooLarge: "50 MiBの上限を超えています。", signature: "対応する画像署名ではありません。", unsupported: "未対応のメタデータ構造です。", incomplete: "画像が不完全なため安全に処理できません。", limit: "安全上限を超えました。", inspection: "検査に失敗しました。", notCleanable: "検査が不完全なため処理できません。", cleaning: "クリーンに失敗しました。保存していません。", verification: "検証に失敗しました。保存していません。", save: "検証済みコピーを保存できません。" } }, + }, + es: { + metadata: { title: "Inspector y limpiador de metadatos — Secure Tools", description: "Inspecciona y elimina metadatos JPEG, PNG y WebP localmente." }, toolName: "Inspector y limpiador de metadatos", categoryDescription: "Revisa metadatos de privacidad y guarda una copia verificada.", + copy: { ...shared, eyebrow: "Herramienta de privacidad", title: "Inspector y limpiador de metadatos", description: "Inspecciona los metadatos de una imagen JPEG, PNG o WebP y crea una copia verificada sin subirla.", scope: { title: "Alcance honesto", body: "Muestra metadatos decodificados y contenedores opacos identificables. «Parcial» indica una inspección correcta, pero no exhaustiva." }, drop: { title: "Añade una imagen", description: "Suelta o elige un JPEG, PNG o WebP. Otro archivo sustituye al actual.", choose: "Elegir imagen", localTitle: "Procesamiento local.", localBody: "La imagen no sale del dispositivo.", privacyLink: "Cómo funciona la privacidad" }, source: { title: "Imagen de origen", empty: "Aún no hay imagen.", summary: "{name} · {size} · {format} · {count} metadatos" }, inspector: { title: "Metadatos detectados", empty: "No se identificaron metadatos en el alcance compatible.", namespace: "Espacio: {namespace}", source: "Estructura: {source}", opaque: "Contenedor opaco", truncated: "Visualización acortada por seguridad.", diagnostics: "Diagnósticos" }, coverage: shared.coverage, clean: { title: "Limpieza de privacidad", description: "Crea otro archivo con la política oficial. No decodifica ni recodifica píxeles; conserva el original.", policy: "Elimina EXIF, XMP, IPTC, comentarios, texto y marcas temporales compatibles. Conserva perfiles ICC.", button: "Limpiar y guardar", imageFile: "Imagen limpia" }, result: { title: "Resultado verificado", summary: "Se verificaron {count} controles antes de guardar {name}.", removed: "Eliminado", preserved: "Conservado", none: "Nada informado", diagnostics: "Diagnósticos de limpieza" }, actions: { clear: "Quitar imagen" }, status: { reading: "Inspeccionando localmente…", loaded: "Inspección completa: {count} entradas.", noneFound: "Inspección completa. No se encontraron metadatos compatibles.", cleaning: "Limpiando y verificando…", saved: "{name} verificado y guardado.", downloaded: "{name} verificado y descargado.", cancelled: "Guardado cancelado.", cleared: "Imagen eliminada." }, errors: { oneFile: "Elige una sola imagen.", noFile: "Elige una imagen no vacía.", tooLarge: "Supera el límite de 50 MiB.", signature: "La firma no es JPEG, PNG ni WebP compatible.", unsupported: "Estructura no compatible.", incomplete: "El contenedor está incompleto y no puede limpiarse con seguridad.", limit: "Se superó un límite de seguridad.", inspection: "Falló la inspección.", notCleanable: "La inspección está incompleta.", cleaning: "Falló la limpieza. No se guardó nada.", verification: "La copia no superó la verificación. No se guardó nada.", save: "No se pudo guardar la copia verificada." } }, + }, + de: { + metadata: { title: "Bildmetadaten-Prüfer & Bereiniger — Secure Tools", description: "JPEG-, PNG- und WebP-Metadaten lokal prüfen und entfernen." }, toolName: "Bildmetadaten-Prüfer & Bereiniger", categoryDescription: "Datenschutzrelevante Metadaten prüfen und eine verifizierte Kopie speichern.", + copy: { ...shared, eyebrow: "Bild-Datenschutzwerkzeug", title: "Bildmetadaten-Prüfer & Bereiniger", description: "Metadaten eines JPEG-, PNG- oder WebP-Bildes prüfen und ohne Upload eine verifizierte Kopie erstellen.", scope: { title: "Ehrlicher Prüfumfang", body: "Zeigt dekodierte Metadaten und erkennbare undurchsichtige Container. „Teilweise“ bedeutet erfolgreich, aber nicht vollständig." }, drop: { title: "Ein Bild hinzufügen", description: "JPEG, PNG oder WebP ablegen oder auswählen. Eine neue Datei ersetzt die aktuelle.", choose: "Bild auswählen", localTitle: "Lokal verarbeitet.", localBody: "Das Bild verlässt dieses Gerät nicht.", privacyLink: "So funktioniert Datenschutz" }, source: { title: "Quellbild", empty: "Noch kein Bild ausgewählt.", summary: "{name} · {size} · {format} · {count} Metadaten" }, inspector: { title: "Erkannte Metadaten", empty: "Im unterstützten Umfang wurden keine Metadaten erkannt.", namespace: "Namensraum: {namespace}", source: "Struktur: {source}", opaque: "Undurchsichtiger Container", truncated: "Anzeige aus Sicherheitsgründen gekürzt.", diagnostics: "Parser-Diagnose" }, coverage: shared.coverage, clean: { title: "Datenschutzbereinigung", description: "Erstellt eine neue Datei nach offizieller Richtlinie. Pixel werden nicht dekodiert oder neu kodiert; das Original bleibt erhalten.", policy: "Entfernt unterstützte EXIF-, XMP-, IPTC-, Kommentar-, Text- und Zeitdaten. ICC-Farbprofile bleiben erhalten.", button: "Bereinigen und speichern", imageFile: "Bereinigtes Bild" }, result: { title: "Verifiziertes Ergebnis", summary: "Vor dem Speichern von {name} wurden {count} Richtlinienprüfungen bestätigt.", removed: "Entfernt", preserved: "Erhalten", none: "Keine Meldung", diagnostics: "Bereinigungsdiagnose" }, actions: { clear: "Bild entfernen" }, status: { reading: "Metadaten werden lokal geprüft…", loaded: "Prüfung abgeschlossen: {count} Einträge.", noneFound: "Prüfung abgeschlossen. Keine unterstützten Metadaten gefunden.", cleaning: "Bereinigung und Prüfung laufen…", saved: "{name} verifiziert und gespeichert.", downloaded: "{name} verifiziert und heruntergeladen.", cancelled: "Speichern abgebrochen.", cleared: "Quellbild entfernt." }, errors: { oneFile: "Genau ein Bild auswählen.", noFile: "Ein nicht leeres Bild auswählen.", tooLarge: "Das Bild überschreitet 50 MiB.", signature: "Keine unterstützte JPEG-, PNG- oder WebP-Signatur.", unsupported: "Nicht unterstützte Struktur.", incomplete: "Der Container ist unvollständig und kann nicht sicher bereinigt werden.", limit: "Sicherheitslimit überschritten.", inspection: "Prüfung fehlgeschlagen.", notCleanable: "Die Prüfung ist unvollständig.", cleaning: "Bereinigung fehlgeschlagen. Nichts gespeichert.", verification: "Verifizierung fehlgeschlagen. Nichts gespeichert.", save: "Die verifizierte Kopie konnte nicht gespeichert werden." } }, + }, + fr: { + metadata: { title: "Inspecteur et nettoyeur de métadonnées — Secure Tools", description: "Inspectez et supprimez localement les métadonnées JPEG, PNG et WebP." }, toolName: "Inspecteur et nettoyeur de métadonnées", categoryDescription: "Examinez les métadonnées privées et enregistrez une copie vérifiée.", + copy: { ...shared, eyebrow: "Outil de confidentialité", title: "Inspecteur et nettoyeur de métadonnées", description: "Inspectez les métadonnées d’une image JPEG, PNG ou WebP et créez une copie vérifiée sans transfert.", scope: { title: "Périmètre honnête", body: "Affiche les métadonnées décodées et les conteneurs opaques identifiables. « Partiel » signifie réussi mais non exhaustif." }, drop: { title: "Ajouter une image", description: "Déposez ou choisissez un JPEG, PNG ou WebP. Un nouveau fichier remplace l’actuel.", choose: "Choisir une image", localTitle: "Traitement local.", localBody: "L’image ne quitte pas cet appareil.", privacyLink: "Fonctionnement de la confidentialité" }, source: { title: "Image source", empty: "Aucune image sélectionnée.", summary: "{name} · {size} · {format} · {count} métadonnées" }, inspector: { title: "Métadonnées détectées", empty: "Aucune métadonnée identifiée dans le périmètre pris en charge.", namespace: "Espace : {namespace}", source: "Structure : {source}", opaque: "Conteneur opaque", truncated: "Affichage raccourci par sécurité.", diagnostics: "Diagnostics" }, coverage: shared.coverage, clean: { title: "Nettoyage de confidentialité", description: "Crée un fichier selon la politique officielle. Aucun pixel n’est décodé ni réencodé ; l’original reste intact.", policy: "Supprime EXIF, XMP, IPTC, commentaires, textes et horodatages pris en charge. Conserve les profils ICC.", button: "Nettoyer et enregistrer", imageFile: "Image nettoyée" }, result: { title: "Résultat vérifié", summary: "{count} contrôles vérifiés avant l’enregistrement de {name}.", removed: "Supprimé", preserved: "Conservé", none: "Aucun élément signalé", diagnostics: "Diagnostics du nettoyage" }, actions: { clear: "Retirer l’image" }, status: { reading: "Inspection locale…", loaded: "Inspection terminée : {count} entrées.", noneFound: "Inspection terminée. Aucune métadonnée prise en charge.", cleaning: "Nettoyage et vérification…", saved: "{name} vérifié et enregistré.", downloaded: "{name} vérifié et téléchargé.", cancelled: "Enregistrement annulé.", cleared: "Image source retirée." }, errors: { oneFile: "Choisissez une seule image.", noFile: "Choisissez une image non vide.", tooLarge: "L’image dépasse 50 Mio.", signature: "Signature JPEG, PNG ou WebP non prise en charge.", unsupported: "Structure non prise en charge.", incomplete: "Le conteneur est incomplet et ne peut pas être nettoyé sûrement.", limit: "Limite de sécurité dépassée.", inspection: "Échec de l’inspection.", notCleanable: "L’inspection est incomplète.", cleaning: "Échec du nettoyage. Aucun fichier enregistré.", verification: "La vérification a échoué. Aucun fichier enregistré.", save: "Impossible d’enregistrer la copie vérifiée." } }, + }, +}; + +const localizedPresentation = { + en: { + groups: shared.groups, values: shared.values, + coverage: { "format-only": "The image format was recognized, but detailed metadata inspection is unavailable.", "container-inspected": "Supported metadata containers were checked. Some payloads may remain opaque.", "container-partial": "Container inspection could not be completed safely. Cleaning is disabled.", "metadata-partial": "Supported metadata fields were decoded, but the inspection is successful and non-exhaustive.", "metadata-inspected": "Supported metadata inspection completed under the library’s current semantics; this is not proof of complete privacy." }, + }, + ko: { + groups: { location: "위치", device: "카메라 및 기기", time: "날짜 및 시간", technical: "이미지 및 EXIF", software: "소프트웨어", author: "작성자 및 식별 정보", rights: "저작권 및 권리", descriptive: "설명 및 텍스트", xmp: "XMP", iptc: "IPTC", color: "색상 프로필", rendering: "렌더링", other: "기타 및 알 수 없음" }, + values: { opaque: "감지됨, 페이로드는 해석되지 않음", bytes: "바이너리 페이로드 ({count}바이트)", true: "참", false: "거짓", unknown: "알 수 없는 메타데이터" }, + coverage: { "format-only": "이미지 형식은 인식했지만 자세한 메타데이터 검사는 지원되지 않습니다.", "container-inspected": "지원되는 메타데이터 컨테이너를 확인했습니다. 일부 페이로드는 해석되지 않을 수 있습니다.", "container-partial": "컨테이너 검사를 안전하게 완료하지 못해 정리를 비활성화했습니다.", "metadata-partial": "지원되는 메타데이터 필드를 해석했지만 성공한 검사는 완전하지 않습니다.", "metadata-inspected": "라이브러리의 현재 의미 범위에서 검사를 완료했으며 완전한 개인정보 보호를 보증하지 않습니다." }, + }, + ja: { + groups: { location: "位置", device: "カメラ・端末", time: "日時", technical: "画像・EXIF", software: "ソフトウェア", author: "作成者・識別情報", rights: "著作権・権利", descriptive: "説明・テキスト", xmp: "XMP", iptc: "IPTC", color: "カラープロファイル", rendering: "レンダリング", other: "その他・不明" }, values: { opaque: "検出済み(内容は未解読)", bytes: "バイナリデータ({count}バイト)", true: "真", false: "偽", unknown: "不明なメタデータ" }, + coverage: { "format-only": "形式は認識されましたが、詳細なメタデータ検査は利用できません。", "container-inspected": "対応するコンテナを確認しました。一部の内容は不透明な場合があります。", "container-partial": "コンテナ検査を安全に完了できないため、クリーン機能を無効にしました。", "metadata-partial": "対応フィールドを解読しましたが、検査は成功した非網羅的なものです。", "metadata-inspected": "ライブラリの現在の意味範囲で検査を完了しました。完全なプライバシーの証明ではありません。" }, + }, + es: { + groups: { location: "Ubicación", device: "Cámara y dispositivo", time: "Fecha y hora", technical: "Imagen y EXIF", software: "Software", author: "Autor e identidad", rights: "Derechos", descriptive: "Descripción y texto", xmp: "XMP", iptc: "IPTC", color: "Perfil de color", rendering: "Renderizado", other: "Otros o desconocidos" }, values: { opaque: "Detectado; contenido no decodificado", bytes: "Contenido binario ({count} bytes)", true: "Verdadero", false: "Falso", unknown: "Metadato desconocido" }, + coverage: { "format-only": "Se reconoció el formato, pero no hay inspección detallada.", "container-inspected": "Se revisaron los contenedores compatibles. Algún contenido puede seguir opaco.", "container-partial": "No se pudo completar la inspección con seguridad; la limpieza está desactivada.", "metadata-partial": "Se decodificaron campos compatibles, pero la inspección correcta no es exhaustiva.", "metadata-inspected": "La inspección terminó según la semántica actual; no prueba privacidad completa." }, + }, + de: { + groups: { location: "Standort", device: "Kamera und Gerät", time: "Datum und Zeit", technical: "Bild und EXIF", software: "Software", author: "Autor und Identität", rights: "Urheberrecht und Rechte", descriptive: "Beschreibung und Text", xmp: "XMP", iptc: "IPTC", color: "Farbprofil", rendering: "Darstellung", other: "Sonstige und unbekannte" }, values: { opaque: "Erkannt, Nutzdaten nicht dekodiert", bytes: "Binäre Nutzdaten ({count} Bytes)", true: "Wahr", false: "Falsch", unknown: "Unbekannte Metadaten" }, + coverage: { "format-only": "Das Format wurde erkannt, eine Detailprüfung ist jedoch nicht verfügbar.", "container-inspected": "Unterstützte Container wurden geprüft. Einige Nutzdaten können undurchsichtig bleiben.", "container-partial": "Die Prüfung konnte nicht sicher abgeschlossen werden; Bereinigung ist deaktiviert.", "metadata-partial": "Unterstützte Felder wurden dekodiert, die erfolgreiche Prüfung ist aber nicht vollständig.", "metadata-inspected": "Prüfung nach aktueller Semantik abgeschlossen; dies beweist keinen vollständigen Datenschutz." }, + }, + fr: { + groups: { location: "Localisation", device: "Appareil photo", time: "Date et heure", technical: "Image et EXIF", software: "Logiciel", author: "Auteur et identité", rights: "Droits", descriptive: "Description et texte", xmp: "XMP", iptc: "IPTC", color: "Profil colorimétrique", rendering: "Rendu", other: "Autres et inconnues" }, values: { opaque: "Détecté, contenu non décodé", bytes: "Contenu binaire ({count} octets)", true: "Vrai", false: "Faux", unknown: "Métadonnée inconnue" }, + coverage: { "format-only": "Le format est reconnu, mais l’inspection détaillée n’est pas disponible.", "container-inspected": "Les conteneurs pris en charge ont été vérifiés. Certains contenus peuvent rester opaques.", "container-partial": "L’inspection n’a pas pu être terminée sûrement ; le nettoyage est désactivé.", "metadata-partial": "Les champs pris en charge ont été décodés, mais l’inspection réussie n’est pas exhaustive.", "metadata-inspected": "Inspection terminée selon la sémantique actuelle ; elle ne prouve pas une confidentialité complète." }, + }, +}; +for (const [language, presentation] of Object.entries(localizedPresentation)) Object.assign(imageMetadataLocales[language].copy, presentation); diff --git a/js/locales/ja.js b/js/locales/ja.js index 04ecda6..5f53e58 100644 --- a/js/locales/ja.js +++ b/js/locales/ja.js @@ -122,6 +122,7 @@ export const ja = { "imageConverter": "画像変換", "imageCompressor": "画像圧縮", "imageResizer": "画像サイズ変更", + "imageMetadata": "画像メタデータ検査・クリーナー", "metadataInspector": "メタデータ確認", "metadataCleaner": "メタデータ削除", "available": "利用可能", @@ -153,7 +154,8 @@ export const ja = { "description": "画像ファイルをブラウザ上でプライベートに処理します", "convert": "一般的な画像形式を端末内で変換します", "compress": "画質を調整しながらファイルサイズを小さくします", - "resize": "画像を指定したピクセルサイズに変更します" + "resize": "画像を指定したピクセルサイズに変更します", + "metadata": "プライバシー関連メタデータを確認し、検証済みコピーを保存します" }, "privacy": { "title": "プライバシーツール", diff --git a/js/locales/ko.js b/js/locales/ko.js index 5ff80b3..4992bc1 100644 --- a/js/locales/ko.js +++ b/js/locales/ko.js @@ -31,7 +31,7 @@ export const ko = { categories: { pdf: "PDF", image: "이미지", privacy: "개인정보 보호", scan: "스캔 및 OCR", media: "미디어" }, categoryDescriptions: { pdf: "문서를 만들고 합치고 정리합니다", image: "이미지를 변환하고 다듬습니다", privacy: "숨겨진 파일 정보를 확인하고 정리합니다", scan: "스캔을 활용 가능한 문서로 바꿉니다", media: "오디오와 비디오를 기기에서 처리합니다" }, browseCategory: "도구 보기 →", - imagesToPdf: "이미지를 PDF로", mergePdf: "PDF 합치기", splitPdf: "PDF 나누기", organizePdf: "PDF 정리", pdfToImages: "PDF를 이미지로", pdfMetadata: "PDF 메타데이터", imageConverter: "이미지 변환기", imageCompressor: "이미지 압축기", imageResizer: "이미지 크기 조절", metadataInspector: "메타데이터 확인", metadataCleaner: "메타데이터 제거", + imagesToPdf: "이미지를 PDF로", mergePdf: "PDF 합치기", splitPdf: "PDF 나누기", organizePdf: "PDF 정리", pdfToImages: "PDF를 이미지로", pdfMetadata: "PDF 메타데이터", imageConverter: "이미지 변환기", imageCompressor: "이미지 압축기", imageResizer: "이미지 크기 조절", imageMetadata: "이미지 메타데이터 검사 및 정리", metadataInspector: "메타데이터 확인", metadataCleaner: "메타데이터 제거", available: "사용 가능", comingSoon: "준비 중", }, categories: { @@ -49,7 +49,7 @@ export const ko = { }, image: { title: "이미지 도구", description: "일상 이미지 파일을 브라우저에서 비공개로 준비하세요.", - convert: "일반 이미지 형식을 로컬에서 변환합니다.", compress: "명확한 품질 설정으로 파일 크기를 줄입니다.", resize: "이미지를 정확한 픽셀 크기로 조절합니다.", + convert: "일반 이미지 형식을 로컬에서 변환합니다.", compress: "명확한 품질 설정으로 파일 크기를 줄입니다.", resize: "이미지를 정확한 픽셀 크기로 조절합니다.", metadata: "개인정보 관련 메타데이터를 확인하고 검증된 정리본을 저장합니다.", }, privacy: { title: "개인정보 보호 도구", description: "원본 파일을 공유하지 않고 숨겨진 정보를 확인하고 제거하세요.", diff --git a/tests/i18n-quality.test.mjs b/tests/i18n-quality.test.mjs index 9b06ae6..d5cba2a 100644 --- a/tests/i18n-quality.test.mjs +++ b/tests/i18n-quality.test.mjs @@ -45,7 +45,7 @@ function placeholders(value) { function testCatalogParityAndQuality() { assert.deepEqual([...Object.keys(translations)], [...languageNames.keys()]); const english = flatten(translations.en); - assert.equal(english.size, 632); + assert.equal(english.size, 712); for (const [language, catalog] of Object.entries(translations)) { const flattened = flatten(catalog); @@ -77,7 +77,7 @@ function testResolutionDetectionAndPersistence() { function testSelectorsAndDocumentTranslation() { const pages = listFiles(root, (file) => file.endsWith(".html") && fs.readFileSync(file, "utf8").includes("data-language-select")); - assert.equal(pages.length, 18, "Every production page with the shared header must expose the language selector"); + assert.equal(pages.length, 19, "Every production page with the shared header must expose the language selector"); for (const file of pages) { const html = fs.readFileSync(file, "utf8"); const select = html.match(/]*data-language-select[^>]*>([\s\S]*?)<\/select>/)?.[1]; @@ -101,6 +101,7 @@ function testDynamicToolsAndMetadata() { "tools/pdf/organize/app.js", "tools/pdf/to-images/app.js", "tools/pdf/metadata/app.js", + "tools/image/metadata/app.js", ]; for (const relative of dynamicApps) { const source = fs.readFileSync(path.join(root, relative), "utf8"); diff --git a/tests/image-compressor.test.mjs b/tests/image-compressor.test.mjs index 2260d25..80d52db 100644 --- a/tests/image-compressor.test.mjs +++ b/tests/image-compressor.test.mjs @@ -50,7 +50,7 @@ await assert.rejects(compressImages({ files: Array.from({ length: 5 }, (_, i) => assert.equal(MAX_JOB_PIXELS, 200_000_000); assert.equal(limitClosed, 5); const html = read("tools/image/compress/index.html"); const app = read("tools/image/compress/app.js"); const logic = read("tools/image/compress/compressor.js"); const css = read("tools/image/compress/tool.css"); const category = read("tools/image/index.html"); -assert.match(category, /href="\.\/converter\/"/); assert.match(category, /href="\.\/resize\/"/); assert.match(category, /href="\.\/compress\/"/); assert.equal((category.match(/class="category-tool surface"/g) || []).length, 3); assert.doesNotMatch(category, /metadataInspector|metadataCleaner|categories\.plannedNote/); +assert.match(category, /href="\.\/converter\/"/); assert.match(category, /href="\.\/resize\/"/); assert.match(category, /href="\.\/compress\/"/); assert.match(category, /href="\.\/metadata\/"/); assert.equal((category.match(/class="category-tool surface"/g) || []).length, 4); assert.doesNotMatch(category, /categories\.plannedNote/); assert.match(html, /type="file"[^>]*multiple[^>]*aria-describedby="drop-description"/); assert.match(html, /id="output-format"[\s\S]*value="original"[\s\S]*value="jpeg"[\s\S]*value="png"[\s\S]*value="webp"/); assert.match(html, /id="quality"[^>]*min="0\.5"[^>]*max="1"[^>]*value="0\.8"/); assert.match(html, /id="compression-results"[^>]*hidden[^>]*aria-labelledby="results-title"/); assert.match(html, /role="status" aria-live="polite"/); assert.match(html, /connect-src 'none'/); assert.match(app, /showQuality = elements\.format\.value !== "png"/); assert.match(app, /invalidateResults\(true\)/); assert.match(app, /finally \{ state\.busy = false; elements\.progress\.hidden = true/); assert.match(app, /URL\.revokeObjectURL/); assert.match(logic, /decoded\?\.close\(\)/); assert.match(read("tools/shared/image.js"), /imageOrientation: "from-image"/); assert.match(app, /const metrics = \{ results: result\.results, aggregate: result\.aggregate \}/, "Result UI state must not retain generated output blobs"); diff --git a/tests/image-converter.test.mjs b/tests/image-converter.test.mjs index e25b19f..9dbc977 100644 --- a/tests/image-converter.test.mjs +++ b/tests/image-converter.test.mjs @@ -172,7 +172,7 @@ const organizer = read("tools/pdf/organize/app.js"); assert.match(category, /href="\.\/converter\/"[\s\S]*status--available/); assert.match(category, /href="\.\/resize\/"[\s\S]*status--available/); assert.match(category, /href="\.\/compress\/"[\s\S]*status--available/); -assert.equal((category.match(/class="category-tool surface"/g) || []).length, 3); +assert.equal((category.match(/class="category-tool surface"/g) || []).length, 4); assert.doesNotMatch(category, /categories\.plannedNote/); assert.match(html, /type="file"[^>]*multiple[^>]*aria-describedby="drop-description"/); assert.match(html, /