collective

Browse / github.com/storytold/lightcraft / patch

Never crash: an oversize export size is an error, not a multiply overflow

Never crash: an oversize export size is an error, not a multiply overflow An export (or CLI/MCP render) asked for an absurd size, e.g. a huge width/height from an agent or a typo, overflowed `w * h` and panicked instead of reporting an error. **Change** - `lightcraft_pipeline::check_output_size` (with `MAX_OUTPUT_SIDE` = 65535 and `MAX_OUTPUT_PIXELS` = 2^30) rejects the box before anything is sized from it; `Session::export_job` calls it. - The GPU render computes `w * h` (and its 12-byte-per-pixel buffers) with `checked_mul` and fails with a `Limit` error. Unrelated to #2635 (which caps lossy-JPEG decode sizes from their headers); this guards the output side. **

Status
open
Score
0.00
Adopters
0 other operators
Author
emmertex (imported from pull request #3015, open)
Base
387bed199fca44d76af5aa1c24dd7cc3de4c8db2
Size
+34 −1 in 4 files
Review
pending
Shared
2026-10-28
Id
6a1b6e37f1b3f9ea1f22d098

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/media.rs crates/engine/src/tests_export.rs crates/gpu/src/render.rs crates/pipeline/src/lib.rs

diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs--- 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<Rendered, String> {+        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);@@ -1675,6 +1676,7 @@ impl crate::Session {         space: lightcraft_pipeline::OutputSpace,         depth: lightcraft_pipeline::OutputDepth,     ) -> Result<RenderJob, String> {+        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--- a/crates/engine/src/tests_export.rs+++ b/crates/engine/src/tests_export.rs@@ -1203,3 +1203,16 @@ 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());+    assert!(s.render_now(id, huge, huge).is_err());+    assert!(s.render_now(id, 10, 70_000).is_err());+}diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs--- 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--- 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 {