Merge pull request #2608 from storytold/fix/2141-header-sized-allocations

Media: header-sized EXR windows and audio sample rates are bounded (#2141)
This commit is contained in:
BFlat
2026-10-30 03:36:36 +07:00
committed by GitHub
2 changed files with 28 additions and 1 deletions
+19 -1
View File
@@ -13,6 +13,10 @@ use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_raster::AuxChannels;
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;
fn is_depth(name: &str) -> bool {
let last = name.rsplit('.').next().unwrap_or(name);
last.eq_ignore_ascii_case("z") || name.to_ascii_lowercase().contains("depth")
@@ -75,7 +79,9 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option<AuxChannels> {
}
};
collect_meta(&img.attributes.other);
let n = w.checked_mul(h)?;
// Each channel is a display-window plane: a 60000² window over a 1×1 data window was a
// multi-gigabyte allocation per channel (#2141).
let n = w.checked_mul(h).filter(|n| *n <= MAX_DISPLAY_PIXELS)?;
// The decoded image is consumed channel by channel, so each channel's samples are freed (or
// moved) as its plane is made instead of the whole file being held twice (#474).
for layer in img.layer_data {
@@ -337,6 +343,18 @@ mod tests {
assert_eq!(aux.get("GlossDir.R").and_then(|p| p.first()).copied(), Some(0.25));
}
/// #2141: one pixel of data in a 60000² display window is refused, not allocated per channel.
#[test]
fn a_huge_display_window_is_refused_not_allocated() {
use exr::prelude::*;
let channels = AnyChannels::sort(vec![AnyChannel::new("R", FlatSamples::F32(vec![0.5]))].into());
let mut image = Image::from_layer(Layer::new((1, 1), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels));
image.attributes.display_window = IntegerBounds::new((0, 0), (60000, 60000));
let mut bytes = std::io::Cursor::new(Vec::new());
image.write().to_buffered(&mut bytes).unwrap();
assert!(read_exr_channels(&bytes.into_inner()).is_none());
}
#[test]
fn layered_exr_without_an_unnamed_rgb_layer_imports() {
use exr::prelude::*;
+9
View File
@@ -67,6 +67,10 @@ const CONFORM_VERSION: u32 = 3;
/// conform thread allocate all of it at once (#408).
const MAX_CONFORM_SECONDS: i64 = 24 * 3600;
/// The sample rates audio is read at; a header outside them is damaged or hostile (#2141).
const MIN_SAMPLE_RATE: u32 = 1_000;
const MAX_SAMPLE_RATE: u32 = 768_000;
/// The sample frames of `footage`'s audio at `rate` Hz that a conformed file holds, `None` past
/// [`MAX_CONFORM_SECONDS`].
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
@@ -513,6 +517,11 @@ impl MediaPool {
let src = self.inner.source(&path).ok_or("the file can't be opened")?;
let s0 = start.to_units_floor(rate as i64);
let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate);
// The header's rate scales the samples read: 0xFFFFFFFF Hz made one conform chunk about
// 47 GB (#2141). Real audio is 1–768 kHz.
if native != 0 && !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&native) {
return Err(format!("a sample rate of {native} Hz is outside {MIN_SAMPLE_RATE}–{MAX_SAMPLE_RATE} Hz"));
}
if native == 0 || rate == 0 || native == rate {
return read_stereo(&src, s0, frames, rate);
}