From 7173242ab5e16c229d591753d15ea2da0f00a747 Mon Sep 17 00:00:00 2001 From: Exalted5373 <247056922+Exalted5373@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:28:34 -0700 Subject: [PATCH] LR-IMP-FORMATS: no overflow deciding whether to scale a full-size JPEG decode A full-size load asks the decoder for the largest box (`SourceLevel::Full` is `usize::MAX`, cast to `u32::MAX`). `jpeg::decode` then computed `2 * u32::MAX` to decide on DCT scaling: an overflow panic in debug builds (caught, so every full-size JPEG load failed with "decoder panicked"), and a wrap that happened to give the right answer in release. The comparison saturates now, and the engine converts its `usize` edge with `u32::try_from` instead of a truncating `as`. Found decoding camera JPEGs (no JFIF/EXIF header) at full size. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/jpeg.rs | 13 ++++++++++++- crates/engine/src/files.rs | 13 ++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index 55d4f6823..92409af35 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -165,7 +165,7 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: return Err(Error::Malformed(F, "no frame header".into())); } check_size(F, m.width as u64, m.height as u64, opts)?; - let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= 2 * mw || m.height >= 2 * mh)); + let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2))); // zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK, // 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling). let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components; @@ -377,6 +377,17 @@ mod tests { } } + #[test] + fn unbounded_fit_box_decodes_at_full_size() { + // a full-size load asks for the largest box (`usize::MAX` saturated to `u32::MAX`): no DCT scaling, + // and no overflow working out whether to scale + use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let pixels = vec![128u8; 32 * 24 * 3]; + let jpeg = encode_jpeg(&EncodeImage::new(32, 24, 3, Samples::U8(&pixels)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let d = crate::decode(&jpeg, DecodeOptions::fit(u32::MAX, u32::MAX)).unwrap(); + assert_eq!((d.width, d.height), (32, 24)); + } + #[test] fn markers_on_garbage() { assert!(parse_markers(b"").is_none()); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 1b21faa64..c620be928 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -310,7 +310,7 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) }; return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone })); } - let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?; + let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?; drop(bytes); let img = d.to_working(); let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; @@ -378,7 +378,7 @@ fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { /// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented. pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> { - let d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?; + let d = decode_raw_preview(bytes, fit_box(max_edge))?; let img = d.to_working(); let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default())) @@ -387,7 +387,7 @@ pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, S /// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the /// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option { - let mut d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?; + let mut d = decode_raw_preview(bytes, fit_box(max_edge))?; if d.image.width.max(d.image.height) > max_edge { d.image = fit(&d.image, max_edge, max_edge, Filter::Box); d.alpha = None; @@ -396,6 +396,13 @@ pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option lightcraft_codecs::DecodeOptions { + let e = u32::try_from(max_edge).unwrap_or(u32::MAX); + lightcraft_codecs::DecodeOptions::fit(e, e) +} + /// Filesystem-backed embedded-preview hook (native). pub fn fs_preview_loader() -> PreviewLoader { Arc::new(|path: &str, max_edge: usize| embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge))