diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index d372ec849..7869fc653 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -16,6 +16,13 @@ use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, split_channel}; /// The largest display window decoded (16384²; vector footage and Continuously Rasterize /// stop at the same size). const MAX_DISPLAY_PIXELS: usize = 1 << 28; +/// Samples all of a file's display-window planes may hold together (4 GiB of f32; 256 MiB in a +/// browser tab's wasm32 heap): each channel is expanded to the display window, so many channels +/// of a small data window would otherwise multiply into far more than the file holds. +#[cfg(not(target_arch = "wasm32"))] +const MAX_PLANE_SAMPLES: usize = 1 << 30; +#[cfg(target_arch = "wasm32")] +const MAX_PLANE_SAMPLES: usize = 1 << 26; fn is_depth(name: &str) -> bool { let last = name.rsplit('.').next().unwrap_or(name); @@ -55,6 +62,11 @@ pub fn unsupported_compression(bytes: &[u8]) -> Option<&'static str> { /// Read all channels of an EXR file's bytes. `None` when it is not a readable EXR. pub fn read_exr_channels(bytes: &[u8]) -> Option { + read_exr_channels_within(bytes, MAX_PLANE_SAMPLES) +} + +/// [`read_exr_channels`] keeping at most `samples_left` samples of planes. +fn read_exr_channels_within(bytes: &[u8], mut samples_left: usize) -> Option { use exr::prelude::*; let img = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(std::io::Cursor::new(bytes)).ok()?; let dw = img.attributes.display_window; @@ -93,6 +105,11 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option { for ch in layer.channel_data.list { let name = channel_name(prefix.as_deref(), &ch.name.to_string()); let fill = if is_depth(&name) { BACKGROUND_DEPTH } else { 0.0 }; + let Some(left) = samples_left.checked_sub(n) else { + log::warn!("media: EXR channel `{name}` skipped: the channels would hold more than {MAX_PLANE_SAMPLES} samples"); + continue; + }; + samples_left = left; if let Some(plane) = place(ch.sample_data, size, offset, (w, h), n, fill) { aux.channels.push((name, plane)); } @@ -294,6 +311,26 @@ mod tests { assert!(read_exr_channels(b"not an exr").is_none()); } + #[test] + fn channel_planes_share_one_sample_budget() { + use exr::prelude::*; + let (w, h) = (4usize, 2usize); + let channels = AnyChannels::sort( + vec![ + AnyChannel::new("A", FlatSamples::F32(vec![1.0; w * h])), + AnyChannel::new("B", FlatSamples::F32(vec![2.0; w * h])), + AnyChannel::new("C", FlatSamples::F32(vec![3.0; w * h])), + ] + .into(), + ); + let image = Image::from_layer(Layer::new((w, h), LayerAttributes::named("l"), Encoding::FAST_LOSSLESS, channels)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + assert_eq!(read_exr_channels_within(bytes.get_ref(), 3 * w * h).unwrap().channels.len(), 3); + // Room for two planes: the third channel is skipped, not allocated. + assert_eq!(read_exr_channels_within(bytes.get_ref(), 3 * w * h - 1).unwrap().channels.len(), 2); + } + /// #474: a full-window f32 channel (Cryptomatte ranks, …) becomes its plane without a copy, /// so a decode no longer holds the file's samples twice; other channels are placed in the /// display window as before. diff --git a/crates/media/src/gif_anim.rs b/crates/media/src/gif_anim.rs index 09c0cda34..fe0489f4a 100644 --- a/crates/media/src/gif_anim.rs +++ b/crates/media/src/gif_anim.rs @@ -94,6 +94,12 @@ impl Anim { return crate::webp_anim::load(bytes); } let dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?; + // The decoder allocates the full logical canvas for the first frame, however small the + // frames themselves are: check it before decoding any. + let (w, h) = image::ImageDecoder::dimensions(&dec); + if u64::from(w) * u64::from(h) * 4 > MAX_BYTES as u64 { + return Err(MediaError::Unsupported(format!("GIF canvas of {w}×{h} is too large to hold in memory"))); + } let (mut frames, mut starts) = (Vec::new(), Vec::new()); let (mut at, mut size) = (0i64, 0usize); for frame in dec.into_frames() { @@ -212,4 +218,18 @@ mod tests { assert!(pool.frame_at(&f, Tick::ZERO).is_err()); let _ = probe_bytes("a.gif", bytes.into()); } + + #[test] + fn a_huge_canvas_is_refused_before_decoding() { + // A 65535×65535 logical screen (16 GiB of RGBA) holding one 1×1 frame. + let mut out = Vec::new(); + { + let mut enc = gif::Encoder::new(&mut out, u16::MAX, u16::MAX, &[]).unwrap(); + for _ in 0..2 { + enc.write_frame(&gif::Frame::from_rgb(1, 1, &RED)).unwrap(); + } + } + let e = super::Anim::load(&out).err().unwrap(); + assert!(e.to_string().contains("too large"), "{e}"); + } } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6bae86f5b..566af1621 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -9,6 +9,10 @@ use image::metadata::Orientation; use crate::{MediaError, Result, ext_of, rate_from_fc}; +/// Most pixels a still may decode to: 2 GiB as the renderer's float RGBA (16384 × 8192, beyond +/// any camera's still). +pub(crate) const MAX_STILL_PIXELS: u64 = 1 << 27; + /// Frame rate given to image sequences on import (After Effects' default preference). pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30; @@ -286,6 +290,12 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> im None => image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?, }; let mut dec = reader.into_decoder()?; + // `from_decoder` skips the reader's allocation limit, and some decoders (BMP) allocate + // whatever size their header claims: refuse a still the renderer could not hold as floats. + let (w, h) = dec.dimensions(); + if u64::from(w) * u64::from(h) > MAX_STILL_PIXELS { + return Err(image::ImageError::Limits(image::error::LimitError::from_kind(image::error::LimitErrorKind::DimensionError))); + } let orientation = dec.orientation().unwrap_or(Orientation::NoTransforms); let mut img = image::DynamicImage::from_decoder(dec)?; img.apply_orientation(orientation); @@ -359,6 +369,23 @@ fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option mod tests { use super::*; + #[test] + fn a_still_larger_than_the_renderer_holds_is_refused_before_decoding() { + // A 24-bit BMP header claiming 20000×20000 pixels (within the BMP decoder's own limit) + // over no pixel data. + let mut bmp = b"BM".to_vec(); + bmp.extend(54u32.to_le_bytes()); + bmp.extend([0u8; 4]); + bmp.extend(54u32.to_le_bytes()); + bmp.extend(40u32.to_le_bytes()); + bmp.extend(20_000i32.to_le_bytes()); + bmp.extend(20_000i32.to_le_bytes()); + bmp.extend(1u16.to_le_bytes()); + bmp.extend(24u16.to_le_bytes()); + bmp.extend([0u8; 24]); + assert!(matches!(load_oriented(&bmp, Some(image::ImageFormat::Bmp)), Err(image::ImageError::Limits(_)))); + } + /// #2592: an encoder-delayed H.264 track's edit list shows 75 frames at 25 fps (3 s at a /// 1000 timescale), though its media runs a frame longer; empty edits and no edits leave it. #[test] diff --git a/crates/media/src/webp_anim.rs b/crates/media/src/webp_anim.rs index 9420be1ed..f0784d1e3 100644 --- a/crates/media/src/webp_anim.rs +++ b/crates/media/src/webp_anim.rs @@ -157,6 +157,11 @@ pub(crate) fn load(bytes: &[u8]) -> Result { return Err(MediaError::Unsupported(format!("animated WebP of {} frames of {}×{} is too large to hold in memory", h.delays.len(), h.width, h.height))); } let dec = WebPDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("WebP: {e}")))?; + // The budget above used the canvas this file's header scan found; the decoder must agree, or + // a second VP8X header could make it allocate a far larger canvas. + if image::ImageDecoder::dimensions(&dec) != (h.width, h.height) { + return Err(MediaError::Decode("WebP: conflicting canvas sizes".into())); + } let (mut frames, mut delays, mut size) = (Vec::new(), Vec::new(), 0usize); for frame in dec.into_frames() { let frame = match frame { @@ -326,4 +331,20 @@ mod tests { let e = super::load(&bytes).err().unwrap(); assert!(e.to_string().contains("too large"), "{e}"); } + + #[test] + fn conflicting_canvas_headers_are_refused() { + // The first VP8X claims 16384×16384; a second one, which the header scan keeps, 1×1. + let mut bytes = webp(1, 1, &[(RED, 50); 2]); + bytes[24..27].copy_from_slice(&u24(16383)); + bytes[27..30].copy_from_slice(&u24(16383)); + let mut small = vec![0x12, 0, 0, 0]; + small.extend_from_slice(&u24(0)); + small.extend_from_slice(&u24(0)); + let second = chunk(b"VP8X", &small); + bytes.splice(30..30, second.iter().copied()); + let riff = u32::try_from(bytes.len() - 8).unwrap(); + bytes[4..8].copy_from_slice(&riff.to_le_bytes()); + assert!(super::load(&bytes).is_err()); + } }