diff --git a/crates/image-codecs/src/allocation.rs b/crates/image-codecs/src/allocation.rs new file mode 100644 index 000000000..5425a0c24 --- /dev/null +++ b/crates/image-codecs/src/allocation.rs @@ -0,0 +1,194 @@ +//! Predecode bounds for raster containers whose frames may exceed their declared canvas. + +use crate::codecs::webp; +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, SampleType}; +use crate::options::Limits; + +/// The largest width and height any decoded frame can declare, without allocating pixel data. +/// Animated GIF and WebP frames are inspected individually, including frames larger than the +/// animation canvas. Other recognized raster formats use their container's image dimensions. +pub fn probe_allocation_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { + let format = crate::detect(bytes).ok_or(CodecError::UnknownFormat)?; + let (width, height) = match format { + Format::Gif => gif_bounds(bytes)?, + Format::WebP => { + let (width, height, _) = webp::probe(bytes, &Limits::default())?; + let (frame_width, frame_height) = webp_bounds(bytes)?; + (width.max(frame_width), height.max(frame_height)) + } + _ => { + if let Some(info) = crate::inspect_raster(bytes)? { + (info.width, info.height) + } else { + crate::probe_size(bytes)? + } + } + }; + Limits::default().check(width, height, ChannelLayout::Rgba, SampleType::U8)?; + Ok((width, height)) +} + +fn malformed(format: Format, message: &'static str) -> CodecError { + CodecError::malformed(format, message) +} + +fn u16_le(bytes: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(bytes.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn gif_bounds(bytes: &[u8]) -> Result<(u32, u32), CodecError> { + let bad = || malformed(Format::Gif, "truncated or malformed GIF frame table"); + if !bytes.starts_with(b"GIF87a") && !bytes.starts_with(b"GIF89a") { + return Err(bad()); + } + let packed = *bytes.get(10).ok_or_else(bad)?; + let (mut width, mut height) = (u32::from(u16_le(bytes, 6).ok_or_else(bad)?), u32::from(u16_le(bytes, 8).ok_or_else(bad)?)); + let table_len = + if packed & 0x80 != 0 { 3usize.checked_mul(1usize.checked_shl(u32::from((packed & 7) + 1)).ok_or_else(bad)?).ok_or_else(bad)? } else { 0 }; + let mut at = 13usize.checked_add(table_len).ok_or_else(bad)?; + if bytes.get(..at).is_none() || width == 0 || height == 0 { + return Err(bad()); + } + let mut frames = 0usize; + loop { + match bytes.get(at).copied().ok_or_else(bad)? { + 0x3b => break, + 0x21 => { + at = at.checked_add(2).ok_or_else(bad)?; + loop { + let length = usize::from(*bytes.get(at).ok_or_else(bad)?); + at = at.checked_add(1).ok_or_else(bad)?; + if length == 0 { + break; + } + at = at.checked_add(length).ok_or_else(bad)?; + if bytes.get(..at).is_none() { + return Err(bad()); + } + } + } + 0x2c => { + let left = u32::from(u16_le(bytes, at + 1).ok_or_else(bad)?); + let top = u32::from(u16_le(bytes, at + 3).ok_or_else(bad)?); + let frame_width = u32::from(u16_le(bytes, at + 5).ok_or_else(bad)?); + let frame_height = u32::from(u16_le(bytes, at + 7).ok_or_else(bad)?); + let descriptor = bytes.get(at..at.checked_add(10).ok_or_else(bad)?).ok_or_else(bad)?; + let flags = descriptor[9]; + let mut body = at.checked_add(10).ok_or_else(bad)?; + if flags & 0x80 != 0 { + let table = 3usize.checked_mul(1usize.checked_shl(u32::from((flags & 7) + 1)).ok_or_else(bad)?).ok_or_else(bad)?; + body = body.checked_add(table).ok_or_else(bad)?; + } + body = body.checked_add(1).ok_or_else(bad)?; // LZW minimum code size. + if bytes.get(..body).is_none() || frame_width == 0 || frame_height == 0 { + return Err(bad()); + } + width = width.max(left.saturating_add(frame_width)); + height = height.max(top.saturating_add(frame_height)); + at = body; + loop { + let length = usize::from(*bytes.get(at).ok_or_else(bad)?); + at = at.checked_add(1).ok_or_else(bad)?; + if length == 0 { + break; + } + at = at.checked_add(length).ok_or_else(bad)?; + if bytes.get(..at).is_none() { + return Err(bad()); + } + } + frames = frames.saturating_add(1); + } + _ => return Err(bad()), + } + } + if frames == 0 { + return Err(bad()); + } + Ok((width, height)) +} + +fn webp_bounds(bytes: &[u8]) -> Result<(u32, u32), CodecError> { + let bad = || malformed(Format::WebP, "truncated or malformed WebP frame table"); + if bytes.get(0..4) != Some(b"RIFF") || bytes.get(8..12) != Some(b"WEBP") { + return Err(bad()); + } + let riff_len = usize::try_from(u32::from_le_bytes(bytes.get(4..8).ok_or_else(bad)?.try_into().map_err(|_| bad())?)).map_err(|_| bad())?; + let end = 8usize.checked_add(riff_len).ok_or_else(bad)?; + if end > bytes.len() || end < 12 { + return Err(bad()); + } + let mut max_width = 0u32; + let mut max_height = 0u32; + webp_chunks(bytes, 12, end, false, &mut max_width, &mut max_height)?; + if max_width == 0 || max_height == 0 { + return Err(bad()); + } + Ok((max_width, max_height)) +} + +fn webp_chunks(bytes: &[u8], mut at: usize, end: usize, nested: bool, max_width: &mut u32, max_height: &mut u32) -> Result<(), CodecError> { + let bad = || malformed(Format::WebP, "truncated or malformed WebP frame table"); + while at < end { + let header_end = at.checked_add(8).ok_or_else(bad)?; + let header = bytes.get(at..header_end).filter(|_| header_end <= end).ok_or_else(bad)?; + let size = usize::try_from(u32::from_le_bytes(header.get(4..8).ok_or_else(bad)?.try_into().map_err(|_| bad())?)).map_err(|_| bad())?; + let from = header_end; + let to = from.checked_add(size).ok_or_else(bad)?; + let payload = bytes.get(from..to).filter(|_| to <= end).ok_or_else(bad)?; + match header.get(..4).ok_or_else(bad)? { + b"VP8 " => { + if payload.get(3..6) != Some(&[0x9d, 0x01, 0x2a]) || payload.len() < 10 { + return Err(bad()); + } + let w = u32::from(u16::from_le_bytes([payload[6], payload[7]]) & 0x3fff); + let h = u32::from(u16::from_le_bytes([payload[8], payload[9]]) & 0x3fff); + *max_width = (*max_width).max(w); + *max_height = (*max_height).max(h); + } + b"VP8L" => { + if payload.len() < 5 || payload[0] != 0x2f { + return Err(bad()); + } + let bits = u32::from_le_bytes([payload[1], payload[2], payload[3], payload[4]]); + *max_width = (*max_width).max((bits & 0x3fff) + 1); + *max_height = (*max_height).max(((bits >> 14) & 0x3fff) + 1); + } + b"VP8X" => { + if nested || payload.len() < 10 { + return Err(bad()); + } + *max_width = (*max_width).max(read_le24(payload, 4).ok_or_else(bad)?.saturating_add(1)); + *max_height = (*max_height).max(read_le24(payload, 7).ok_or_else(bad)?.saturating_add(1)); + } + b"ANMF" => { + if nested || payload.len() < 16 { + return Err(bad()); + } + let x = read_le24(payload, 0).ok_or_else(bad)?.saturating_mul(2); + let y = read_le24(payload, 3).ok_or_else(bad)?.saturating_mul(2); + let w = read_le24(payload, 6).ok_or_else(bad)?.saturating_add(1); + let h = read_le24(payload, 9).ok_or_else(bad)?.saturating_add(1); + *max_width = (*max_width).max(x.saturating_add(w)); + *max_height = (*max_height).max(y.saturating_add(h)); + webp_chunks(payload, 16, payload.len(), true, max_width, max_height)?; + } + _ => {} + } + at = to.checked_add(size & 1).ok_or_else(bad)?; + if at > end { + return Err(bad()); + } + } + if at != end { + return Err(bad()); + } + Ok(()) +} + +fn read_le24(bytes: &[u8], at: usize) -> Option { + let b = bytes.get(at..at.checked_add(3)?)?; + Some(u32::from_le_bytes([b[0], b[1], b[2], 0])) +} diff --git a/crates/image-codecs/src/lib.rs b/crates/image-codecs/src/lib.rs index 865e59865..e02c438ec 100644 --- a/crates/image-codecs/src/lib.rs +++ b/crates/image-codecs/src/lib.rs @@ -14,6 +14,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod allocation; pub mod cicp; mod codecs; mod error; @@ -29,6 +30,7 @@ pub mod orientation; pub mod resolution; pub mod source_settings; pub mod web; +pub use crate::allocation::probe_allocation_size; pub use crate::cicp::Cicp; pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color}; pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};