1use 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#[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 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
87pub(crate) const SIGNATURE_BOX: [u8; 12] = [
90 0x00, 0x00, 0x00, 0x0C, b'J', b'X', b'L', b' ', 0x0D, 0x0A, 0x87, 0x0A,
91];
92
93pub(crate) const CODESTREAM_MAGIC: [u8; 2] = [0xFF, 0x0A];
95
96const BRAND: [u8; 4] = *b"jxl ";
98
99const KEPT: [BoxType; 5] = [*b"JXL ", *b"ftyp", *b"jxll", *b"jxlc", *b"jxlp"];
102
103const 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
132struct Processed {
134 findings: Vec<Finding>,
135 notes: Vec<Note>,
136 output: Vec<u8>,
137}
138
139fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
141 let mut out = Processed {
142 findings: Vec::new(),
143 notes: vec![Note::OutOfScopeContent {
144 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 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 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 return Err(malformed(MalformedDetail::Truncated));
193 }
194 if !boxes.iter().any(|b| b.is(*b"jxlc") || b.is(*b"jxlp")) {
195 return Err(malformed(MalformedDetail::MissingMarker));
199 }
200
201 Ok(out)
202}
203
204fn check_prefix(boxes: &[Bmff<'_>]) -> Result<()> {
210 let signature = boxes
211 .first()
212 .ok_or_else(|| malformed(MalformedDetail::Truncated))?;
213 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
230fn 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
238fn 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 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 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
280fn 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 #![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 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 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 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()); tiff.extend_from_slice(&2u16.to_le_bytes()); 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}