diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e7fb9..1a39b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes will be documented here. The project intends to follow seman ## Unreleased +## 0.1.1 - 2026-08-27 + +### Fixed + +- Preserve exactly one valid JPEG IFD0 EXIF Orientation value from 1 through 8 by rewriting it into a minimal endian-preserving EXIF APP1 segment while removing all other EXIF fields. +- Treat canonical Orientation-only JPEG EXIF as allowed rendering metadata during verification, while rejecting Orientation combined with any other EXIF or GPS metadata. +- Keep JPEG scan data, ICC, unknown application segments, structural bytes, and trailing data byte-identical during Orientation-preserving cleaning. +- Fail safely by removing EXIF rather than guessing when Orientation is malformed, out of range, duplicated, conflicting, incomplete, or exceeds configured parsing limits. + ## 0.1.0 - 2026-08-26 ### Added diff --git a/README.md b/README.md index fe1e5af..84fe5f1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Current implementation: - bounded binary input and endian-aware read core; -- JPEG inspection, common TIFF/EXIF decoding, whole-segment Privacy Clean, and verification; +- JPEG inspection, common TIFF/EXIF decoding, Orientation-preserving Privacy Clean, and verification; - WebP RIFF/chunk inspection, EXIF/XMP/ICC container detection, Privacy Clean, and verification; - PNG chunk inspection with text, XMP, EXIF, ICC, timestamp, rendering, and APNG classification; - shared TIFF/EXIF decoding for JPEG EXIF and PNG `eXIf` payloads; @@ -40,7 +40,7 @@ import { GPS rational components remain exact numerator/denominator pairs; decimal coordinates are not derived. Unknown TIFF tags and MakerNote are represented structurally without dumping or recursively parsing their payloads. -`DEFAULT_CLEANING_POLICY` is the authoritative semantic default: remove recognized EXIF, XMP, IPTC, comments, ordinary text, and standalone timestamps; preserve ICC, unknown, rendering, and image data. Each format maps only applicable concepts to physical containers. The deprecated `preserveColorProfiles` alias is used only when explicit `preserveIcc` is absent. +`DEFAULT_CLEANING_POLICY` is the authoritative semantic default: remove recognized private EXIF, XMP, IPTC, comments, ordinary text, and standalone timestamps; preserve ICC, unknown, rendering, and image data. For JPEG only, a single valid IFD0 Orientation value from 1 through 8 is rewritten into canonical Orientation-only EXIF so display rotation survives cleaning; all other EXIF fields are removed. Each format maps only applicable concepts to physical containers. The deprecated `preserveColorProfiles` alias is used only when explicit `preserveIcc` is absent. `verifyMetadata` supports `absent`, `present`, or `ignore` expectations. Concepts not implemented for a format produce no check rather than implying an exhaustive search. Verification fails closed if metadata reporting reaches its configured entry limit. Single-file verification observes supported container presence or absence and cannot prove provenance or pixel privacy. @@ -58,7 +58,7 @@ The library does not perform image decoding or encoding, visual redaction, pixel ## Secure Tools ecosystem -This is an independent open-source library in the broader Secure Tools ecosystem. Future integration will verify the release version, license, and SHA-256 checksum, then vendor the pinned browser artifact on the same origin rather than coupling application code to this repository. +This is an independent open-source library in the broader Secure Tools ecosystem. Secure Tools should upgrade its vendored browser artifact to `v0.1.1` after that release is published, verify the version, license, and SHA-256 checksum, and continue serving the pinned artifact from the same origin rather than coupling application code to this repository. ## License diff --git a/docs/cleaning-policy.md b/docs/cleaning-policy.md index 9c4daa9..3cf12da 100644 --- a/docs/cleaning-policy.md +++ b/docs/cleaning-policy.md @@ -4,29 +4,29 @@ Privacy Clean applies one normalized semantic policy, then maps it directly to e ## Semantic defaults -| Policy field | Default | Meaning | -| -------------------- | ------- | ----------------------------------------------- | -| `removeExif` | `true` | Remove recognized EXIF containers | -| `removeXmp` | `true` | Remove recognized XMP containers | -| `removeIptc` | `true` | Remove recognized IPTC containers | -| `removeComments` | `true` | Remove recognized comment containers | -| `removeTextMetadata` | `true` | Remove recognized ordinary text metadata | -| `removeTimestamps` | `true` | Remove recognized standalone timestamp metadata | -| `preserveIcc` | `true` | Preserve recognized ICC containers | - -Unknown, rendering, and image data are preserved. `preserveColorProfiles` remains a deprecated alias for `preserveIcc`: explicit `preserveIcc` wins, otherwise the alias is used, otherwise the default applies. The exported `DEFAULT_CLEANING_POLICY` is authoritative; legacy format-named defaults reference the same frozen object. +| Policy field | Default | Meaning | +| -------------------- | ------- | -------------------------------------------------- | +| `removeExif` | `true` | Remove private EXIF; retain valid JPEG Orientation | +| `removeXmp` | `true` | Remove recognized XMP containers | +| `removeIptc` | `true` | Remove recognized IPTC containers | +| `removeComments` | `true` | Remove recognized comment containers | +| `removeTextMetadata` | `true` | Remove recognized ordinary text metadata | +| `removeTimestamps` | `true` | Remove recognized standalone timestamp metadata | +| `preserveIcc` | `true` | Preserve recognized ICC containers | + +Unknown, rendering, and image data are preserved. JPEG Orientation is rendering metadata: when the input has exactly one fully decoded IFD0 Orientation with TIFF type SHORT, count 1, and value 1 through 8, cleaning rewrites it into a canonical minimal EXIF APP1 segment. No pixel decode, rotation, or re-encoding occurs. Missing, malformed, out-of-range, incomplete, or ambiguous Orientation is not guessed and the EXIF container is removed. `preserveColorProfiles` remains a deprecated alias for `preserveIcc`: explicit `preserveIcc` wins, otherwise the alias is used, otherwise the default applies. The exported `DEFAULT_CLEANING_POLICY` is authoritative; legacy format-named defaults reference the same frozen object. ## JPEG mapping -| Semantic field | Physical mapping | -| ---------------- | ------------------------------ | -| `removeExif` | EXIF APP1 | -| `removeXmp` | Standard and extended XMP APP1 | -| `removeIptc` | Photoshop/IPTC APP13 | -| `removeComments` | COM | -| `preserveIcc` | ICC APP2 | +| Semantic field | Physical mapping | +| ---------------- | -------------------------------------------- | +| `removeExif` | EXIF APP1 except canonical valid Orientation | +| `removeXmp` | Standard and extended XMP APP1 | +| `removeIptc` | Photoshop/IPTC APP13 | +| `removeComments` | COM | +| `preserveIcc` | ICC APP2 | -JFIF/JFXX, Adobe APP14, unknown APP segments, structural markers, scan data, and data after EOI are preserved. +JFIF/JFXX, Adobe APP14, unknown APP segments, structural markers, scan data, and data after EOI are preserved byte-for-byte. A rewritten EXIF segment contains only the TIFF header, one IFD0 Orientation entry, and a zero next-IFD pointer; all device, software, timestamp, location, identity, description, rights, MakerNote, thumbnail, and unknown EXIF fields are removed. ## WebP mapping @@ -50,4 +50,4 @@ Other semantic fields are not applicable. VP8/VP8L, ALPH, ANIM/ANMF, unknown chu Rendering/color chunks, IDAT, APNG structure, unknown ancillary and critical chunks, retained CRCs, and data after IEND are preserved. Compressed text and ICC payloads are never decompressed. -One removed physical container produces one source-ordered change record. `cleanMetadata` always returns a distinct output view, change evidence, bounded diagnostics, and one re-inspection report. Valid outer boundaries permit whole-container removal even when inner metadata is malformed; unsafe container boundaries produce a typed incomplete-format error without output. +One removed or rewritten physical container produces one source-ordered removal record. Rewritten JPEG Orientation also produces a preservation record. Canonical Orientation-only EXIF is considered absent for the JPEG `exif: "absent"` privacy expectation; Orientation combined with any other EXIF or GPS entry remains present and fails verification. `cleanMetadata` always returns a distinct output view, change evidence, bounded diagnostics, and one re-inspection report. Valid outer boundaries permit whole-container removal even when inner metadata is malformed; unsafe container boundaries produce a typed incomplete-format error without output. diff --git a/docs/format-support.md b/docs/format-support.md index fa028e7..000228d 100644 --- a/docs/format-support.md +++ b/docs/format-support.md @@ -33,6 +33,6 @@ The shared TIFF subset covers common IFD0, ExifIFD, GPSIFD, and next-IFD entries ## Container cleaning and verification -JPEG removes EXIF, XMP, Photoshop/IPTC, and comments. WebP removes EXIF and XMP, repairs RIFF size, and aligns retained VP8X flags. PNG removes `eXIf`, XMP and ordinary text chunks, and `tIME`; it preserves ICC, rendering/color, IDAT, APNG, unknown, critical, CRC, and trailing bytes by default. +JPEG removes private EXIF, XMP, Photoshop/IPTC, and comments. Exactly one valid IFD0 Orientation value from 1 through 8 is retained in a canonical minimal EXIF APP1 segment to preserve rendering; malformed or ambiguous Orientation is removed rather than guessed. WebP removes EXIF and XMP, repairs RIFF size, and aligns retained VP8X flags. PNG removes `eXIf`, XMP and ordinary text chunks, and `tIME`; it preserves ICC, rendering/color, IDAT, APNG, unknown, critical, CRC, and trailing bytes by default. -Verification reports observable supported metadata-container presence or absence. Expectations for concepts not implemented by a format are omitted as not applicable; they do not generate synthetic passing checks. If `maxMetadataEntries` truncates reporting, the report carries `metadataTruncated: true` and verification returns `valid: false` with no checks. It does not decode XMP/IPTC/ICC or compressed PNG text, prove byte provenance, or prove complete removal of personal information. +Verification reports observable supported metadata-container presence or absence. Canonical JPEG Orientation-only EXIF satisfies the default privacy expectation, but Orientation plus any other EXIF or GPS entry does not. Expectations for concepts not implemented by a format are omitted as not applicable; they do not generate synthetic passing checks. If `maxMetadataEntries` truncates reporting, the report carries `metadataTruncated: true` and verification returns `valid: false` with no checks. It does not decode XMP/IPTC/ICC or compressed PNG text, prove byte provenance, or prove complete removal of personal information. diff --git a/docs/releasing.md b/docs/releasing.md index be79d59..e43e085 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,6 +1,6 @@ # Release process -This document defines release validation and publication. `v0.1.0` is published as an immutable GitHub release from commit `352258ec413a838dfe8b9146370505f125b5ae10`; it is not published to npm. +This document defines release validation and publication. `v0.1.0` is published as an immutable GitHub release from commit `352258ec413a838dfe8b9146370505f125b5ae10`; it is not published to npm. `v0.1.1` is the current patch release candidate and must not be tagged or published until its final main-branch commit and artifacts pass this process. ## Release validation @@ -14,8 +14,8 @@ The command performs a clean install; formatting, lint, type, unit/property, bou Outputs are written to ignored `release/`: -- `secure-metadata-0.1.0.tgz` — npm package; -- `secure-metadata-0.1.0.browser.js` — standalone browser ESM artifact; +- `secure-metadata-0.1.1.tgz` — npm package; +- `secure-metadata-0.1.1.browser.js` — standalone browser ESM artifact; - `SHA256SUMS` — version, source commit, filenames, and SHA-256 hashes. Verify a transferred artifact set with `npm run release:verify`. `npm run package:audit` separately verifies the exact npm payload and imports the packed package through both public entry points. diff --git a/docs/security-model.md b/docs/security-model.md index 746df90..162a18a 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -8,7 +8,7 @@ All binary input is attacker-controlled. The cross-format guarantees are: 4. no recursion over untrusted container structures; 5. no pixel decoding or image re-encoding; 6. no compressed metadata inflation; -7. safe whole-container removal when outer boundaries are trustworthy; +7. safe whole-container removal when outer boundaries are trustworthy, with a bounded canonical rewrite only for one valid JPEG Orientation; 8. unknown metadata preservation by default; 9. ICC and rendering/color preservation by default; 10. deterministic, source-ordered output; @@ -19,11 +19,11 @@ All binary input is attacker-controlled. The cross-format guarantees are: ## Format-specific reconstruction -JPEG requires trustworthy traversal through EOI and preserves retained marker, fill, scan, restart, and trailing bytes. WebP requires a complete RIFF/chunk boundary, preserves padding and trailing data, repairs RIFF size, and changes only applicable VP8X metadata flags. PNG requires a complete IEND boundary, preserves trailing data, and copies every retained length/type/data/CRC byte unchanged. Each reconstruction uses one final output allocation and honors the caller's exact `Uint8Array` view. +JPEG requires trustworthy traversal through EOI and preserves retained marker, fill, scan, restart, and trailing bytes. When exactly one EXIF APP1 contains a fully decoded IFD0 Orientation SHORT/count-1 value from 1 through 8, the cleaner replaces that segment with a minimal TIFF structure preserving the original byte order and value. It never rotates or re-encodes pixels. Multiple, malformed, incomplete, limited, or conflicting candidates are removed rather than interpreted. WebP requires a complete RIFF/chunk boundary, preserves padding and trailing data, repairs RIFF size, and changes only applicable VP8X metadata flags. PNG requires a complete IEND boundary, preserves trailing data, and copies every retained length/type/data/CRC byte unchanged. Each reconstruction uses one final output allocation and honors the caller's exact `Uint8Array` view. Malformed inner EXIF/TIFF or textual payloads do not block removal of their bounded JPEG segment, WebP chunk, or PNG chunk. Unsafe outer boundaries produce `IncompleteJpegError`, `IncompleteWebPError`, or `IncompletePngError` without partial output. -Verification reports only supported `present` or `absent` observations. Not-applicable format concepts produce no check. Truncated metadata reporting is recorded independently of diagnostic output, produces no checks, and fails verification. The library does not establish authenticity, provenance, absence of proprietary metadata, visible-person privacy, steganography safety, malware safety, or complete metadata absence. +Verification reports only supported `present` or `absent` observations. Canonical Orientation-only JPEG EXIF is an explicit rendering exception to the default EXIF privacy-absence check; any additional EXIF or GPS entry makes EXIF present. Not-applicable format concepts produce no check. Truncated metadata reporting is recorded independently of diagnostic output, produces no checks, and fails verification. The library does not establish authenticity, provenance, absence of proprietary metadata, visible-person privacy, steganography safety, malware safety, or complete metadata absence. Core production code has zero runtime dependencies and no network, analytics, telemetry, filesystem, DOM, Node `Buffer`, or required platform-global behavior. diff --git a/package-lock.json b/package-lock.json index a4625fa..551145b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "secure-metadata", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "secure-metadata", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 4709432..854ead1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "secure-metadata", - "version": "0.1.0", + "version": "0.1.1", "description": "Deterministic, security-conscious metadata tooling for binary image formats.", "license": "MIT", "type": "module", diff --git a/scripts/release/audit-package.mjs b/scripts/release/audit-package.mjs index 642687d..a573969 100644 --- a/scripts/release/audit-package.mjs +++ b/scripts/release/audit-package.mjs @@ -6,7 +6,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { npmCommand, run } from "./shared.mjs"; const packageJson = JSON.parse(await readFile("package.json", "utf8")); -assert.equal(packageJson.version, "0.1.0"); +assert.equal(packageJson.version, "0.1.1"); assert.equal(packageJson.license, "MIT"); assert.equal( packageJson.repository.url, diff --git a/scripts/release/check-version.mjs b/scripts/release/check-version.mjs index 584959b..c941636 100644 --- a/scripts/release/check-version.mjs +++ b/scripts/release/check-version.mjs @@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises"; const packageJson = JSON.parse(await readFile("package.json", "utf8")); const lock = JSON.parse(await readFile("package-lock.json", "utf8")); -assert.equal(packageJson.version, "0.1.0"); +assert.equal(packageJson.version, "0.1.1"); assert.equal(lock.version, packageJson.version); assert.equal(lock.packages[""].version, packageJson.version); diff --git a/src/exif/orientation.ts b/src/exif/orientation.ts new file mode 100644 index 0000000..53cfd43 --- /dev/null +++ b/src/exif/orientation.ts @@ -0,0 +1,121 @@ +import { resolveParseLimit } from "../core/limits.js"; +import type { ParseLimits } from "../core/limits.js"; +import { TIFF_FIELD_TYPE } from "./field-types.js"; +import { parseTiff } from "./tiff.js"; +import { TIFF_TAG } from "./tags.js"; +import type { TiffByteOrder } from "./tiff-reader.js"; +import type { TiffParseResult } from "./types.js"; + +const EXIF_SIGNATURE = Uint8Array.of(0x45, 0x78, 0x69, 0x66, 0x00, 0x00); +const TIFF_LENGTH = 26; + +export interface ExifOrientation { + readonly value: number; + readonly byteOrder: TiffByteOrder; +} + +function matches(left: Uint8Array, right: Uint8Array): boolean { + return ( + left.byteLength === right.byteLength && + left.every((value, index) => value === right[index]) + ); +} + +function parseLimits(limits: Partial | undefined) { + return { + maxIfdEntries: resolveParseLimit("maxIfdEntries", limits?.maxIfdEntries), + maxIfdDepth: resolveParseLimit("maxIfdDepth", limits?.maxIfdDepth), + maxMetadataEntries: resolveParseLimit( + "maxMetadataEntries", + limits?.maxMetadataEntries, + ), + maxStringBytes: resolveParseLimit("maxStringBytes", limits?.maxStringBytes), + maxDiagnostics: resolveParseLimit("maxDiagnostics", limits?.maxDiagnostics), + }; +} + +export function orientationFromTiff( + result: TiffParseResult, +): ExifOrientation | undefined { + if ( + !result.complete || + result.entryLimitExceeded === true || + result.byteOrder === undefined + ) { + return undefined; + } + + const orientations = result.entries.filter( + (entry) => + entry.tag === TIFF_TAG.ORIENTATION && entry.path === "IFD0/Orientation", + ); + const orientation = orientations[0]; + if ( + orientations.length !== 1 || + orientation === undefined || + orientation.type !== TIFF_FIELD_TYPE.SHORT || + orientation.count !== 1 || + typeof orientation.value !== "number" || + orientation.value < 1 || + orientation.value > 8 + ) { + return undefined; + } + + return { value: orientation.value, byteOrder: result.byteOrder }; +} + +export function minimalOrientationExifPayload( + orientation: ExifOrientation, +): Uint8Array { + const output = new Uint8Array(EXIF_SIGNATURE.byteLength + TIFF_LENGTH); + output.set(EXIF_SIGNATURE); + const tiffOffset = EXIF_SIGNATURE.byteLength; + const view = new DataView( + output.buffer, + output.byteOffset + tiffOffset, + TIFF_LENGTH, + ); + const little = orientation.byteOrder === "little"; + output.set(little ? [0x49, 0x49] : [0x4d, 0x4d], tiffOffset); + view.setUint16(2, 42, little); + view.setUint32(4, 8, little); + view.setUint16(8, 1, little); + view.setUint16(10, TIFF_TAG.ORIENTATION, little); + view.setUint16(12, TIFF_FIELD_TYPE.SHORT, little); + view.setUint32(14, 1, little); + view.setUint16(18, orientation.value, little); + view.setUint32(22, 0, little); + return output; +} + +export function preservedOrientationExifPayload( + payload: Uint8Array, + limits?: Partial, +): Uint8Array | undefined { + if ( + payload.byteLength < EXIF_SIGNATURE.byteLength || + !EXIF_SIGNATURE.every((value, index) => payload[index] === value) + ) { + return undefined; + } + const tiff = parseTiff( + payload.subarray(EXIF_SIGNATURE.byteLength), + parseLimits(limits), + ); + const orientation = orientationFromTiff(tiff); + return orientation === undefined + ? undefined + : minimalOrientationExifPayload(orientation); +} + +export function isMinimalOrientationExifPayload( + payload: Uint8Array, + result: TiffParseResult, +): boolean { + const orientation = orientationFromTiff(result); + return ( + orientation !== undefined && + matches(payload, minimalOrientationExifPayload(orientation)) + ); +} diff --git a/src/exif/tags.ts b/src/exif/tags.ts index 98b8fa6..07c7da7 100644 --- a/src/exif/tags.ts +++ b/src/exif/tags.ts @@ -59,7 +59,7 @@ const IFD0_TAGS: Readonly> = { [TIFF_TAG.ORIENTATION]: { name: "Orientation", namespace: "exif", - category: "technical", + category: "rendering", privacy: "non-sensitive", }, [TIFF_TAG.SOFTWARE]: { diff --git a/src/jpeg/metadata.ts b/src/jpeg/metadata.ts index ecafecd..f0574ec 100644 --- a/src/jpeg/metadata.ts +++ b/src/jpeg/metadata.ts @@ -6,6 +6,7 @@ import { metadataEntriesFromTiff, relocateTiffDiagnostics, } from "../exif/metadata.js"; +import { isMinimalOrientationExifPayload } from "../exif/orientation.js"; import { parseTiff, type TiffParseLimits } from "../exif/tiff.js"; import { JPEG_MARKER } from "./markers.js"; import type { JpegParseResult, JpegSegment } from "./types.js"; @@ -62,6 +63,7 @@ export function inspectJpegMetadata( switch (segment.metadataKind) { case "exif": { + const containerIndex = entries.length; if ( !add({ id: `jpeg-exif-${String(segment.offset)}`, @@ -86,6 +88,20 @@ export function inspectJpegMetadata( (tiffLimits.maxDiagnostics ?? DEFAULT_PARSE_LIMITS.maxDiagnostics) - diagnostics.length, }); + const payload = reader.slice( + segment.payloadOffset, + segment.payloadLength, + ); + if (isMinimalOrientationExifPayload(payload, tiff)) { + entries[containerIndex] = { + id: `jpeg-exif-${String(segment.offset)}`, + namespace: "exif", + name: "EXIF Orientation container", + category: "rendering", + privacy: "non-sensitive", + source: source(segment), + }; + } entries.push( ...metadataEntriesFromTiff(tiff, { format: "jpeg", diff --git a/src/policy/clean.ts b/src/policy/clean.ts index b8ebe65..8a238d7 100644 --- a/src/policy/clean.ts +++ b/src/policy/clean.ts @@ -14,6 +14,7 @@ import type { MetadataChange, MetadataNamespace, } from "../core/types.js"; +import { preservedOrientationExifPayload } from "../exif/orientation.js"; import { inspectMetadata } from "../inspect.js"; import { parseJpeg } from "../jpeg/parser.js"; import type { JpegSegment } from "../jpeg/types.js"; @@ -110,15 +111,40 @@ function changeFor( }; } -function copyWithoutSegments( +interface SegmentEdit { + readonly segment: JpegSegment; + readonly replacement?: Uint8Array; +} + +function jpegApp1Segment(payload: Uint8Array): Uint8Array { + const declaredLength = payload.byteLength + 2; + if (declaredLength > 0xffff) { + throw new SecureMetadataError( + "Preserved EXIF Orientation exceeds the JPEG APP1 size limit.", + "CLEAN_OUTPUT_SIZE_INVALID", + ); + } + const output = new Uint8Array(payload.byteLength + 4); + output.set([0xff, 0xe1, declaredLength >>> 8, declaredLength & 0xff]); + output.set(payload, 4); + return output; +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + return ( + left.byteLength === right.byteLength && + left.every((value, index) => value === right[index]) + ); +} + +function copyWithSegmentEdits( input: Uint8Array, - removals: readonly JpegSegment[], + edits: readonly SegmentEdit[], ): Uint8Array { - const retained: Array<{ offset: number; length: number }> = []; let inputOffset = 0; - let outputLength = 0; + let outputLength = input.byteLength; - for (const segment of removals) { + for (const { segment, replacement } of edits) { const end = segment.rangeOffset + segment.rangeLength; if ( !Number.isSafeInteger(segment.rangeOffset) || @@ -129,16 +155,14 @@ function copyWithoutSegments( end > input.byteLength ) { throw new SecureMetadataError( - "JPEG cleaner produced an invalid removal range.", + "JPEG cleaner produced an invalid edit range.", "CLEAN_OUTPUT_SIZE_INVALID", ); } - - const length = segment.rangeOffset - inputOffset; - retained.push({ offset: inputOffset, length }); - outputLength += length; + outputLength += (replacement?.byteLength ?? 0) - segment.rangeLength; if ( !Number.isSafeInteger(outputLength) || + outputLength < 0 || outputLength > input.byteLength ) { throw new SecureMetadataError( @@ -149,32 +173,22 @@ function copyWithoutSegments( 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); + inputOffset = 0; let outputOffset = 0; - for (const range of retained) { - output.set( - input.subarray(range.offset, range.offset + range.length), - outputOffset, - ); - outputOffset += range.length; + for (const { segment, replacement } of edits) { + const retainedLength = segment.rangeOffset - inputOffset; + output.set(input.subarray(inputOffset, segment.rangeOffset), outputOffset); + outputOffset += retainedLength; + if (replacement !== undefined) { + output.set(replacement, outputOffset); + outputOffset += replacement.byteLength; + } + inputOffset = segment.rangeOffset + segment.rangeLength; } + output.set(input.subarray(inputOffset), outputOffset); return output; } - export function cleanMetadata( input: BinaryInput, policy?: CleaningPolicy, @@ -216,18 +230,68 @@ export function cleanMetadata( } const resolved = normalizeCleaningPolicy(policy); - const removals = jpeg.segments.filter((segment) => - shouldRemove(segment, resolved), - ); - const removed = removals.map((segment) => changeFor(segment, "removed")); - const preserved = jpeg.segments - .filter( - (segment) => - (segment.kind === "application" || segment.kind === "comment") && - !shouldRemove(segment, resolved), - ) - .map((segment) => changeFor(segment, "preserved")); - const output = copyWithoutSegments(bytes, removals); + const orientationCandidates = resolved.removeExif + ? jpeg.segments.flatMap((segment) => { + if ( + segment.metadataKind !== "exif" || + segment.payloadOffset === undefined || + segment.payloadLength === undefined + ) { + return []; + } + const payload = reader.slice( + segment.payloadOffset, + segment.payloadLength, + ); + const replacement = preservedOrientationExifPayload( + payload, + policy?.limits, + ); + return replacement === undefined ? [] : [{ segment, replacement }]; + }) + : []; + const orientationCandidate = + orientationCandidates.length === 1 ? orientationCandidates[0] : undefined; + const edits: SegmentEdit[] = []; + const orientationPreserved: MetadataChange[] = []; + + for (const segment of jpeg.segments) { + if (!shouldRemove(segment, resolved)) { + continue; + } + if ( + orientationCandidate !== undefined && + segment === orientationCandidate.segment + ) { + const replacement = jpegApp1Segment(orientationCandidate.replacement); + orientationPreserved.push({ + ...changeFor(segment, "preserved"), + name: "EXIF Orientation", + }); + const original = bytes.subarray( + segment.rangeOffset, + segment.rangeOffset + segment.rangeLength, + ); + if (!bytesEqual(original, replacement)) { + edits.push({ segment, replacement }); + } + continue; + } + edits.push({ segment }); + } + + const removed = edits.map(({ segment }) => changeFor(segment, "removed")); + const preserved = [ + ...jpeg.segments + .filter( + (segment) => + (segment.kind === "application" || segment.kind === "comment") && + !shouldRemove(segment, resolved), + ) + .map((segment) => changeFor(segment, "preserved")), + ...orientationPreserved, + ]; + const output = copyWithSegmentEdits(bytes, edits); const report = inspectMetadata( output, policy?.limits === undefined ? undefined : { limits: policy.limits }, diff --git a/src/verify/verify.ts b/src/verify/verify.ts index ff45b04..a87ad38 100644 --- a/src/verify/verify.ts +++ b/src/verify/verify.ts @@ -35,6 +35,29 @@ export const DEFAULT_PNG_VERIFICATION_POLICY = Object.freeze({ icc: "ignore", } satisfies Record); +function namespaceIsPresent( + report: ReturnType, + namespace: VerificationCheck["namespace"], +): boolean { + if (report.format !== "jpeg" || namespace !== "exif") { + return report.entries.some((entry) => entry.namespace === namespace); + } + + const exifEntries = report.entries.filter( + (entry) => entry.namespace === "exif" || entry.namespace === "gps", + ); + return ( + exifEntries.length !== 0 && + !( + exifEntries.length === 2 && + exifEntries.some( + (entry) => entry.name === "EXIF Orientation container", + ) && + exifEntries.some((entry) => entry.name === "Orientation") + ) + ); +} + export function verifyMetadata( input: BinaryInput, expectation?: VerificationPolicy, @@ -108,9 +131,7 @@ export function verifyMetadata( continue; } - const present = report.entries.some( - (entry) => entry.namespace === namespace, - ); + const present = namespaceIsPresent(report, namespace); const actual = present ? "present" : "absent"; checks.push({ namespace, diff --git a/tests/property/orientation-cleaner-properties.test.ts b/tests/property/orientation-cleaner-properties.test.ts new file mode 100644 index 0000000..2a41888 --- /dev/null +++ b/tests/property/orientation-cleaner-properties.test.ts @@ -0,0 +1,75 @@ +import fc from "fast-check"; +import { expect, it } from "vitest"; + +import { cleanMetadata, verifyMetadata } from "../../src/index.js"; +import { TIFF_FIELD_TYPE } from "../../src/exif/field-types.js"; +import { TIFF_TAG } from "../../src/exif/tags.js"; +import { + concat, + EXIF, + jpeg, + MARKER, + segment, +} from "../helpers/jpeg-builder.js"; +import { TiffBuilder, type TestByteOrder } from "../helpers/tiff-builder.js"; +import { CLEANER_PROPERTY_RUNS, propertyParameters } from "./config.js"; + +function exifWithPrivateSuffix( + order: TestByteOrder, + orientation: number, + suffix: Uint8Array, +): Uint8Array { + const tiff = new TiffBuilder(order, 160) + .bytes(120, suffix) + .ifd(8, [ + { + tag: TIFF_TAG.ORIENTATION, + type: TIFF_FIELD_TYPE.SHORT, + count: 1, + value: orientation, + }, + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.UNDEFINED, + count: suffix.byteLength, + valueOffset: 120, + }, + ]) + .finish(120 + suffix.byteLength); + return segment(MARKER.APP1, concat(EXIF, tiff)); +} + +it("property: preserves valid Orientation while removing generated private EXIF", () => { + fc.assert( + fc.property( + fc.constantFrom("little", "big"), + fc.integer({ min: 1, max: 8 }), + fc.uint8Array({ minLength: 1, maxLength: 24 }), + fc + .array(fc.integer({ min: 0, max: 0xfe }), { maxLength: 32 }) + .map((values) => Uint8Array.from(values)), + (order, orientation, privateBytes, scan) => { + const sos = segment(MARKER.SOS); + const input = jpeg( + exifWithPrivateSuffix(order, orientation, privateBytes), + sos, + scan, + ); + + const first = cleanMetadata(input); + const second = cleanMetadata(first.output); + + expect(first.report.entries).toContainEqual( + expect.objectContaining({ name: "Orientation", value: orientation }), + ); + expect(first.report.entries.map(({ name }) => name)).not.toContain( + "Make", + ); + expect(first.output.slice(-scan.byteLength - 2, -2)).toEqual(scan); + expect(second.output).toEqual(first.output); + expect(verifyMetadata(first.output).valid).toBe(true); + }, + ), + propertyParameters({ numRuns: CLEANER_PROPERTY_RUNS }), + ); +}); diff --git a/tests/unit/jpeg-orientation-preservation.test.ts b/tests/unit/jpeg-orientation-preservation.test.ts new file mode 100644 index 0000000..21be5a9 --- /dev/null +++ b/tests/unit/jpeg-orientation-preservation.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vitest"; + +import { + cleanMetadata, + inspectMetadata, + verifyMetadata, +} from "../../src/index.js"; +import { TIFF_FIELD_TYPE } from "../../src/exif/field-types.js"; +import { TIFF_TAG } from "../../src/exif/tags.js"; +import { + concat, + EXIF, + ICC, + jpeg, + MARKER, + segment, +} from "../helpers/jpeg-builder.js"; +import { + TiffBuilder, + type TestByteOrder, + type TestIfdEntry, +} from "../helpers/tiff-builder.js"; + +const SCAN = Uint8Array.of(0x11, 0xff, 0x00, 0x22, 0xff, 0xd0, 0x33); + +function orientationTiff( + value: number, + order: TestByteOrder, + withPrivateTags = false, +): Uint8Array { + const builder = new TiffBuilder(order, 160); + const entries: TestIfdEntry[] = [ + { + tag: TIFF_TAG.ORIENTATION, + type: TIFF_FIELD_TYPE.SHORT, + count: 1, + value, + }, + ]; + if (withPrivateTags) { + entries.push( + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 12, + valueOffset: 100, + }, + { + tag: TIFF_TAG.SOFTWARE, + type: TIFF_FIELD_TYPE.ASCII, + count: 14, + valueOffset: 120, + }, + ); + builder.ascii(100, "PrivateMake").ascii(120, "PrivateEditor"); + } + return builder.ifd(8, entries).finish(withPrivateTags ? 134 : 26); +} + +function orientationExif( + value: number, + order: TestByteOrder, + withPrivateTags = false, +): Uint8Array { + return segment( + MARKER.APP1, + concat(EXIF, orientationTiff(value, order, withPrivateTags)), + ); +} + +describe("JPEG EXIF Orientation preservation", () => { + it.each( + (["little", "big"] as const).flatMap((order) => + Array.from({ length: 8 }, (_, index) => [order, index + 1] as const), + ), + )( + "preserves %s-endian EXIF Orientation %i and strips other EXIF", + (order, value) => { + const icc = segment(MARKER.APP2, concat(ICC, Uint8Array.of(1, 1, 0xaa))); + const unknown = segment(0xe3, Uint8Array.of(0xde, 0xad, 0xbe, 0xef)); + const sos = segment(MARKER.SOS, Uint8Array.of(1)); + const trailing = Uint8Array.of(0xfa, 0xfb); + const input = concat( + jpeg(orientationExif(value, order, true), icc, unknown, sos, SCAN), + trailing, + ); + const before = Uint8Array.from(input); + + const first = cleanMetadata(input); + const second = cleanMetadata(first.output); + + expect(first.output).toEqual( + concat( + jpeg(orientationExif(value, order), icc, unknown, sos, SCAN), + trailing, + ), + ); + expect(input).toEqual(before); + expect(second.output).toEqual(first.output); + expect(second.removed).toEqual([]); + expect(first.removed).toContainEqual( + expect.objectContaining({ namespace: "exif", action: "removed" }), + ); + expect(first.preserved).toContainEqual( + expect.objectContaining({ + namespace: "exif", + name: "EXIF Orientation", + action: "preserved", + }), + ); + expect(first.report.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "EXIF Orientation container", + category: "rendering", + privacy: "non-sensitive", + }), + expect.objectContaining({ + name: "Orientation", + value, + category: "rendering", + privacy: "non-sensitive", + }), + ]), + ); + expect(first.report.entries.map(({ name }) => name)).not.toContain( + "Make", + ); + expect(first.report.entries.map(({ name }) => name)).not.toContain( + "Software", + ); + expect(verifyMetadata(first.output, { icc: "present" }).valid).toBe(true); + }, + ); + + it("does not treat Orientation plus another EXIF tag as verified clean", () => { + const input = jpeg(orientationExif(6, "little", true)); + + const verification = verifyMetadata(input); + + expect(verification.valid).toBe(false); + expect(verification.checks).toContainEqual({ + namespace: "exif", + expected: "absent", + actual: "present", + passed: false, + }); + }); + + it.each([ + ["zero", orientationExif(0, "little")], + ["out of range", orientationExif(9, "big")], + [ + "wrong type", + segment( + MARKER.APP1, + concat( + EXIF, + new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.ORIENTATION, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 6, + }, + ]) + .finish(26), + ), + ), + ], + [ + "wrong count", + segment( + MARKER.APP1, + concat( + EXIF, + new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.ORIENTATION, + type: TIFF_FIELD_TYPE.SHORT, + count: 2, + value: [1, 6], + }, + ]) + .finish(26), + ), + ), + ], + ])("removes %s Orientation instead of guessing", (_name, exif) => { + const result = cleanMetadata(jpeg(exif)); + + expect(result.output).toEqual(jpeg()); + expect(result.preserved).not.toContainEqual( + expect.objectContaining({ name: "EXIF Orientation" }), + ); + expect(verifyMetadata(result.output).valid).toBe(true); + }); + + it("removes ambiguous duplicate Orientation containers", () => { + const result = cleanMetadata( + jpeg(orientationExif(6, "little"), orientationExif(8, "big")), + ); + + expect(result.output).toEqual(jpeg()); + expect(result.removed).toHaveLength(2); + expect(verifyMetadata(result.output).valid).toBe(true); + }); + + it("inspection distinguishes canonical Orientation-only EXIF", () => { + const report = inspectMetadata(jpeg(orientationExif(6, "little"))); + + expect(report.entries.map(({ name }) => name)).toEqual([ + "EXIF Orientation container", + "Orientation", + ]); + expect(verifyMetadata(jpeg(orientationExif(6, "little"))).valid).toBe(true); + }); +});