Merge PR #2180: raw, codecs: checked dimension products so wasm32 cannot wrap the size gates (#2138)

This commit is contained in:
aiongg
2026-10-21 14:49:01 +00:00
6 changed files with 111 additions and 15 deletions
+3 -2
View File
@@ -99,9 +99,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<Decoded> {
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(_));
+28
View File
@@ -338,3 +338,31 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result<Th
source_height: d.source_height,
})
}
/// Cap on total decoded samples. Mirrors `lightcraft_raw::MAX_SAMPLES` (codecs does not depend on
/// lightcraft-raw, so the value is restated here).
const MAX_SAMPLES: u64 = 1 << 30;
/// Checked sample-buffer length `width × height × ch`, in u64: `None` on overflow, a zero dimension,
/// or a product past [`MAX_SAMPLES`].
pub(crate) fn checked_sample_buf_len(width: usize, height: usize, ch: usize) -> Option<u64> {
(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));
}
}
+54 -2
View File
@@ -71,6 +71,15 @@ 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. 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: u64, height: u64) -> Option<u64> {
width.checked_mul(height).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 +774,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 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()))?;
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 +1431,40 @@ 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(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]
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);
}
}
+6 -2
View File
@@ -192,8 +192,12 @@ 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)?;
// 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)
}
comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float),
+8 -7
View File
@@ -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,9 +623,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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"));
}
// 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);
@@ -631,7 +632,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
// 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 +643,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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 +660,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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);
+12 -2
View File
@@ -161,7 +161,14 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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 {