diff --git a/crates/codecs/src/avi.rs b/crates/codecs/src/avi.rs index e4622bc42..8855c3a65 100644 --- a/crates/codecs/src/avi.rs +++ b/crates/codecs/src/avi.rs @@ -337,10 +337,12 @@ impl AviSource { let mut audio_streams = Vec::new(); for &s in &astreams { let Some(st) = file.streams.get(s) else { continue }; - let w = st.audio.clone().unwrap_or_default(); + let mut w = st.audio.clone().unwrap_or_default(); + let Ok((sample_rate, channels)) = filmcraft_media::bounded_audio_dimensions(w.sample_rate, u32::from(w.channels)) else { continue }; + w.sample_rate = sample_rate; + w.channels = u16::try_from(channels).unwrap_or(u16::MAX); let (codec, label) = audio_codec(&w); - let mut sample_rate = w.sample_rate.max(1); - let mut channels = u32::from(w.channels.max(1)); + let (mut sample_rate, mut channels) = (sample_rate, channels); let (pieces, first, starts) = match &codec { ACodec::Pcm(cfg) => { let block = usize::from(w.block_align.max(1)).max((usize::from(cfg.bits).div_ceil(8)) * channels as usize); @@ -537,14 +539,18 @@ impl MediaSource for AviSource { return Err(MediaError::Unsupported(format!("{name} audio"))); } let ch = ainfo.channels.max(1) as usize; - let ratio = f64::from(ainfo.sample_rate) / f64::from(sample_rate.max(1)); + let source_frames = filmcraft_media::resample_source_frames(frames, ainfo.sample_rate, sample_rate)?; + let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?; + filmcraft_media::validate_audio_buffer_shape(ch, need)?; + filmcraft_media::validate_audio_buffer_shape(ch, frames)?; + let ratio = f64::from(ainfo.sample_rate) / f64::from(sample_rate); let s0 = (start as f64 * ratio).floor() as i64; - let need = (frames as f64 * ratio).ceil() as i64 + 2; - let mut src: Vec> = vec![vec![0.0; need.max(0) as usize]; ch]; + let need_i64 = i64::try_from(need).unwrap_or(i64::MAX); + let mut src: Vec> = vec![vec![0.0; need]; ch]; let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner()); let mut i = a.starts.partition_point(|&x| x <= s0).saturating_sub(1); while let Some(&pk_start) = a.starts.get(i) { - if pk_start >= s0 + need { + if pk_start >= s0.saturating_add(need_i64) { break; } let pk = self.audio_packet(a, &mut st, ainfo, i)?; diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index f2083b6d4..bdc8dc4fe 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -142,6 +142,20 @@ fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 { i64::try_from((t.codec_delay_ns as i128 + scale_ns / 2) / scale_ns).unwrap_or(i64::MAX) } +/// The stream description of an audio track, `None` when its advertised sample rate or channel +/// count is out of range (see [`filmcraft_media::bounded_audio_dimensions`]). +fn audio_info(t: &filmcraft_matroska::Track) -> Option { + let a = t.audio.clone().unwrap_or_default(); + let mut rate = a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round().max(1.0) as u32; + let mut channels = (a.channels as u32).max(1); + if let Some(h) = opus_head(&t.codec) { + rate = crate::audio::OPUS_RATE; + channels = h.channels as u32; + } + let (rate, channels) = filmcraft_media::bounded_audio_dimensions(rate, channels).ok()?; + Some(AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) }) +} + /// The track timestamp that media time zero stands for (#715): the earliest start among the /// playable tracks. Block timestamps count from the start of the Segment, and a remux that keeps /// its source's timestamps (`ffmpeg -copyts`, a cut from a recording) starts its first picture @@ -339,7 +353,9 @@ impl MkvSource { .tracks .iter() .enumerate() - .filter(|(_, t)| t.kind == TrackKind::Audio && !t.samples.is_empty()) + // A track whose advertised dimensions are refused is no stream at all, so `atracks[k]` and + // `info.audio_streams[k]` stay the same track. + .filter(|(_, t)| t.kind == TrackKind::Audio && !t.samples.is_empty() && audio_info(t).is_some()) .map(|(i, _)| i) .take(filmcraft_media::MAX_AUDIO_STREAMS) .collect(); @@ -407,20 +423,7 @@ impl MkvSource { hdr: v.color.as_ref().and_then(hdr_metadata), } }); - let audio_streams: Vec = atracks - .iter() - .filter_map(|&i| file.tracks.get(i)) - .map(|t| { - let a = t.audio.clone().unwrap_or_default(); - let mut rate = a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round().max(1.0) as u32; - let mut channels = (a.channels as u32).max(1); - if let Some(h) = opus_head(&t.codec) { - rate = crate::audio::OPUS_RATE; - channels = h.channels as u32; - } - AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) } - }) - .collect(); + let audio_streams: Vec = atracks.iter().filter_map(|&i| file.tracks.get(i)).filter_map(audio_info).collect(); let info = MediaInfo { name: name.to_string(), kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly }, @@ -628,15 +631,19 @@ impl MediaSource for MkvSource { let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?; let src_rate = ainfo.sample_rate; let ch = ainfo.channels.max(1) as usize; + let source_frames = filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)?; + let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?; + filmcraft_media::validate_audio_buffer_shape(ch, need)?; + filmcraft_media::validate_audio_buffer_shape(ch, frames)?; let ratio = src_rate as f64 / sample_rate as f64; let s0 = (start as f64 * ratio).floor() as i64; - let need = (frames as f64 * ratio).ceil() as i64 + 2; - let mut src: Vec> = vec![vec![0.0; need.max(0) as usize]; ch]; + let need_i64 = i64::try_from(need).unwrap_or(i64::MAX); + let mut src: Vec> = vec![vec![0.0; need]; ch]; let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner()); let mut i = a.starts.partition_point(|&x| x <= s0).saturating_sub(1); while i < n_packets { let Some(&pk_start) = a.starts.get(i) else { break }; - if pk_start >= s0 + need { + if pk_start >= s0.saturating_add(need_i64) { break; } let pk = self.audio_packet(a, ainfo, &mut st, i)?; diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index edaee154f..de2beb7d1 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -484,20 +484,24 @@ impl MediaSource for Mp4Source { let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?; let src_rate = ainfo.sample_rate; let ch = ainfo.channels.max(1) as usize; + let source_frames = filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)?; + let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?; + filmcraft_media::validate_audio_buffer_shape(ch, need)?; + filmcraft_media::validate_audio_buffer_shape(ch, frames)?; // Map the requested window to source samples (edit list offset applied: presentation = pts + edit_offset). let ratio = src_rate as f64 / sample_rate as f64; let p0 = (start as f64 * ratio).floor() as i64; // (in i128: a hostile edit list can make the offset large enough to overflow i64) let offset = i128::from(a.offset) * i128::from(src_rate) / i128::from(track.timescale.max(1)); let s0 = i64::try_from(i128::from(p0) - offset).unwrap_or(if offset > 0 { i64::MIN } else { i64::MAX }); - let need = (frames as f64 * ratio).ceil() as i64 + 2; - let mut src: Vec> = vec![vec![0.0; need.max(0) as usize]; ch]; + let need_i64 = i64::try_from(need).unwrap_or(i64::MAX); + let mut src: Vec> = vec![vec![0.0; need]; ch]; let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner()); let first = a.starts.partition_point(|&x| x <= s0.max(0)).saturating_sub(1); let mut i = first; while let Some(sample) = track.samples.get(i) { let Some(&pk_start) = a.starts.get(i) else { break }; - if pk_start >= s0.saturating_add(need) { + if pk_start >= s0.saturating_add(need_i64) { break; } let pk = self.audio_packet(a, ainfo, &mut st, i)?; @@ -589,7 +593,8 @@ fn audio_stream_info(t: &filmcraft_isobmff::Track, bytes: &crate::Src) -> Option } c => codec_label(c), }; - Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec, bits_per_sample: bits }) + let (rate, ch) = filmcraft_media::bounded_audio_dimensions(rate, ch).ok()?; + Some(AudioStreamInfo { sample_rate: rate, channels: ch, codec, bits_per_sample: bits }) } fn codec_label(c: &CodecConfig) -> String { diff --git a/crates/codecs/src/mxf.rs b/crates/codecs/src/mxf.rs index cf8900a10..e94aeaa32 100644 --- a/crates/codecs/src/mxf.rs +++ b/crates/codecs/src/mxf.rs @@ -330,7 +330,7 @@ impl MxfSource { if let Some(&a0) = atracks.first() { let si0 = file.tracks[a0].sound.clone().unwrap_or_default(); let rate = si0.sample_rate; - let mut channels = 0; + let mut channels: u32 = 0; for &ai in &atracks { let t = &file.tracks[ai]; let Some(si) = t.sound.as_ref() else { continue }; @@ -342,11 +342,18 @@ impl MxfSource { let stored = t.stored_sample_frames().saturating_sub(first); let presented = t.duration.map_or(stored, |du| ((du as i128 * n as i128 / d.max(1) as i128) as u64).min(stored)); aframes = aframes.min(presented); - channels += si.channels.max(1); + let next = + channels.checked_add(si.channels.max(1)).ok_or_else(|| CodecError::Unsupported("MXF: combined audio channel count overflows".into()))?; + if next > filmcraft_media::MAX_AUDIO_CHANNELS { + return Err(CodecError::Unsupported(format!("MXF: combined audio exceeds {} channels", filmcraft_media::MAX_AUDIO_CHANNELS))); + } + channels = next; combined.push((ai, first)); } let sr = if rate.is_valid() { (rate.num as f64 / rate.den as f64).round() as u32 } else { 48_000 }; - audio = Some(AudioStreamInfo { sample_rate: sr.max(1), channels, codec: file.tracks[a0].codec.name().into(), bits_per_sample: Some(si0.bits) }); + if let Ok((sr, channels)) = filmcraft_media::bounded_audio_dimensions(sr, channels) { + audio = Some(AudioStreamInfo { sample_rate: sr, channels, codec: file.tracks[a0].codec.name().into(), bits_per_sample: Some(si0.bits) }); + } } if aframes == u64::MAX { aframes = 0; @@ -461,15 +468,20 @@ impl MediaSource for MxfSource { let ainfo = self.info.audio().ok_or(MediaError::NoStream("audio"))?; let src_rate = ainfo.sample_rate; let ch = ainfo.channels.max(1) as usize; + let exact = src_rate == sample_rate; + let source_frames = if exact { frames } else { filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)? }; + let need = + if exact { source_frames } else { source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))? }; + filmcraft_media::validate_audio_buffer_shape(ch, need)?; + filmcraft_media::validate_audio_buffer_shape(ch, frames)?; let mut out = AudioBuffer::silence(sample_rate, ch, frames); let ratio = src_rate as f64 / sample_rate as f64; - let exact = src_rate == sample_rate; let s0 = if exact { start } else { (start as f64 * ratio).floor() as i64 }; - let need = if exact { frames as i64 } else { (frames as f64 * ratio).ceil() as i64 + 2 }; + let need_i64 = i64::try_from(need).unwrap_or(i64::MAX); // clip the request to the presented range [0, aframes) let a = s0.max(0); - let b = (s0 + need).min(self.aframes as i64); - let mut src: Vec> = vec![vec![0.0; need.max(0) as usize]; ch]; + let b = s0.saturating_add(need_i64).min(self.aframes as i64); + let mut src: Vec> = vec![vec![0.0; need]; ch]; if b > a { let mut c0 = 0; for &(ti, first) in &self.atracks { diff --git a/crates/gpu/src/blend_tests.rs b/crates/gpu/src/blend_tests.rs index a21c5a2b7..028ceb5ec 100644 --- a/crates/gpu/src/blend_tests.rs +++ b/crates/gpu/src/blend_tests.rs @@ -151,7 +151,8 @@ fn gpu_blend_modes_match_cpu() { 1.0, 24, filmcraft_time::FrameRate::FPS_24, - ); + ) + .unwrap(); let demo = Arc::new(VideoFrame::rgba8(320, 180, pixels)); let base = ramp_layer(200, 180, 3); let top = ramp_layer(160, 120, 0); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 034333ee3..ff7dc97c3 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -116,7 +116,8 @@ fn gpu_matches_cpu_plan() { 1.0, 24, filmcraft_time::FrameRate::FPS_24, - ); + ) + .unwrap(); let rgba = Arc::new(VideoFrame::rgba8(320, 180, pixels)); let (w, h) = (320usize, 180usize); let plan = FramePlan::Layers { @@ -464,7 +465,8 @@ fn gpu_draws_yuv_alpha_plane_like_the_cpu() { 1.0, 24, filmcraft_time::FrameRate::FPS_24, - ); + ) + .unwrap(); let background = Arc::new(VideoFrame::rgba8(320, 180, pixels)); for bits in [8, 10, 12] { let mut c = GpuCompositor::new(&dev, &q); diff --git a/crates/media/examples/demo_frames.rs b/crates/media/examples/demo_frames.rs index 1d1b4a24e..697f8b234 100644 --- a/crates/media/examples/demo_frames.rs +++ b/crates/media/examples/demo_frames.rs @@ -12,7 +12,7 @@ fn main() { let rows = gens.len().div_ceil(cols); let mut sheet = image::RgbaImage::new(w * cols as u32, h * rows as u32); for (i, g) in gens.iter().enumerate() { - let px = render(g, w, h, 3.3, 79, FrameRate::FPS_23_976); + let px = render(g, w, h, 3.3, 79, FrameRate::FPS_23_976).unwrap(); let img = image::RgbaImage::from_raw(w, h, px).unwrap(); image::imageops::overlay(&mut sheet, &img, ((i % cols) as u32 * w) as i64, ((i / cols) as u32 * h) as i64); } diff --git a/crates/media/src/generators.rs b/crates/media/src/generators.rs index 50bdf21a3..482652438 100644 --- a/crates/media/src/generators.rs +++ b/crates/media/src/generators.rs @@ -19,6 +19,10 @@ use serde::{Deserialize, Serialize}; use crate::digits::{draw_text, text_width}; use crate::{AudioStreamInfo, FrameCache, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, VideoStreamInfo}; +/// Generator frames are RGBA8 and cached. Keep one project-requested frame within the media +/// working-set budget before allocating it. +const MAX_GENERATOR_PIXELS: u64 = 1 << 25; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum DemoScene { Aurora, @@ -153,7 +157,7 @@ impl MediaSource for GeneratorSource { let (w, h) = (((v.width as f32 * s).round() as u32).max(2), ((v.height as f32 * s).round() as u32).max(2)); let t = rate.tick_of(frame).seconds() as f32; self.cache.get_or_insert_with((frame, bucket), || { - let px = render(&self.generator, w, h, t, frame, rate); + let px = render(&self.generator, w, h, t, frame, rate)?; Ok(Arc::new(VideoFrame::rgba8(w, h, px).with_pts(rate.tick_of(frame)))) }) } @@ -291,9 +295,16 @@ fn to8(c: [f32; 3]) -> [u8; 4] { } /// Render a generator frame as straight-alpha sRGB RGBA8. -pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate) -> Vec { +pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate) -> Result> { + let pixels = u64::from(w).checked_mul(u64::from(h)).ok_or_else(|| MediaError::Decode("generator frame dimensions overflow".into()))?; + if pixels > MAX_GENERATOR_PIXELS { + return Err(MediaError::Decode(format!("generator frame has {pixels} pixels; limit is {MAX_GENERATOR_PIXELS}"))); + } let (wu, hu) = (w as usize, h as usize); - let mut px = vec![0u8; wu * hu * 4]; + let len = wu.checked_mul(hu).and_then(|n| n.checked_mul(4)).ok_or_else(|| MediaError::Decode("generator frame byte count overflows".into()))?; + let mut px = Vec::new(); + px.try_reserve_exact(len).map_err(|_| MediaError::Decode("cannot allocate generator frame".into()))?; + px.resize(len, 0); match g { Generator::BarsAndTone => bars(&mut px, wu, hu), Generator::ColorMatte { color } => { @@ -315,7 +326,7 @@ pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate }); } } - px + Ok(px) } fn bars(px: &mut [u8], w: usize, h: usize) { @@ -558,13 +569,14 @@ mod tests { let g = GeneratorSource::demo(DemoScene::OceanSunset); let a = g.video_frame(FrameRequest { time: Tick(TICKS_PER_SECOND), scale: 0.25 }).unwrap(); assert_eq!((a.width, a.height), (480, 270)); - let b = render(&Generator::Demo(DemoScene::OceanSunset), 480, 270, FrameRate::FPS_23_976.tick_of(23).seconds() as f32, 23, FrameRate::FPS_23_976); + let b = + render(&Generator::Demo(DemoScene::OceanSunset), 480, 270, FrameRate::FPS_23_976.tick_of(23).seconds() as f32, 23, FrameRate::FPS_23_976).unwrap(); assert_eq!(a.to_rgba8(), b); } #[test] fn bars_values() { - let px = render(&Generator::BarsAndTone, 800, 600, 0.0, 0, FrameRate::FPS_25); + let px = render(&Generator::BarsAndTone, 800, 600, 0.0, 0, FrameRate::FPS_25).unwrap(); // first bar after the side panel is 75% white let i = (10 * 800 + 150) * 4; assert_eq!(&px[i..i + 4], &[191, 191, 191, 255]); @@ -685,10 +697,16 @@ mod tests { #[test] fn all_scenes_render() { for s in DemoScene::ALL { - let px = render(&Generator::Demo(s), 64, 36, 1.5, 36, FrameRate::FPS_24); + let px = render(&Generator::Demo(s), 64, 36, 1.5, 36, FrameRate::FPS_24).unwrap(); assert!(px.as_chunks::<4>().0.iter().any(|p| p[0] > 20 || p[1] > 20 || p[2] > 20)); } - let px = render(&Generator::CountingLeader, 64, 36, 1.5, 36, FrameRate::FPS_24); + let px = render(&Generator::CountingLeader, 64, 36, 1.5, 36, FrameRate::FPS_24).unwrap(); assert_eq!(px.len(), 64 * 36 * 4); } + + #[test] + fn oversized_generator_frames_are_rejected_before_allocation() { + let err = render(&Generator::BlackVideo, 32_768, 32_768, 0.0, 0, FrameRate::FPS_24).unwrap_err(); + assert!(err.to_string().contains("limit"), "{err}"); + } } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 069c550a8..ea8c4a7d7 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -53,6 +53,59 @@ pub enum MediaError { pub type Result = std::result::Result; +/// Up to 384 kHz audio (surpassing professional 192 kHz formats). +pub const MAX_AUDIO_SAMPLE_RATE: u32 = 384_000; +/// Up to 64 discrete channels (exceeding standard 32-channel immersive layouts). +pub const MAX_AUDIO_CHANNELS: u32 = 64; +/// Bounded planar audio buffer: 64 channels * 192 kHz * 10 seconds of f32 is ~492 MiB. +pub const MAX_AUDIO_BUFFER_BYTES: usize = 512 * 1024 * 1024; + +/// Bound container-advertised audio stream dimensions before they are trusted by the app or +/// allocator. +/// +/// Out-of-range values are rejected rather than clamped: publishing a lower sample rate without +/// resampling the samples would make the stream play at the wrong speed, and a clamped channel +/// count would misdescribe the interleaving. +pub fn bounded_audio_dimensions(sample_rate: u32, channels: u32) -> Result<(u32, u32)> { + if sample_rate == 0 || sample_rate > MAX_AUDIO_SAMPLE_RATE { + return Err(MediaError::Decode(format!("unsupported audio sample rate {sample_rate}"))); + } + if channels == 0 || channels > MAX_AUDIO_CHANNELS { + return Err(MediaError::Decode(format!("unsupported audio channel count {channels}"))); + } + Ok((sample_rate, channels)) +} + +/// Check an adapter-owned planar `f32` audio allocation before constructing it. +pub fn validate_audio_buffer_shape(channels: usize, frames: usize) -> Result<()> { + if channels == 0 || channels > MAX_AUDIO_CHANNELS as usize { + return Err(MediaError::Decode(format!("unsupported audio channel count {channels}"))); + } + let bytes = channels + .checked_mul(frames) + .and_then(|samples| samples.checked_mul(std::mem::size_of::())) + .ok_or_else(|| MediaError::Decode("audio buffer dimensions overflow".into()))?; + if bytes > MAX_AUDIO_BUFFER_BYTES { + return Err(MediaError::Decode(format!("audio buffer of {bytes} bytes exceeds the {} byte limit", MAX_AUDIO_BUFFER_BYTES))); + } + Ok(()) +} + +/// Number of source frames needed to resample `frames` output frames. Arithmetic is checked so +/// corrupted rates cannot wrap into a small allocation. +pub fn resample_source_frames(frames: usize, source_rate: u32, output_rate: u32) -> Result { + if output_rate == 0 { + return Err(MediaError::Decode("zero requested audio sample rate".into())); + } + let source = usize::try_from(source_rate).map_err(|_| MediaError::Decode("invalid source sample rate".into()))?; + let output = usize::try_from(output_rate).map_err(|_| MediaError::Decode("invalid output sample rate".into()))?; + frames + .checked_mul(source) + .and_then(|n| n.checked_add(output.saturating_sub(1))) + .map(|n| n / output) + .ok_or_else(|| MediaError::Decode("resampled audio window overflows".into())) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum MediaKind { /// Video, possibly with audio. @@ -257,4 +310,15 @@ mod importable_tests { } assert!(!is_importable(Path::new("notes.txt"))); } + + #[test] + fn hostile_audio_dimensions_are_bounded_before_adapters_allocate() { + assert!(bounded_audio_dimensions(0, 0).is_err()); + assert!(bounded_audio_dimensions(u32::MAX, u32::MAX).is_err()); + assert!(bounded_audio_dimensions(MAX_AUDIO_SAMPLE_RATE + 1, 2).is_err()); + assert_eq!(bounded_audio_dimensions(48_000, 2).ok(), Some((48_000, 2))); + assert!(validate_audio_buffer_shape(MAX_AUDIO_CHANNELS as usize, MAX_AUDIO_BUFFER_BYTES / 4 / MAX_AUDIO_CHANNELS as usize).is_ok()); + assert!(validate_audio_buffer_shape(MAX_AUDIO_CHANNELS as usize, MAX_AUDIO_BUFFER_BYTES / 4 / MAX_AUDIO_CHANNELS as usize + 1).is_err()); + assert!(resample_source_frames(usize::MAX, MAX_AUDIO_SAMPLE_RATE, 1).is_err()); + } } diff --git a/crates/media/src/wav.rs b/crates/media/src/wav.rs index a15f0d6b2..94da63438 100644 --- a/crates/media/src/wav.rs +++ b/crates/media/src/wav.rs @@ -115,7 +115,13 @@ impl WavSource { let (tag, ch, rate, bits) = fmt.ok_or_else(|| MediaError::Decode(format!("{name}: missing fmt chunk")))?; let (off, dlen) = data.ok_or_else(|| MediaError::Decode(format!("{name}: missing data chunk")))?; let float = tag == 3; - if !(tag == 1 || float) || ch == 0 || !matches!(bits, 8 | 16 | 24 | 32 | 64) { + if !(tag == 1 || float) + || ch == 0 + || rate == 0 + || u32::from(ch) > crate::MAX_AUDIO_CHANNELS + || rate > crate::MAX_AUDIO_SAMPLE_RATE + || !matches!(bits, 8 | 16 | 24 | 32 | 64) + { return Err(MediaError::Unsupported(format!("{name}: WAV format tag {tag}, {bits} bits"))); } let frame_bytes = u64::from(ch) * u64::from(bits / 8); @@ -191,6 +197,7 @@ impl MediaSource for WavSource { } fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result { let total = self.frames; + crate::validate_audio_buffer_shape(self.channels, frames)?; let mut out = AudioBuffer::silence(sample_rate, self.channels, frames); if total == 0 || sample_rate == 0 { return Ok(out); @@ -343,4 +350,15 @@ mod tests { let src = WavSource::parse("short.wav", with_chunk(&wav, b"bext", &[0u8; 100]).into()).unwrap(); assert_eq!(src.time_reference(), None); } + + #[test] + fn hostile_header_dimensions_are_rejected_before_publishing_or_reading() { + let mut wav = write_wav16(&[0.0; 2], 1, 48_000); + // `fmt ` starts at byte 12: channels at +22, rate at +24. + wav[22..24].copy_from_slice(&u16::MAX.to_le_bytes()); + assert!(WavSource::parse("wide.wav", wav.into()).is_err()); + let mut wav = write_wav16(&[0.0; 2], 1, 48_000); + wav[24..28].copy_from_slice(&(crate::MAX_AUDIO_SAMPLE_RATE + 1).to_le_bytes()); + assert!(WavSource::parse("fast.wav", wav.into()).is_err()); + } } diff --git a/crates/render/src/image.rs b/crates/render/src/image.rs index 449560fcf..62c9d50ff 100644 --- a/crates/render/src/image.rs +++ b/crates/render/src/image.rs @@ -11,14 +11,38 @@ pub struct Image { } impl Image { + pub const MAX_PIXELS: usize = 268_435_456; + pub fn new(w: usize, h: usize) -> Self { - Self { w, h, px: vec![0.0; w * h * 4] } + let len = w.checked_mul(h).filter(|&px| px <= Self::MAX_PIXELS).and_then(|px| px.checked_mul(4)).unwrap_or(0); + let (w, h, px) = if len == 0 && (w > 0 || h > 0) { + (0, 0, Vec::new()) + } else { + let mut px = Vec::new(); + if px.try_reserve_exact(len).is_ok() { + px.resize(len, 0.0); + (w, h, px) + } else { + (0, 0, Vec::new()) + } + }; + Self { w, h, px } } pub fn filled(w: usize, h: usize, c: [f32; 4]) -> Self { - let mut px = Vec::with_capacity(w * h * 4); - for _ in 0..w * h { - px.extend_from_slice(&c); - } + let len = w.checked_mul(h).filter(|&px| px <= Self::MAX_PIXELS).and_then(|px| px.checked_mul(4)).unwrap_or(0); + let (w, h, px) = if len == 0 && (w > 0 || h > 0) { + (0, 0, Vec::new()) + } else { + let mut px = Vec::new(); + if px.try_reserve_exact(len).is_ok() { + for _ in 0..w * h { + px.extend_from_slice(&c); + } + (w, h, px) + } else { + (0, 0, Vec::new()) + } + }; Self { w, h, px } } #[inline] @@ -83,6 +107,9 @@ impl Image { /// Resample `self` into a `w`×`h` image through `m` (maps source pixels → destination pixels). /// Uses bilinear filtering, with a box pre-filter (mip level) when minifying by more than 2×. pub fn transformed(&self, w: usize, h: usize, m: &Affine) -> Image { + if self.w == 0 || self.h == 0 || w == 0 || h == 0 { + return Image::new(w, h); + } let Some(inv) = m.inverse() else { return Image::new(w, h) }; // minification factor let sx = (m.a * m.a + m.b * m.b).sqrt(); @@ -93,6 +120,9 @@ impl Image { let m2 = m.then_apply(&Affine::scale(2.0, 2.0)); return half.transformed(w, h, &m2); } + if w == 0 || h == 0 { + return Image::new(0, 0); + } let mut out = Image::new(w, h); // Destination bounds of the source rect, to skip empty rows/cols. let b = m.bounds(&filmcraft_geom::Rect::new(0.0, 0.0, self.w as f64, self.h as f64)); @@ -119,6 +149,9 @@ impl Image { /// Half-size box downsample. pub fn downsample2(&self) -> Image { + if self.w == 0 || self.h == 0 { + return Image::new(0, 0); + } let (w, h) = ((self.w / 2).max(1), (self.h / 2).max(1)); let mut out = Image::new(w, h); out.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| { @@ -161,6 +194,9 @@ impl Image { /// Apply a per-pixel function on straight-alpha colour (alpha preserved). pub fn map_rgb(&mut self, f: impl Fn([f32; 3], usize, usize) -> [f32; 3] + Sync) { + if self.w == 0 || self.h == 0 { + return; + } let w = self.w; self.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| { for x in 0..w { @@ -179,6 +215,9 @@ impl Image { /// Convert to straight sRGB RGBA8. pub fn to_rgba8(&self) -> Vec { + if self.w == 0 || self.h == 0 { + return Vec::new(); + } let mut out = vec![0u8; self.w * self.h * 4]; out.par_chunks_mut(self.w * 4).zip(self.px.par_chunks(self.w * 4)).for_each(|(o, s)| filmcraft_frame::linear_premul_to_srgb8(s, o)); out @@ -186,6 +225,9 @@ impl Image { /// Opaque version over black (for monitors). pub fn over_black_rgba8(&self) -> Vec { + if self.w == 0 || self.h == 0 { + return Vec::new(); + } let mut out = vec![0u8; self.w * self.h * 4]; out.par_chunks_mut(self.w * 4).zip(self.px.par_chunks(self.w * 4)).for_each(|(o, s)| { for (o, s) in o.as_chunks_mut::<4>().0.iter_mut().zip(s.as_chunks::<4>().0) { @@ -202,6 +244,9 @@ impl Image { /// the same (the sRGB of the premultiplied linear color), so the picture still shows over /// black, and the alpha says where it is transparent. pub fn over_black_with_alpha_rgba8(&self) -> Vec { + if self.w == 0 || self.h == 0 { + return Vec::new(); + } let mut out = vec![0u8; self.w * self.h * 4]; out.par_chunks_mut(self.w * 4).zip(self.px.par_chunks(self.w * 4)).for_each(|(o, s)| { for (o, s) in o.as_chunks_mut::<4>().0.iter_mut().zip(s.as_chunks::<4>().0) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1136585a2..653cd4327 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -84,7 +84,11 @@ impl Default for RenderOptions { } /// Output size for a sequence at a scale. +/// +/// A non-finite or non-positive scale is invalid and falls back to 1.0 rather than being cast to a +/// zero pixel count (which would silently collapse the frame to 1x1). pub fn output_size(seq: &Sequence, scale: f32) -> (usize, usize) { + let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; (((seq.settings.width as f32 * scale).round() as usize).max(1), ((seq.settings.height as f32 * scale).round() as usize).max(1)) }