From 36d22a27455b64244fd255f1b830554503bf7b94 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:39:58 +0300 Subject: [PATCH 1/2] raw, codecs: checked dimension products so wasm32 cannot wrap the size gates (#2138) On wasm32 usize is 32-bit, so 65536 x 65536 wraps to 0 and products like w * h, w * h * cpp, fw * fh * nc and row_bytes * ch wrapped past their own validation and let later true-dimension indexing panic (profile.web sets panic = abort). All the enumerated sites now validate through a checked u64 gate: - lightcraft-raw gains checked_pixel_count(width, height) (u64, capped at MAX_SAMPLES), used by the ARW decode head (the strip-length equality now compares the true sample count, not a wrapped one) and by RawImage::check's data-length validation; - the CR2 lossless-JPEG frame size is checked in u64 on both the Full and Header paths (it used to be clamped AFTER wrapping), and the no-slices width fallback is checked; - the TIFF DEFLATE chunk's inflate length is checked and capped before the cast; - lightcraft-codecs gains checked_sample_buf_len for the finish() buffer bound, compared in u64 via try_from so no cast truncates on 32-bit. Tests pin the gate arithmetic with values that would wrap 32-bit usize (65536 x 65536, u32::MAX x 2, 2^30 x 4). cargo check --target wasm32-unknown-unknown passes for both crates. The issue's suggested wasm32 arithmetic-side-effects clippy pass is a follow-up. --- crates/codecs/src/convert.rs | 5 ++-- crates/codecs/src/lib.rs | 28 ++++++++++++++++++ crates/raw/src/lib.rs | 55 ++++++++++++++++++++++++++++++++++-- crates/raw/src/tiffraw.rs | 3 +- crates/raw/src/vendor/arw.rs | 10 +++---- crates/raw/src/vendor/cr2.rs | 14 +++++++-- 6 files changed, 102 insertions(+), 13 deletions(-) diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs index 02652db5a..84108ba81 100644 --- a/crates/codecs/src/convert.rs +++ b/crates/codecs/src/convert.rs @@ -87,9 +87,10 @@ pub(crate) fn check_size(format: Format, w: u64, h: u64, opts: &DecodeOptions) - } pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u32), opts: &DecodeOptions) -> Result { - let n = raw.width * raw.height; let ch = raw.model.channels() + raw.alpha as usize; - if n == 0 || raw.buf.len() < n * ch { + let buf_len = + crate::checked_sample_buf_len(raw.width, raw.height, ch).ok_or_else(|| Error::Malformed(format, "sample buffer too short".into()))?; + if (raw.buf.len() as u64) < buf_len { return Err(Error::Malformed(format, "sample buffer too short".into())); } let float = matches!(raw.buf, Buf::F32(_)); diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index d14b1812f..404646c8d 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -338,3 +338,31 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result Option { + (width as u64).checked_mul(height as u64).and_then(|n| n.checked_mul(ch as u64)).filter(|&n| n > 0 && n <= MAX_SAMPLES) +} + +#[cfg(test)] +mod checked_arithmetic_tests { + use super::*; + + #[test] + fn checked_sample_buf_len_wrap_inputs_rejected() { + // 2^30 px × 4 ch = 2^32 samples wraps on 32-bit usize and is past the 2^30-sample cap. + assert_eq!(checked_sample_buf_len(1 << 30, 1, 4), None); + assert_eq!(checked_sample_buf_len(u32::MAX as usize, 1, 2), None); + } + + #[test] + fn checked_sample_buf_len_normal_accepted() { + assert_eq!(checked_sample_buf_len(100, 1, 3), Some(300)); + assert_eq!(checked_sample_buf_len(1, 1, 1), Some(1)); + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 202983331..f2bd03d81 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -71,6 +71,16 @@ use serde::{Deserialize, Serialize}; /// Upper bound on decoded samples (guards allocations driven by header values). pub const MAX_SAMPLES: usize = 1 << 30; +/// Checked product of input-derived dimensions, in u64 so it cannot wrap +/// on either 32-bit (wasm32) or 64-bit targets. +/// +/// Returns `None` on overflow, zero, or when the product exceeds [`MAX_SAMPLES`]. +pub(crate) fn checked_pixel_count(width: usize, height: usize) -> Option { + let w = width as u64; + let h = height as u64; + w.checked_mul(h).filter(|&n| n > 0 && n <= MAX_SAMPLES as u64) +} + /// Errors from raw decoding. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum RawError { @@ -765,8 +775,14 @@ impl RawImage { if self.cpp == 2 { return Err(RawError::Unsupported("2 samples per pixel".into())); } - if samples && self.data.len() != self.width * self.height * self.cpp { - return Err(RawError::Corrupt("data length mismatch".into())); + if samples { + let expected = crate::checked_pixel_count(self.width, self.height) + .and_then(|n| n.checked_mul(self.cpp as u64)) + .ok_or_else(|| RawError::Corrupt("data length mismatch".into()))?; + let actual = u64::try_from(self.data.len()).map_err(|_| RawError::Corrupt("data length mismatch".into()))?; + if actual != expected { + return Err(RawError::Corrupt("data length mismatch".into())); + } } if let Some(c) = &self.cfa && (!c.valid() || c.pattern.iter().any(|&p| p > 2)) @@ -1416,3 +1432,38 @@ mod tests { assert_eq!(decode(b"junk"), Err(RawError::NotRaw)); } } + +#[cfg(test)] +mod checked_arithmetic_tests { + use super::*; + + #[test] + fn checked_pixel_count_wrap_inputs_rejected() { + // 65536 × 65536 = 2^32 wraps to 0 on wasm32 usize; u64 math keeps it exact. + assert_eq!(checked_pixel_count(65536, 65536), None); + assert_eq!(checked_pixel_count(65536, 65537), None); + // u32::MAX × 2 wraps on 32-bit usize. + assert_eq!(checked_pixel_count(u32::MAX as usize, 2), None); + } + + #[test] + fn checked_pixel_count_over_cap_rejected() { + // MAX_SAMPLES = 2^30; one over is rejected. + assert_eq!(checked_pixel_count((1 << 30) + 1, 1), None); + assert_eq!(checked_pixel_count(1, (1 << 30) + 1), None); + } + + #[test] + fn checked_pixel_count_at_cap_accepted() { + assert_eq!(checked_pixel_count(1 << 30, 1), Some(1 << 30)); + assert_eq!(checked_pixel_count(1 << 15, 1 << 15), Some(1 << 30)); + } + + #[test] + fn checked_pixel_count_small_accepted() { + assert_eq!(checked_pixel_count(100, 100), Some(10_000)); + assert_eq!(checked_pixel_count(1, 1), Some(1)); + assert_eq!(checked_pixel_count(0, 100), None); + assert_eq!(checked_pixel_count(100, 0), None); + } +} diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 17032f694..7b5181128 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -193,7 +193,8 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack comp::ADOBE_DEFLATE | comp::DEFLATE => { let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 }; let row_bytes = if float { cw * cpp * bytes_per } else { (cw * cpp * bits as usize).div_ceil(8) }; - let raw = inflate(src, row_bytes * ch)?; + let inflate_len = crate::checked_pixel_count(row_bytes, ch).ok_or_else(|| RawError::Corrupt("bad TIFF chunk dimensions".into()))?; + let raw = inflate(src, inflate_len as usize)?; unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor) } comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float), diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index a662799ae..d84579ad1 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -621,9 +621,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("ARW without a CFA image IFD (old ARW or SR2)".into()))?; let info = raw.image()?; let (w, h) = (info.width as usize, info.height as usize); - if w * h > crate::MAX_SAMPLES { - return Err(RawError::Limit("image too large")); - } + let samples = crate::checked_pixel_count(w, h).ok_or_else(|| RawError::Limit("image too large"))?; let bits = info.bits() as u32; let linear_rgb = info.compression == 7 && info.photometric == photometric::YCBCR; let chunks = info.chunks(bytes.len() as u64); @@ -631,7 +629,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // The row-per-w-bytes (ARW2) layout stores exactly one byte per pixel in a single strip; all 108 such files in the test // archive measure exactly width x height. A200/A230/A350 files carry a strip 4-23 % larger than that (and A290/A390 one // smaller), which is a different packing, so they are refused rather than read with the wrong layout. - let one_byte_per_sample = chunks.len() == 1 && strip_len == (w * h) as u64; + let one_byte_per_sample = chunks.len() == 1 && strip_len == samples; let packed12 = info.compression == 32767 && is_packed12(&info, chunks.len(), strip_len); let (data, out_bits) = match info.compression { 7 if linear_rgb => (RawData::U16(read_ycbcr_tiles(bytes, &info, raw, mode)?), 14), @@ -642,7 +640,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { 32767 if one_byte_per_sample => { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let curve = code_curve(&raw.u64s(TONE_CURVE).unwrap_or_else(|| vec![8000, 10400, 12900, 14100])); - let mut data = vec![0u16; w * h]; + let mut data = vec![0u16; samples as usize]; data.par_chunks_mut(w).enumerate().for_each(|(y, out)| { let row = src.get(y * w..((y + 1) * w).min(src.len())).unwrap_or(&[]); if row.len() == w { @@ -659,7 +657,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { 32767 if packed12 => { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let row_bytes = w / 2 * 3; - let mut data = vec![0u16; w * h]; + let mut data = vec![0u16; samples as usize]; data.par_chunks_mut(w).enumerate().for_each(|(y, out)| { if let Some(row) = src.get(y * row_bytes..(y + 1) * row_bytes) { unpack_row12(row, out); diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 3fe173e09..4729d1235 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -161,7 +161,14 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let src = chunk_bytes(bytes, &chunk).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let (fw, fh, nc, prec) = ljpeg::frame_info(src)?; let frame = match mode { - Mode::Full => Some(ljpeg::decode(src, (fw * fh * nc).min(crate::MAX_SAMPLES))?), + Mode::Full => { + let total = (fw as u64) + .checked_mul(fh as u64) + .and_then(|v| v.checked_mul(nc as u64)) + .filter(|t| *t > 0 && *t <= crate::MAX_SAMPLES as u64) + .ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?; + Some(ljpeg::decode(src, total as usize)?) + } Mode::Header => None, }; let total = match &frame { @@ -186,7 +193,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { v.push(s[2] as usize); v } - None => vec![fw * nc], + None => { + let w = (fw as u64).checked_mul(nc as u64).ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?; + vec![w as usize] + } }; let width: usize = widths.iter().sum(); if width == 0 || total % width != 0 { From 9f2052f56b029ac19840d0160ca0c2009d95915a Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:20:44 +0300 Subject: [PATCH 2/2] =?UTF-8?q?raw:=20audit=20fixes=20=E2=80=94=20DEFLATE?= =?UTF-8?q?=20row=20bytes=20in=20u64,=20checked=20is=5Fpacked12,=20cap=20i?= =?UTF-8?q?nvariant=20documented=20(#2138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewer audit of the first commit found the DEFLATE arm computed row_bytes in wrapping usize before the gate, so on wasm32 a hostile chunk header (width 2^29+1, 16-bit) could wrap cw * cpp * bits to a small value and slip a garbage chunk past the new gate. row_bytes is now computed in u64 (the gate caps at MAX_SAMPLES, so the usize cast is lossless on 32-bit too), checked_pixel_count takes u64 directly so no call site can truncate before the check, is_packed12 uses a checked_mul chain instead of comparing against a possibly-wrapped length, and the arw decode head documents the invariant the cap provides: with w*h <= 2^30 every downstream usize product stays inside range on 32-bit targets. Behaviour is unchanged for every input the previous commit accepted; the only shrunk set is chunks that passed because wrapping made row_bytes small. --- crates/raw/src/lib.rs | 15 ++++++++------- crates/raw/src/tiffraw.rs | 7 +++++-- crates/raw/src/vendor/arw.rs | 7 +++++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index f2bd03d81..937920c17 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -72,13 +72,12 @@ use serde::{Deserialize, Serialize}; pub const MAX_SAMPLES: usize = 1 << 30; /// Checked product of input-derived dimensions, in u64 so it cannot wrap -/// on either 32-bit (wasm32) or 64-bit targets. +/// on either 32-bit (wasm32) or 64-bit targets. The arguments are u64 too, so callers can feed +/// unchecked products of header values (row bytes, sample counts) without truncating on 32-bit. /// /// Returns `None` on overflow, zero, or when the product exceeds [`MAX_SAMPLES`]. -pub(crate) fn checked_pixel_count(width: usize, height: usize) -> Option { - let w = width as u64; - let h = height as u64; - w.checked_mul(h).filter(|&n| n > 0 && n <= MAX_SAMPLES as u64) +pub(crate) fn checked_pixel_count(width: u64, height: u64) -> Option { + width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_SAMPLES as u64) } /// Errors from raw decoding. @@ -776,7 +775,7 @@ impl RawImage { return Err(RawError::Unsupported("2 samples per pixel".into())); } if samples { - let expected = crate::checked_pixel_count(self.width, self.height) + let expected = crate::checked_pixel_count(self.width as u64, self.height as u64) .and_then(|n| n.checked_mul(self.cpp as u64)) .ok_or_else(|| RawError::Corrupt("data length mismatch".into()))?; let actual = u64::try_from(self.data.len()).map_err(|_| RawError::Corrupt("data length mismatch".into()))?; @@ -1443,7 +1442,9 @@ mod checked_arithmetic_tests { assert_eq!(checked_pixel_count(65536, 65536), None); assert_eq!(checked_pixel_count(65536, 65537), None); // u32::MAX × 2 wraps on 32-bit usize. - assert_eq!(checked_pixel_count(u32::MAX as usize, 2), None); + assert_eq!(checked_pixel_count(u64::from(u32::MAX), 2), None); + // inputs wider than a 32-bit usize reach the gate untruncated (2^40 × 2^40 overflows u64). + assert_eq!(checked_pixel_count(1 << 40, 1 << 40), None); } #[test] diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 7b5181128..a781151b4 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -192,8 +192,11 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack } comp::ADOBE_DEFLATE | comp::DEFLATE => { let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 }; - let row_bytes = if float { cw * cpp * bytes_per } else { (cw * cpp * bits as usize).div_ceil(8) }; - let inflate_len = crate::checked_pixel_count(row_bytes, ch).ok_or_else(|| RawError::Corrupt("bad TIFF chunk dimensions".into()))?; + // row bytes in u64: a wrapping `cw · cpp · …` in usize could come out small on 32-bit targets and slip past the gate + let row_bytes = if float { cw as u64 * cpp as u64 * bytes_per as u64 } else { (cw as u64 * cpp as u64 * bits as u64).div_ceil(8) }; + // the gate caps at MAX_SAMPLES (≤ 2^30), so the `usize` cast below is lossless on 32-bit targets too + let inflate_len = + crate::checked_pixel_count(row_bytes, ch as u64).ok_or_else(|| RawError::Corrupt("bad TIFF chunk dimensions".into()))?; let raw = inflate(src, inflate_len as usize)?; unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor) } diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index d84579ad1..3938fa50d 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -245,7 +245,9 @@ fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> O /// DSLR-A900 sample this is the whole strip (36 917 760 = 6080 × 4048 × 1.5) and it ends at the end of the file. fn is_packed12(info: &ImageInfo, strip_count: usize, strip_len: u64) -> bool { let (w, h) = (info.width as u64, info.height as u64); - info.bits() == 12 && strip_count == 1 && w % 2 == 0 && w > 0 && h > 0 && strip_len == w * h * 3 / 2 + // checked_mul chain: a wrapping `w · h · 3` must refuse the strip, not compare against a wrapped length + let packed_len = w.checked_mul(h).and_then(|n| n.checked_mul(3)).map(|n| n / 2); + info.bits() == 12 && strip_count == 1 && w % 2 == 0 && w > 0 && h > 0 && packed_len == Some(strip_len) } /// Unpack plain 12-bit samples stored least-significant-bit first: two pixels per three bytes, the first being @@ -621,7 +623,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("ARW without a CFA image IFD (old ARW or SR2)".into()))?; let info = raw.image()?; let (w, h) = (info.width as usize, info.height as usize); - let samples = crate::checked_pixel_count(w, h).ok_or_else(|| RawError::Limit("image too large"))?; + // the cap (≤ 2^30) keeps every downstream usize product (w·h·2, w·h·3/2, y·w) inside range on 32-bit targets + let samples = crate::checked_pixel_count(w as u64, h as u64).ok_or(RawError::Limit("image too large"))?; let bits = info.bits() as u32; let linear_rgb = info.compression == 7 && info.photometric == photometric::YCBCR; let chunks = info.chunks(bytes.len() as u64);