Skip to main content

strypt_core/formats/
jxl.rs

1//! JPEG XL, in both of its spellings: a bare codestream and an ISO-BMFF container.
2//!
3//! The fifth tranche of Phase 2's third group (ADR-0032), and **ADR-0036 is required reading
4//! before touching it.**
5//!
6//! # The same container as HEIF, and the opposite conclusion
7//!
8//! ADR-0034 rebuilds a HEIF because its Exif and XMP are *items* located by `iloc` as absolute
9//! file offsets, so removing one moves every surviving one. JPEG XL spells the same box grammar
10//! and needs none of that: ISO/IEC 18181-2 puts Exif, XMP and JUMBF in top-level boxes of their
11//! own, and no box's contents are addressed by a file offset. So this handler edits by deletion —
12//! output is the input's bytes with whole boxes cut out — and a clean file comes back
13//! byte-identical, as GIF and SVG do.
14//!
15//! # What is removed, and what refuses the file
16//!
17//! `Exif`, `xml ` (XMP), `jumb` (JUMBF, which is where C2PA provenance arrives), `brob`, `jbrd`,
18//! `jxli`, `free` and `skip` are deleted. Everything reaching the output does so from an
19//! allow-list — signature, `ftyp`, `jxll`, `jxlc`, `jxlp` — so an unknown top-level box refuses
20//! the file rather than surviving by going unrecognised (ADR-0033's direction, ADR-0035's rule).
21//!
22//! **`brob` is dropped without being decompressed**, which is why no Brotli decompressor is in
23//! this tree: removal does not need to read what is being removed (ADR-0022 made the same
24//! argument for PNG's compressed text chunks).
25//!
26//! **`jbrd` is dropped and the cost is declared.** It holds the original JPEG's marker segments
27//! verbatim, so it is a copy of that file's headers; deleting it means the picture still decodes
28//! identically but bit-exact JPEG reconstruction stops working.
29//!
30//! # The codestream is never entered
31//!
32//! Not in either spelling. A bare codestream has no box layer, so it is reported clean and
33//! returned untouched — but its `ImageMetadata` carries an ICC profile, whose description and
34//! manufacturer fields name a device or an application, and may carry a preview frame. Both are
35//! entropy-coded inside the image data and out of reach without a decoder. Every report says so,
36//! and `docs/THREAT_MODEL.md` §7.12 says it at length.
37
38use crate::bytes::Reader;
39use crate::container::bmff::{self, Box as Bmff, BoxType, WalkError};
40use crate::detect::Format;
41use crate::error::{MalformedDetail, Result, StryptError, UnsupportedKind};
42use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, exif, xmp};
43use crate::report::{
44    Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, StripReport,
45};
46
47/// Removal of metadata from JPEG XL images.
48#[derive(Debug, Clone, Copy, Default)]
49pub struct JxlHandler;
50
51impl MetadataHandler for JxlHandler {
52    fn name(&self) -> &'static str {
53        Format::Jxl.id()
54    }
55
56    fn format(&self) -> Format {
57        Format::Jxl
58    }
59
60    fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
61        // One pass serves both entry points, with the output discarded here, so nothing `strip`
62        // removes can be invisible to `inspect` (`docs/ARCHITECTURE.md` §3).
63        let processed = process(input, options, &ParseLimits::default())?;
64        Ok(MetadataReport {
65            format: Format::Jxl,
66            findings: processed.findings,
67            notes: processed.notes,
68        })
69    }
70
71    fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
72        let processed = process(input, &options.inspect, &options.limits)?;
73        Ok(Stripped {
74            report: StripReport {
75                format: Format::Jxl,
76                removed: processed.findings,
77                retained: Vec::new(),
78                notes: processed.notes,
79                input_bytes: as_u64(input.len()),
80                output_bytes: as_u64(processed.output.len()),
81            },
82            bytes: processed.output,
83        })
84    }
85}
86
87/// The signature box, whole: length 12, type `JXL `, then the same CR-LF-EOF-LF tail PNG uses to
88/// catch transfer corruption (ISO/IEC 18181-2 §5.2).
89pub(crate) const SIGNATURE_BOX: [u8; 12] = [
90    0x00, 0x00, 0x00, 0x0C, b'J', b'X', b'L', b' ', 0x0D, 0x0A, 0x87, 0x0A,
91];
92
93/// A bare codestream's first two bytes (ISO/IEC 18181-1 §9.1).
94pub(crate) const CODESTREAM_MAGIC: [u8; 2] = [0xFF, 0x0A];
95
96/// The brand a JPEG XL file's `ftyp` declares.
97const BRAND: [u8; 4] = *b"jxl ";
98
99/// Boxes that reach the output. Everything not named here is either deleted by [`DELETED`] or
100/// refuses the file — the allow-list direction ADR-0033 established.
101const KEPT: [BoxType; 5] = [*b"JXL ", *b"ftyp", *b"jxll", *b"jxlc", *b"jxlp"];
102
103/// Boxes that are deleted, and how each is reported.
104///
105/// `free` and `skip` are padding by definition and free to hold anything; nothing depends on
106/// them. `jxli` is an optional seek index for an animation — libjxl's own overview says it is not
107/// needed to display one — and it is the only retained candidate that indexes positions in a file
108/// this handler edits, so it goes rather than being trusted (ADR-0036 §6).
109const DELETED: [(BoxType, MetadataKind, &str); 8] = [
110    (*b"Exif", MetadataKind::Other, "Exif box"),
111    (*b"xml ", MetadataKind::Other, "xml box (XMP)"),
112    (
113        *b"jumb",
114        MetadataKind::EditingHistory,
115        "jumb box (JUMBF, C2PA provenance)",
116    ),
117    (
118        *b"brob",
119        MetadataKind::Other,
120        "brob box (Brotli-compressed metadata)",
121    ),
122    (
123        *b"jbrd",
124        MetadataKind::Other,
125        "jbrd box (JPEG reconstruction data)",
126    ),
127    (*b"jxli", MetadataKind::Other, "jxli box (frame index)"),
128    (*b"free", MetadataKind::Other, "free box (padding)"),
129    (*b"skip", MetadataKind::Other, "skip box (padding)"),
130];
131
132/// The result of one pass over a file.
133struct Processed {
134    findings: Vec<Finding>,
135    notes: Vec<Note>,
136    output: Vec<u8>,
137}
138
139/// Walk `input`, decide about every box, and build the sanitised file.
140fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
141    let mut out = Processed {
142        findings: Vec::new(),
143        notes: vec![Note::OutOfScopeContent {
144            // Said on every file, clean ones included: the verdict covers the box layer and
145            // nothing inside the coded image (ADR-0036 §2).
146            location: "a JPEG XL codestream, whose ICC profile and preview frame are coded \
147                       inside the image data"
148                .to_owned(),
149        }],
150        output: Vec::with_capacity(input.len()),
151    };
152
153    if input.starts_with(&CODESTREAM_MAGIC) {
154        // No box layer exists in this spelling, so there is nothing to remove and nothing to
155        // rewrite. Returning the input unchanged is the claim, and the note above is its scope.
156        out.output.extend_from_slice(input);
157        return Ok(out);
158    }
159
160    let mut budget = limits.max_items;
161    let (boxes, trailing) = bmff::top_level(input, &mut budget).map_err(from_walk)?;
162    check_prefix(&boxes)?;
163
164    for b in &boxes {
165        if KEPT.contains(&b.kind) {
166            let raw = raw_of(input, b).ok_or_else(|| malformed(MalformedDetail::Truncated))?;
167            out.output.extend_from_slice(raw);
168            continue;
169        }
170        let Some(entry) = DELETED.iter().find(|(kind, _, _)| *kind == b.kind) else {
171            // An unknown top-level box in a metadata format is more likely to be metadata than
172            // not, and keeping it would be the survival-by-being-unrecognised failure the
173            // allow-list exists to prevent (ADR-0036 §7).
174            return Err(StryptError::UnsupportedFormat {
175                format: UnsupportedKind::UnknownJxlBox,
176            });
177        };
178        out.findings.extend(describe(b, entry, options));
179        if b.is(*b"jbrd") {
180            out.notes.push(Note::CapabilityRemoved {
181                location: "the jbrd box (JPEG bitstream reconstruction data)".to_owned(),
182                capability: "be converted back to the original JPEG bit-for-bit".to_owned(),
183            });
184        }
185    }
186
187    if !trailing.is_empty() {
188        // The format has no terminator, so "after the last box" is not a place it defines: bytes
189        // there mean either a truncated box or something appended, and neither is a file to
190        // report success on. GIF reports and drops its trailing data because §27 gives it an
191        // explicit end; this one is refused.
192        return Err(malformed(MalformedDetail::Truncated));
193    }
194    if !boxes.iter().any(|b| b.is(*b"jxlc") || b.is(*b"jxlp")) {
195        // No codestream is no image. Worth its own check because deletion cannot produce this
196        // and a truncated file can: emitting a container with nothing in it would be a success
197        // message about a file that no longer holds a picture.
198        return Err(malformed(MalformedDetail::MissingMarker));
199    }
200
201    Ok(out)
202}
203
204/// Require the two boxes ISO/IEC 18181-2 §5.2 fixes: the signature, then an `ftyp` branding the
205/// file `jxl `.
206///
207/// Checked before anything is removed. A file that fails here is refused rather than scanned for
208/// boxes on a guess about what it is.
209fn check_prefix(boxes: &[Bmff<'_>]) -> Result<()> {
210    let signature = boxes
211        .first()
212        .ok_or_else(|| malformed(MalformedDetail::Truncated))?;
213    // The whole box is fixed, tail bytes included: they are the CR-LF-EOF-LF transfer-corruption
214    // check, so a file that fails them is one that arrived damaged.
215    if !signature.is(*b"JXL ")
216        || signature.size != as_u64(SIGNATURE_BOX.len())
217        || signature.payload != &SIGNATURE_BOX[8..]
218    {
219        return Err(malformed(MalformedDetail::MissingMarker));
220    }
221    let ftyp = boxes
222        .get(1)
223        .ok_or_else(|| malformed(MalformedDetail::Truncated))?;
224    if !ftyp.is(*b"ftyp") || ftyp.payload.get(..4) != Some(&BRAND) {
225        return Err(malformed(MalformedDetail::MissingMarker));
226    }
227    Ok(())
228}
229
230/// The bytes a box occupied, which is what a kept box is written out from. Copying the original
231/// span rather than re-emitting a header is what makes a clean file byte-identical.
232fn raw_of<'a>(input: &'a [u8], b: &Bmff<'a>) -> Option<&'a [u8]> {
233    let start = usize::try_from(b.offset).ok()?;
234    let len = usize::try_from(b.size).ok()?;
235    input.get(start..start.checked_add(len)?)
236}
237
238/// Name what is inside a box that is about to be deleted.
239fn describe(
240    b: &Bmff<'_>,
241    entry: &(BoxType, MetadataKind, &str),
242    options: &InspectOptions,
243) -> Vec<Finding> {
244    let (_, kind, location) = *entry;
245
246    if b.is(*b"Exif") {
247        // §5.3: the payload opens with a four-byte offset to the TIFF header, so a scan starting
248        // at the payload would read the byte-order mark four bytes early and produce a confident
249        // parse of the wrong bytes — the mistake `exif::scan`'s header warns about.
250        // An Exif block that does not parse is still an Exif block that is about to be removed
251        // whole, so the scan's own caveats are not carried: nothing is left behind to caveat.
252        if let Some(tiff) = exif_payload(b.payload) {
253            let scanned = exif::scan(tiff, location, options, &ParseLimits::default());
254            if !scanned.findings.is_empty() {
255                return scanned.findings;
256            }
257        }
258    }
259    if b.is(*b"xml ") {
260        let findings = xmp::scan(b.payload, location, options);
261        if !findings.is_empty() {
262            return findings;
263        }
264    }
265
266    let finding = Finding::new(kind, location, b.size);
267    if b.is(*b"brob") {
268        // The compressed box names what it wraps in its first four bytes. That name is reported;
269        // the Brotli behind it is never inflated, because the box is being deleted either way.
270        let inner = b.payload.get(..4).unwrap_or_default();
271        return vec![
272            finding
273                .with_field(xmp::name_of(inner))
274                .with_value(options, || MetadataValue::Text(xmp::name_of(inner))),
275        ];
276    }
277    vec![finding]
278}
279
280/// The TIFF structure inside an Exif box, past the four-byte header offset (§5.3).
281fn exif_payload(payload: &[u8]) -> Option<&[u8]> {
282    let mut r = Reader::new(payload);
283    let skip = crate::bytes::u32_to_usize(r.u32_be()?)?;
284    r.skip(skip)?;
285    Some(r.take_rest())
286}
287
288fn from_walk(e: WalkError) -> StryptError {
289    match e {
290        WalkError::Malformed(detail) => malformed(detail),
291        WalkError::Limit(limit) => StryptError::LimitExceeded {
292            format: Format::Jxl,
293            limit,
294        },
295    }
296}
297
298fn malformed(detail: MalformedDetail) -> StryptError {
299    StryptError::Malformed {
300        format: Format::Jxl,
301        offset: None,
302        detail,
303    }
304}
305
306fn as_u64(value: usize) -> u64 {
307    u64::try_from(value).unwrap_or(u64::MAX)
308}
309
310#[cfg(test)]
311mod tests {
312    // Test code is never reachable from untrusted bytes, which is the boundary the
313    // panic-freedom lints exist to police (ADR-0006).
314    #![allow(
315        clippy::unwrap_used,
316        clippy::expect_used,
317        clippy::indexing_slicing,
318        clippy::arithmetic_side_effects
319    )]
320
321    use super::*;
322
323    fn boxed(kind: BoxType, payload: &[u8]) -> Vec<u8> {
324        let size = u32::try_from(payload.len() + 8).unwrap();
325        let mut out = size.to_be_bytes().to_vec();
326        out.extend_from_slice(&kind);
327        out.extend_from_slice(payload);
328        out
329    }
330
331    fn ftyp() -> Vec<u8> {
332        boxed(*b"ftyp", b"jxl \0\0\0\0jxl ")
333    }
334
335    /// A codestream box holding a header stub, not a picture: nothing here enters it.
336    fn jxlc() -> Vec<u8> {
337        boxed(*b"jxlc", &[0xFF, 0x0A, 0x38, 0x00, 0x10, 0x00])
338    }
339
340    fn container(extra: &[Vec<u8>]) -> Vec<u8> {
341        let mut out = SIGNATURE_BOX.to_vec();
342        out.extend_from_slice(&ftyp());
343        for b in extra {
344            out.extend_from_slice(b);
345        }
346        out.extend_from_slice(&jxlc());
347        out
348    }
349
350    fn strip(input: &[u8]) -> Result<Stripped> {
351        JxlHandler.strip(input, &StripOptions::default())
352    }
353
354    #[test]
355    fn a_clean_container_comes_back_byte_identical() {
356        let input = container(&[]);
357        let out = strip(&input).unwrap();
358        assert_eq!(out.bytes, input);
359        assert!(out.report.removed.is_empty());
360    }
361
362    #[test]
363    fn a_bare_codestream_is_returned_unchanged() {
364        let input = [0xFF, 0x0A, 0x38, 0x00, 0x10, 0x00];
365        let out = strip(&input).unwrap();
366        assert_eq!(out.bytes, input);
367        assert!(out.report.removed.is_empty());
368    }
369
370    #[test]
371    fn every_report_notes_the_codestream() {
372        for input in [container(&[]).as_slice(), &[0xFF, 0x0A, 0x38]] {
373            let out = strip(input).unwrap();
374            assert!(
375                out.report
376                    .notes
377                    .iter()
378                    .any(|n| matches!(n, Note::OutOfScopeContent { .. }))
379            );
380        }
381    }
382
383    #[test]
384    fn each_deleted_box_type_is_removed_and_reported() {
385        for (kind, _, location) in DELETED {
386            let input = container(&[boxed(kind, b"SYNTHETIC-PAYLOAD")]);
387            let out = strip(&input).unwrap();
388            assert_eq!(out.report.removed.len(), 1, "{location}");
389            assert_eq!(out.bytes, container(&[]), "{location}");
390        }
391    }
392
393    #[test]
394    fn a_brob_box_is_reported_by_the_type_it_wraps() {
395        let input = container(&[boxed(*b"brob", b"xml \x0b\x00compressed")]);
396        let out = strip(&input).unwrap();
397        assert_eq!(out.report.removed[0].field.as_deref(), Some("xml "));
398    }
399
400    #[test]
401    fn a_jbrd_box_leaves_a_capability_note() {
402        let input = container(&[boxed(*b"jbrd", b"reconstruction")]);
403        let out = strip(&input).unwrap();
404        assert!(
405            out.report
406                .notes
407                .iter()
408                .any(|n| matches!(n, Note::CapabilityRemoved { .. }))
409        );
410    }
411
412    #[test]
413    fn an_unknown_top_level_box_refuses_the_file() {
414        let input = container(&[boxed(*b"zzzz", b"who knows")]);
415        assert!(matches!(
416            strip(&input),
417            Err(StryptError::UnsupportedFormat {
418                format: UnsupportedKind::UnknownJxlBox
419            })
420        ));
421    }
422
423    #[test]
424    fn the_prefix_the_spec_fixes_is_required() {
425        let bad_signature = {
426            let mut v = SIGNATURE_BOX.to_vec();
427            v[11] = 0x00;
428            v.extend_from_slice(&ftyp());
429            v.extend_from_slice(&jxlc());
430            v
431        };
432        let no_signature = [ftyp(), jxlc()].concat();
433        let wrong_brand = {
434            let mut v = SIGNATURE_BOX.to_vec();
435            v.extend_from_slice(&boxed(*b"ftyp", b"heic\0\0\0\0heic"));
436            v.extend_from_slice(&jxlc());
437            v
438        };
439        for input in [bad_signature, no_signature, wrong_brand] {
440            assert!(strip(&input).is_err());
441        }
442    }
443
444    #[test]
445    fn trailing_bytes_are_refused() {
446        let mut input = container(&[]);
447        input.extend_from_slice(b"appended");
448        assert!(strip(&input).is_err());
449    }
450
451    #[test]
452    fn a_container_with_no_codestream_is_refused() {
453        let input = [SIGNATURE_BOX.to_vec(), ftyp()].concat();
454        assert!(matches!(
455            strip(&input),
456            Err(StryptError::Malformed {
457                detail: MalformedDetail::MissingMarker,
458                ..
459            })
460        ));
461    }
462
463    #[test]
464    fn a_size_zero_final_box_is_copied_through() {
465        // Size 0 means "to the end of the file" (ISO/IEC 14496-12 §4.2), legal for the last box.
466        let mut input = [SIGNATURE_BOX.to_vec(), ftyp()].concat();
467        input.extend_from_slice(&[0, 0, 0, 0]);
468        input.extend_from_slice(b"jxlc");
469        input.extend_from_slice(&[0xFF, 0x0A, 0x38, 0x00]);
470        let out = strip(&input).unwrap();
471        assert_eq!(out.bytes, input);
472    }
473
474    #[test]
475    fn exif_findings_name_the_tags_behind_the_header_offset() {
476        // Payload: the four-byte offset, then a little-endian TIFF header with one Artist tag.
477        let mut tiff = b"II\x2a\x00\x08\x00\x00\x00".to_vec();
478        tiff.extend_from_slice(&1u16.to_le_bytes());
479        tiff.extend_from_slice(&0x013Bu16.to_le_bytes()); // Artist
480        tiff.extend_from_slice(&2u16.to_le_bytes()); // ASCII
481        tiff.extend_from_slice(&10u32.to_le_bytes());
482        tiff.extend_from_slice(&26u32.to_le_bytes());
483        tiff.extend_from_slice(&0u32.to_le_bytes());
484        tiff.extend_from_slice(b"SYNTHETIC\0");
485        let mut payload = 0u32.to_be_bytes().to_vec();
486        payload.extend_from_slice(&tiff);
487
488        let input = container(&[boxed(*b"Exif", &payload)]);
489        let out = strip(&input).unwrap();
490        assert!(out.report.removed.iter().any(|f| f.field.is_some()));
491        assert_eq!(out.bytes, container(&[]));
492    }
493
494    #[test]
495    fn an_exif_box_that_does_not_parse_is_still_removed() {
496        let input = container(&[boxed(*b"Exif", b"\xff\xff\xff\xffnot a tiff")]);
497        let out = strip(&input).unwrap();
498        assert_eq!(out.report.removed.len(), 1);
499        assert_eq!(out.bytes, container(&[]));
500    }
501
502    #[test]
503    fn exif_payload_steps_over_the_tiff_header_offset() {
504        assert_eq!(exif_payload(b"\0\0\0\x02..II*\0"), Some(&b"II*\0"[..]));
505        assert_eq!(exif_payload(b"\0\0\0\xff"), None);
506        assert_eq!(exif_payload(b"\0\0"), None);
507    }
508
509    #[test]
510    fn inspect_and_strip_agree() {
511        let input = container(&[boxed(*b"jumb", b"SYNTHETIC-C2PA"), boxed(*b"free", b"pad")]);
512        let inspected = JxlHandler
513            .inspect(&input, &InspectOptions::default())
514            .unwrap();
515        let stripped = strip(&input).unwrap();
516        assert_eq!(inspected.findings, stripped.report.removed);
517    }
518}