From 0b3810192497cda493af37475149fedcfbfc2679 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 26 Oct 2026 11:22:28 +1100 Subject: [PATCH 1/2] Never crash: an oversize export size is an error, not a multiply overflow render_export / export_job now refuse an output box past 65535 px per side or 2^30 pixels (lightcraft_pipeline::check_output_size) before anything is sized from it, and the GPU render checks w*h (and its 12-byte buffers) with checked_mul, failing with a Limit error instead of overflowing. Sources: own design. (cherry picked from commit e0b279ad8e83d115be22ad978188f060308171fc) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/media.rs | 1 + crates/engine/src/tests_export.rs | 11 +++++++++++ crates/gpu/src/render.rs | 5 ++++- crates/pipeline/src/lib.rs | 15 +++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index e76ef8169..f17ec7703 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1675,6 +1675,7 @@ impl crate::Session { space: lightcraft_pipeline::OutputSpace, depth: lightcraft_pipeline::OutputDepth, ) -> Result { + lightcraft_pipeline::check_output_size(max_w, max_h)?; let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; // An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw // image that the file keeps (Lightroom's merged DNGs) diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index d0f174245..dbb7cd562 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -1203,3 +1203,14 @@ fn same_folder_as_original_refuses_photos_without_a_folder() { assert!(e.contains("folder is missing") && e.contains(&gone), "{e}"); let _ = std::fs::remove_dir_all(&dir); } + +/// An absurd export size is refused, not a multiply overflow (it panicked before the fix). +#[test] +fn huge_export_size_is_an_error() { + use lightcraft_pipeline::{OutputDepth, OutputSpace}; + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + let huge = usize::MAX >> 8; + assert!(s.render_export(id, huge, huge, OutputSpace::Srgb, OutputDepth::U8).is_err()); + assert!(s.render_export(id, 70_000, 10, OutputSpace::Srgb, OutputDepth::U8).is_err()); +} diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 64990db0e..009452699 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -599,7 +599,10 @@ pub fn render( }; let plan = lightcraft_pipeline::plan(src, info, s, req); let (w, h) = (plan.w, plan.h); - let n = w * h; + let Some(n) = w.checked_mul(h).filter(|n| n.checked_mul(12).is_some()) else { + fail(FailKind::Limit, format!("{w}×{h} output is too large")); + return None; + }; let _limit = match fault { Some(crate::Fault::Limit(bytes)) => Some(crate::ctx::LimitOverride::new(bytes)), _ => None, diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 8f2702760..42cfedbba 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -173,6 +173,21 @@ impl PixelWindow { } } +/// The longest output side a render may be asked for (TIFF/PNG/JPEG all fit within it). +pub const MAX_OUTPUT_SIDE: usize = 65_535; +/// The most output pixels a render may be asked for (the box it fits into, ≈ 1.07 Gpx). +pub const MAX_OUTPUT_PIXELS: usize = 1 << 30; + +/// Rejects an output box past [`MAX_OUTPUT_SIDE`] or [`MAX_OUTPUT_PIXELS`] before anything is +/// sized from it. +pub fn check_output_size(max_w: usize, max_h: usize) -> Result<(), String> { + let pixels = max_w.checked_mul(max_h); + if max_w > MAX_OUTPUT_SIDE || max_h > MAX_OUTPUT_SIDE || pixels.is_none_or(|n| n > MAX_OUTPUT_PIXELS) { + return Err(format!("output size {max_w}×{max_h} is too large (at most {MAX_OUTPUT_SIDE} px per side and {MAX_OUTPUT_PIXELS} pixels)")); + } + Ok(()) +} + impl RenderRequest { pub fn fit(max_w: usize, max_h: usize) -> Self { Self { From 5725dccb1db905b1ff644420fa9c467f46437671 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 26 Oct 2026 11:41:51 +1100 Subject: [PATCH 2/2] Never crash: render_now refuses an oversize output size too Review follow-up: the synchronous render_now path checks the output box with check_output_size like export_job; the hostile-input test covers it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/media.rs | 1 + crates/engine/src/tests_export.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index f17ec7703..10857d6fa 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1645,6 +1645,7 @@ impl crate::Session { /// Synchronous render (CLI, MCP, tests). pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result { + lightcraft_pipeline::check_output_size(max_w, max_h)?; let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; let r = job.run(); self.accept(&r); diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index dbb7cd562..217e68ea1 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -1213,4 +1213,6 @@ fn huge_export_size_is_an_error() { let huge = usize::MAX >> 8; assert!(s.render_export(id, huge, huge, OutputSpace::Srgb, OutputDepth::U8).is_err()); assert!(s.render_export(id, 70_000, 10, OutputSpace::Srgb, OutputDepth::U8).is_err()); + assert!(s.render_now(id, huge, huge).is_err()); + assert!(s.render_now(id, 10, 70_000).is_err()); }