diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index f60f7dd5f4..1b66380302 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -329,6 +329,15 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)), _ => {} } + // Past the decoder's metadata bound, drop the Extended XMP chunks rather than the mask: + // decoded as it stands, an over-chunked JPEG is written as is, without its /SMask. + let stripped; + let data = if crate::upright_image::decodable_metadata(data) { + data + } else { + stripped = crate::upright_image::without_extended_xmp(data); + &stripped[..] + }; let img = jpeg_of_size(data, w, h)?; Some(match space { Space::Gray => (img.into_luma8().into_raw(), Space::Gray), diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index af3124946f..e43353b7de 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -845,10 +845,11 @@ fn damaged_masks_and_sizes_never_panic() { } #[test] -fn a_jpeg_with_thousands_of_extended_xmp_chunks_is_exported_without_decoding() { - // Export decodes a JPEG with a soft mask, or a JPEG soft mask, to make a PNG with alpha: a JPEG - // whose Extended XMP chunks would take the decoder quadratic time is written as it is instead, - // and such a mask is left out, without reaching the decoder. +fn a_jpeg_with_thousands_of_extended_xmp_chunks_keeps_its_mask() { + // Export decodes a JPEG with a soft mask, or a JPEG soft mask, to make a PNG with alpha. A JPEG + // whose Extended XMP chunks would take the decoder quadratic time is decoded without them, + // which is linear in what is left, so the mask survives; a JPEG mask past the same bound is + // still left out, without reaching the decoder. let flood = crate::upright_image::tests::with_extended_xmp_chunks(&real_jpeg(), 5_000); let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); with_image(&mut doc, |doc| { @@ -860,7 +861,12 @@ fn a_jpeg_with_thousands_of_extended_xmp_chunks_is_exported_without_decoding() { }); crate::extract::JPEGS_DECODED.with(|n| n.set(0)); let out = extract_images(&doc, &[0], 0); - assert_eq!((out.images[0].extension, &out.images[0].data), ("jpg", &flood)); + assert_eq!(out.images[0].extension, "png"); + assert_eq!(crate::extract::JPEGS_DECODED.with(std::cell::Cell::get), 1); + // The chunks themselves are gone, so the decoder never saw them. + assert!(!out.images[0].data.windows(5).any(|w| w == b"http:")); + + crate::extract::JPEGS_DECODED.with(|n| n.set(0)); let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); with_image(&mut doc, |doc| { diff --git a/crates/create/src/upright_image.rs b/crates/create/src/upright_image.rs index e89b47db79..d193a38bfb 100644 --- a/crates/create/src/upright_image.rs +++ b/crates/create/src/upright_image.rs @@ -82,7 +82,11 @@ fn extended_xmp_chunks(bytes: &[u8]) -> usize { /// JPEG `bytes` without their Extended XMP chunks, which nothing here reads: what is left decodes /// in time linear in its size however many chunks the file carried. pub(crate) fn without_extended_xmp(bytes: &[u8]) -> Vec { - let mut out = Vec::with_capacity(bytes.len()); + // Only the bytes that are kept: a CMYK JPEG can be mostly Extended XMP, and reserving the + // whole file for the little that remains aborts the export under a tight address-space limit. + let mut removed = 0usize; + each_extended_xmp(bytes, |r| removed = removed.saturating_add(r.len())); + let mut out = Vec::with_capacity(bytes.len().saturating_sub(removed)); let mut at = 0; each_extended_xmp(bytes, |r| { out.extend_from_slice(bytes.get(at..r.start).unwrap_or_default()); @@ -114,7 +118,9 @@ fn each_extended_xmp(bytes: &[u8], mut f: impl FnMut(std::ops::Range)) { let Some(&[hi, lo]) = bytes.get(i.saturating_add(2)..i.saturating_add(4)) else { break }; let len = usize::from(u16::from_be_bytes([hi, lo])); let end = i.saturating_add(2).saturating_add(len.max(2)); - if marker == 0xE1 && bytes.get(i.saturating_add(4)..).is_some_and(|seg| seg.starts_with(PREFIX)) { + // The prefix must start the APP1 segment's own payload: a zero-length header + // followed by prefix bytes is not a chunk. + if marker == 0xE1 && bytes.get(i.saturating_add(4)..end.min(bytes.len())).is_some_and(|seg| seg.starts_with(PREFIX)) { f(i..end.min(bytes.len())); } i = end;