mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 21:32:52 +00:00
Media: check still, EXR channel, GIF and WebP canvas sizes before allocating
* fix(media): check still, EXR channel, GIF and WebP canvas sizes before allocating * address review: GIF frame allocation limit, lower wasm32 still limit, no EXR fallback after a size-limit error * address review: the 2^25-pixel still limit also applies on 32-bit native targets * Media: raise the still pixel limit to 2^28 (16384 x 16384) on 64-bit targets
This commit is contained in:
1 parent
4d6036265b
commit
a287044644
4 files changed
+109
-1
No files matched your search
@@ -93,7 +93,18 @@ impl Anim {
|
||||
if crate::webp_anim::is_webp(bytes) {
|
||||
return crate::webp_anim::load(bytes);
|
||||
}
|
||||
let dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?;
|
||||
let mut dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?;
|
||||
// A frame may be far larger than the canvas, and the decoder allocates its buffer before
|
||||
// decoding it: no single buffer may pass what all the frames may hold.
|
||||
let mut limits = image::Limits::no_limits();
|
||||
limits.max_alloc = Some(MAX_BYTES as u64);
|
||||
image::ImageDecoder::set_limits(&mut dec, limits).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() {
|
||||
@@ -179,6 +190,17 @@ mod tests {
|
||||
assert_eq!(colour_at(&pool, &f, 5.0), [0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_far_larger_than_the_canvas_is_refused_before_it_is_allocated() {
|
||||
// A 1×1 canvas and a 20000×20000 frame (1.6 GB of RGBA); the data itself is not there.
|
||||
let mut bytes = b"GIF89a".to_vec();
|
||||
// (a two-entry global colour table, so the frame gets as far as its buffer)
|
||||
bytes.extend([1, 0, 1, 0, 0x80, 0, 0, 0, 0, 0, 255, 255, 255]);
|
||||
bytes.extend([0x2c, 0, 0, 0, 0, 0x20, 0x4e, 0x20, 0x4e, 0, 2, 0]);
|
||||
let e = crate::gif_anim::Anim::load(&bytes).err().expect("refused").to_string();
|
||||
assert!(e.to_lowercase().contains("memory"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_delay_is_ten_hundredths_like_in_browsers() {
|
||||
let f = probe_bytes("a.gif", gif(&[(RED, 0), (GREEN, 1)]).into()).unwrap();
|
||||
@@ -212,4 +234,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}");
|
||||
}
|
||||
}
|
||||
@@ -1011,6 +1011,8 @@ impl Inner {
|
||||
crate::exr_channels::count_decode(false);
|
||||
match crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)) {
|
||||
Ok(img) => Ok(dynamic_to_image(&img, op)),
|
||||
// Over the size limit: the layered fallback would allocate its planes anyway.
|
||||
Err(e @ image::ImageError::Limits(_)) => Err(MediaError::Decode(format!("{path}: {e}"))),
|
||||
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
|
||||
Err(e) => crate::exr_channels::layered_picture(path, bytes, &file, op, shared)
|
||||
.map_err(|why| MediaError::Decode(format!("{path}: {}", why.unwrap_or_else(|| e.to_string())))),
|
||||
|
||||
@@ -9,6 +9,15 @@ use image::metadata::Orientation;
|
||||
|
||||
use crate::{MediaError, Result, ext_of, rate_from_fc};
|
||||
|
||||
/// Most pixels a still may decode to: 4 GiB as the renderer's float RGBA (2^28, 16384 × 16384;
|
||||
/// 150-megapixel medium-format frames, stitched panoramas and large scans decode). A browser tab's
|
||||
/// wasm32 heap can't hold one allocation that large (and the decoded input beside it): 512 MiB
|
||||
/// there, and on 32-bit targets, where one allocation may not pass `isize::MAX` (2 GiB).
|
||||
#[cfg(not(any(target_arch = "wasm32", target_pointer_width = "32")))]
|
||||
pub(crate) const MAX_STILL_PIXELS: u64 = 1 << 28;
|
||||
#[cfg(any(target_arch = "wasm32", target_pointer_width = "32"))]
|
||||
pub(crate) const MAX_STILL_PIXELS: u64 = 1 << 25;
|
||||
|
||||
/// Frame rate given to image sequences on import (After Effects' default preference).
|
||||
pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30;
|
||||
|
||||
@@ -305,6 +314,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);
|
||||
@@ -378,6 +393,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]
|
||||
@@ -469,4 +501,21 @@ mod tests {
|
||||
assert_eq!(averaged_rate(u32::MAX, [u32::MAX; 300].into_iter()), None);
|
||||
assert_eq!(video_rate(b"not a movie"), None);
|
||||
}
|
||||
|
||||
/// A 150-megapixel still (a medium-format frame) is not refused for its size: only its (absent)
|
||||
/// pixel data fails, not the dimension check. A still past the limit is.
|
||||
#[cfg(not(any(target_arch = "wasm32", target_pointer_width = "32")))]
|
||||
#[test]
|
||||
fn stills_up_to_the_limit_pass_the_size_check() {
|
||||
let bmp = |w: u32, h: u32| {
|
||||
let mut b = vec![b'B', b'M', 0, 0, 0, 0, 0, 0, 0, 0, 54, 0, 0, 0, 40, 0, 0, 0];
|
||||
b.extend_from_slice(&w.to_le_bytes());
|
||||
b.extend_from_slice(&h.to_le_bytes());
|
||||
b.extend_from_slice(&[1, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
b
|
||||
};
|
||||
let limit = |e: image::ImageError| matches!(e, image::ImageError::Limits(_));
|
||||
assert!(!load_oriented(&bmp(14_000, 10_800), None).err().is_some_and(limit), "150 MP passes the check");
|
||||
assert!(load_oriented(&bmp(20_000, 20_000), None).err().is_some_and(limit), "400 MP is refused");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user