fix(media): check still, EXR channel, GIF and WebP canvas sizes before allocating

This commit is contained in:
Dylan M. Taylor
2026-11-03 15:16:21 +00:00
parent 7870b5916a
commit 90e5dc16d3
4 changed files with 105 additions and 0 deletions
+37
View File
@@ -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<AuxChannels> {
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<AuxChannels> {
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<AuxChannels> {
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.
+20
View File
@@ -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}");
}
}
+27
View File
@@ -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<image::ImageFormat>) -> 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<Item = u32>) -> 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]
+21
View File
@@ -157,6 +157,11 @@ pub(crate) fn load(bytes: &[u8]) -> Result<Anim> {
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());
}
}