From 409b0036f86f7faa5a1f2d9663eb8f692c97ea5a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:37:31 +0000 Subject: [PATCH 01/10] video: bound H.264 picture number arithmetic --- crates/video/src/h264/decode/dpb.rs | 89 +++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/crates/video/src/h264/decode/dpb.rs b/crates/video/src/h264/decode/dpb.rs index 67713d9d3..2615698a4 100644 --- a/crates/video/src/h264/decode/dpb.rs +++ b/crates/video/src/h264/decode/dpb.rs @@ -176,6 +176,7 @@ impl Dpb { return Ok(lists); } let cur_fn = sh.frame_num; + ensure!(max_frame_num > 0 && max_frame_num <= (1 << 16) && cur_fn < max_frame_num, "frame number out of range"); let pic_num = |e: &DpbEntry| Self::frame_num_wrap(e, cur_fn, max_frame_num); let short: Vec<&DpbEntry> = self.entries.iter().filter(|e| e.mark == RefMark::Short).collect(); let mut long: Vec<&DpbEntry> = self.entries.iter().filter(|e| e.mark == RefMark::Long).collect(); @@ -212,6 +213,7 @@ impl Dpb { let mut ref_idx = 0usize; for m in &sh.ref_pic_list_mod[l] { ensure!(ref_idx < n, "too many ref_pic_list_modification entries"); + ensure!(m.idc >= 2 || m.value < max_frame_num, "abs_diff_pic_num_minus1 out of range"); list.push(None); // temporarily one longer let (target, is_long): (Option<&DpbEntry>, bool) = match m.idc { 0 | 1 => { @@ -272,13 +274,13 @@ impl Dpb { /// Adaptive memory control (8.2.5.4). Returns true if MMCO 5 was executed. fn apply_mmcos(&mut self, mmcos: &[Mmco], cur_frame_num: u32, max_frame_num: u32, cur_long: &mut Option) -> Result { let mut had5 = false; - let curr_pic_num = cur_frame_num as i32; + let curr_pic_num = i64::from(cur_frame_num); for m in mmcos { match m.op { 1 => { - let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1); + let pn = curr_pic_num - (i64::from(m.difference_of_pic_nums_minus1) + 1); for e in &mut self.entries { - if e.mark == RefMark::Short && Self::frame_num_wrap(e, cur_frame_num, max_frame_num) == pn { + if e.mark == RefMark::Short && i64::from(Self::frame_num_wrap(e, cur_frame_num, max_frame_num)) == pn { e.mark = RefMark::Unused; } } @@ -295,14 +297,14 @@ impl Dpb { self.max_long_term_frame_idx.is_some_and(|max| m.long_term_frame_idx <= max), "MMCO long-term index exceeds the configured limit" ); - let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1); + let pn = curr_pic_num - (i64::from(m.difference_of_pic_nums_minus1) + 1); for e in &mut self.entries { if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx { e.mark = RefMark::Unused; } } for e in &mut self.entries { - if e.mark == RefMark::Short && Self::frame_num_wrap(e, cur_frame_num, max_frame_num) == pn { + if e.mark == RefMark::Short && i64::from(Self::frame_num_wrap(e, cur_frame_num, max_frame_num)) == pn { e.mark = RefMark::Long; e.long_term_frame_idx = m.long_term_frame_idx; } @@ -461,6 +463,83 @@ mod tests { }) } + fn reference_slice() -> SliceHeader { + SliceHeader { + nal_ref_idc: 1, + nal_unit_type: 1, + idr: false, + first_mb_in_slice: 0, + slice_type_raw: 0, + slice_type: SliceType::P, + pps_id: 0, + colour_plane_id: 0, + frame_num: 0, + field_pic: false, + bottom_field: false, + idr_pic_id: 0, + pic_order_cnt_lsb: 0, + delta_pic_order_cnt_bottom: 0, + delta_pic_order_cnt: [0; 2], + redundant_pic_cnt: 0, + direct_spatial_mv_pred: false, + num_ref_idx_active: [1, 0], + ref_pic_list_mod: [Vec::new(), Vec::new()], + pred_weight_table: None, + no_output_of_prior_pics: false, + long_term_reference: false, + adaptive_ref_pic_marking: false, + mmcos: Vec::new(), + cabac_init_idc: 0, + slice_qp_delta: 0, + sp_for_switch: false, + slice_qs_delta: 0, + disable_deblocking_filter_idc: 0, + slice_alpha_c0_offset_div2: 0, + slice_beta_offset_div2: 0, + slice_group_change_cycle: 0, + header_bits: 0, + } + } + + #[test] + fn large_picture_number_differences_do_not_overflow() { + for op in [1, 3] { + for difference in [i32::MAX as u32, 1 << 31, u32::MAX] { + let mut dpb: Dpb = Dpb::new(); + dpb.max_long_term_frame_idx = Some(0); + let op = Mmco { + op, + difference_of_pic_nums_minus1: difference, + long_term_pic_num: 0, + long_term_frame_idx: 0, + max_long_term_frame_idx_plus1: 0, + }; + assert!(dpb.apply_mmcos(&[op], 5, 16, &mut None).is_ok()); + } + } + } + + #[test] + fn invalid_reference_list_differences_are_rejected() { + use crate::h264::decode::slice::RefPicListMod; + for idc in [0, 1] { + for value in [0, 15] { + let mut sh = reference_slice(); + sh.frame_num = 5; + sh.ref_pic_list_mod[0].push(RefPicListMod { idc, value }); + let dpb: Dpb = Dpb::new(); + assert!(dpb.build_ref_lists(&sh, 0, 16, |entry| entry.frame).is_ok()); + } + for value in [16, i32::MAX as u32, 1 << 31, u32::MAX] { + let mut sh = reference_slice(); + sh.frame_num = 5; + sh.ref_pic_list_mod[0].push(RefPicListMod { idc, value }); + let dpb: Dpb = Dpb::new(); + assert!(dpb.build_ref_lists(&sh, 0, 16, |entry| entry.frame).is_err()); + } + } + } + /// A huge frame_num jump must only materialise the frames the sliding window keeps (#2779). #[test] fn frame_num_gap_materialises_at_most_max_num_ref_frames() { From 6fa8323991b54e7ee62d2d2d9d8f71e719f65a21 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:44:21 +0000 Subject: [PATCH 02/10] video: saturate extreme H.264 picture order values --- crates/video/src/h264/decode/slice.rs | 42 ++++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/crates/video/src/h264/decode/slice.rs b/crates/video/src/h264/decode/slice.rs index 75b6c4f8e..61670f059 100644 --- a/crates/video/src/h264/decode/slice.rs +++ b/crates/video/src/h264/decode/slice.rs @@ -462,21 +462,21 @@ impl PocState { let (prev_msb, prev_lsb) = if sh.idr { (0, 0) } else { (self.prev_ref_msb, self.prev_ref_lsb) }; let max_lsb = 1i32 << sps.log2_max_poc_lsb; let lsb = sh.pic_order_cnt_lsb as i32; - let msb = if lsb < prev_lsb && prev_lsb.wrapping_sub(lsb) >= max_lsb / 2 { - prev_msb.wrapping_add(max_lsb) - } else if lsb > prev_lsb && lsb.wrapping_sub(prev_lsb) > max_lsb / 2 { - prev_msb.wrapping_sub(max_lsb) + let msb = if lsb < prev_lsb && prev_lsb.saturating_sub(lsb) >= max_lsb / 2 { + prev_msb.saturating_add(max_lsb) + } else if lsb > prev_lsb && lsb.saturating_sub(prev_lsb) > max_lsb / 2 { + prev_msb.saturating_sub(max_lsb) } else { prev_msb }; - let top = msb.wrapping_add(lsb); - let bottom = top.wrapping_add(sh.delta_pic_order_cnt_bottom); + let top = msb.saturating_add(lsb); + let bottom = top.saturating_add(sh.delta_pic_order_cnt_bottom); Poc { top, bottom, msb, frame_num_offset: 0 } } 1 => { let frame_num_offset = self.frame_num_offset(sh.idr, sh.frame_num, sps); let n = sps.offset_for_ref_frame.len() as i32; - let mut abs_frame_num = if n != 0 { frame_num_offset.wrapping_add(sh.frame_num as i32) } else { 0 }; + let mut abs_frame_num = if n != 0 { frame_num_offset.saturating_add(sh.frame_num as i32) } else { 0 }; if sh.nal_ref_idc == 0 && abs_frame_num > 0 { abs_frame_num -= 1; } @@ -484,17 +484,17 @@ impl PocState { if abs_frame_num > 0 { let cycle_cnt = (abs_frame_num - 1) / n; let in_cycle = (abs_frame_num - 1) % n; - let delta_cycle = sps.offset_for_ref_frame.iter().fold(0i32, |a, &o| a.wrapping_add(o)); - expected = cycle_cnt.wrapping_mul(delta_cycle); + let delta_cycle = sps.offset_for_ref_frame.iter().fold(0i32, |a, &o| a.saturating_add(o)); + expected = cycle_cnt.saturating_mul(delta_cycle); for &o in &sps.offset_for_ref_frame[..=in_cycle as usize] { - expected = expected.wrapping_add(o); + expected = expected.saturating_add(o); } } if sh.nal_ref_idc == 0 { - expected = expected.wrapping_add(sps.offset_for_non_ref_pic); + expected = expected.saturating_add(sps.offset_for_non_ref_pic); } - let top = expected.wrapping_add(sh.delta_pic_order_cnt[0]); - let bottom = top.wrapping_add(sps.offset_for_top_to_bottom_field).wrapping_add(sh.delta_pic_order_cnt[1]); + let top = expected.saturating_add(sh.delta_pic_order_cnt[0]); + let bottom = top.saturating_add(sps.offset_for_top_to_bottom_field).saturating_add(sh.delta_pic_order_cnt[1]); Poc { top, bottom, msb: 0, frame_num_offset } } _ => { @@ -502,9 +502,9 @@ impl PocState { let temp = if sh.idr { 0 } else if sh.nal_ref_idc == 0 { - 2i32.wrapping_mul(frame_num_offset.wrapping_add(sh.frame_num as i32)).wrapping_sub(1) + 2i32.saturating_mul(frame_num_offset.saturating_add(sh.frame_num as i32)).saturating_sub(1) } else { - 2i32.wrapping_mul(frame_num_offset.wrapping_add(sh.frame_num as i32)) + 2i32.saturating_mul(frame_num_offset.saturating_add(sh.frame_num as i32)) }; Poc { top: temp, bottom: temp, msb: 0, frame_num_offset } } @@ -515,7 +515,7 @@ impl PocState { if idr { 0 } else if self.prev_frame_num > frame_num { - self.prev_frame_num_offset.wrapping_add(sps.max_frame_num() as i32) + self.prev_frame_num_offset.saturating_add(sps.max_frame_num() as i32) } else { self.prev_frame_num_offset } @@ -528,7 +528,7 @@ impl PocState { if sh.nal_ref_idc != 0 { if mmco5 { self.prev_ref_msb = 0; - self.prev_ref_lsb = poc.top.wrapping_sub(poc.frame()); + self.prev_ref_lsb = poc.top.saturating_sub(poc.frame()); } else { self.prev_ref_msb = poc.msb; self.prev_ref_lsb = sh.pic_order_cnt_lsb as i32; @@ -624,6 +624,14 @@ mod poc_tests { } } + #[test] + fn extreme_bottom_delta_saturates_instead_of_reversing_picture_order() { + let sps = sps(0); + let sh = header(0, 1, i32::MAX, [0, 0], 1); + let poc = PocState::default().compute(&sh, &sps); + assert_eq!((poc.top, poc.bottom), (1, i32::MAX)); + } + /// Offsets and deltas are se(v) values that a stream may set anywhere in the i32 range. #[test] fn poc_does_not_overflow_on_extreme_deltas() { From 4cca9be2abc7aeb2e3b55c94673f8413b905bf5e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:10:32 +0000 Subject: [PATCH 03/10] video: configure bounded decoder frame limits --- crates/video/src/h264/decode/README.md | 3 +- crates/video/src/h264/decode/decoder.rs | 13 ++++- crates/video/src/h264/decode/params.rs | 33 +++++++++++- crates/video/src/prores/README.md | 7 +++ crates/video/src/prores/decode.rs | 70 ++++++++++++++++++++++++- crates/video/src/prores/mod.rs | 4 +- 6 files changed, 123 insertions(+), 7 deletions(-) diff --git a/crates/video/src/h264/decode/README.md b/crates/video/src/h264/decode/README.md index a73b8f2f4..4c352fd00 100644 --- a/crates/video/src/h264/decode/README.md +++ b/crates/video/src/h264/decode/README.md @@ -99,7 +99,8 @@ A picture is refused when the pictures the decoder would hold do not fit 1 GiB on 32-bit targets): the DPB (the larger of its slots at the new size and what it holds now), the picture being decoded, and what is in flight or waiting in the output queue. Pictures already held count at their own size, and a buffer shared by the DPB and the queue counts once. -The per-picture size cap applies as well. +The default per-picture luma cap is 2^25 samples; applications with a different bounded frame +policy can raise it with `Decoder::set_max_picture_luma_samples`. `configure_avcc` checks every entry of the record before it handles any: the first list holds SPS NAL units and the second PPS NAL units. A record that carries a picture slice is an error and diff --git a/crates/video/src/h264/decode/decoder.rs b/crates/video/src/h264/decode/decoder.rs index 6d5fe3e5d..01a130b35 100644 --- a/crates/video/src/h264/decode/decoder.rs +++ b/crates/video/src/h264/decode/decoder.rs @@ -115,6 +115,8 @@ pub struct Decoder { /// Bytes the picture store may take ([`crate::budget`]). memory_budget: u64, threads: usize, + /// Maximum reconstructed luma samples in one picture. + max_luma_samples: u64, /// Draft mode: skip deblocking of non-reference pictures ([`Decoder::set_draft`]). draft: bool, } @@ -179,6 +181,7 @@ impl Decoder { max_in_flight: threads.max(1) + 2, memory_budget: crate::budget::DECODE_MEMORY_BUDGET, threads: workers, + max_luma_samples: super::params::MAX_LUMA_SAMPLES, draft: false, } } @@ -391,7 +394,7 @@ impl Decoder { // Before the header fields that depend on the SPS: they are validated for the streams // we decode (a 10-bit slice may have a QP below 0), so a stream we cannot decode must // fail as unsupported here, not as invalid there (#626). - sps.check_supported()?; + sps.check_supported_with_limit(self.max_luma_samples)?; found = Some((pps.clone(), sps.clone())); Ok((&**pps, &**sps)) })?; @@ -468,6 +471,14 @@ impl Decoder { self.memory_budget = bytes; } + /// Set the maximum luma sample count allowed in a single decoded picture. + /// + /// The default is 2^25 samples. This is a per-picture geometry guard; the separate + /// memory budget controls aggregate decoded picture storage. + pub fn set_max_picture_luma_samples(&mut self, samples: u64) { + self.max_luma_samples = samples; + } + /// Refuse an SPS whose pictures would not fit the memory budget, before anything is allocated. fn check_budget(&self, sps: &Sps) -> Result<()> { let chroma = sps.chroma_array_type(); diff --git a/crates/video/src/h264/decode/params.rs b/crates/video/src/h264/decode/params.rs index 62ca789eb..b919efb7d 100644 --- a/crates/video/src/h264/decode/params.rs +++ b/crates/video/src/h264/decode/params.rs @@ -6,7 +6,7 @@ use craft_media::bitstream::BitReader; /// Maximum supported reconstructed luma area. 8K UHD fits comfortably while /// hostile SPS dimensions cannot allocate full pictures and decoder work state. -const MAX_LUMA_SAMPLES: u64 = 1 << 25; +pub(super) const MAX_LUMA_SAMPLES: u64 = 1 << 25; /// Scaling matrices after applying fall-back rules, stored in raster order (weightScale). #[derive(Clone, Debug, PartialEq, Eq)] @@ -527,7 +527,11 @@ impl Sps { } pub fn check_supported(&self) -> Result<()> { - if u64::from(self.width()).saturating_mul(u64::from(self.height())) > MAX_LUMA_SAMPLES { + self.check_supported_with_limit(MAX_LUMA_SAMPLES) + } + + pub(super) fn check_supported_with_limit(&self, max_luma_samples: u64) -> Result<()> { + if u64::from(self.width()).saturating_mul(u64::from(self.height())) > max_luma_samples { return unsupported("picture dimensions exceed the decoder memory limit"); } if !self.frame_mbs_only { @@ -744,6 +748,31 @@ mod tests { assert_eq!(sps.pic_order_cnt_type, 2); assert_eq!(sps.crop_rect(), (0, 0, 176, 144)); } + + #[test] + fn caller_can_raise_single_picture_sample_limit() { + let mut w = BitWriter::new(); + w.write_bits(66, 8); + w.write_bits(0xC0, 8); + w.write_bits(30, 8); + w.write_ue(0); + w.write_ue(0); + w.write_ue(2); + w.write_ue(1); + w.write_bit(false); + w.write_ue(511); // 8192 luma samples wide + w.write_ue(511); // 8192 luma samples high + w.write_bit(true); + w.write_bit(true); + w.write_bit(false); + w.write_bit(false); + w.rbsp_trailing(); + + let sps = Sps::parse(&w.finish()).unwrap(); + assert_eq!((sps.width(), sps.height()), (8192, 8192)); + assert!(sps.check_supported().is_err()); + assert!(sps.check_supported_with_limit(1 << 26).is_ok()); + } /// Baseline SPS with the given `ue(v)` overrides for the fields hostile streams can max out. fn hostile_sps(profile: u32, bit_depth: u32, log2_frame_num: u32, w: u32, h: u32, crop: u32) -> Vec { let mut bw = BitWriter::new(); diff --git a/crates/video/src/prores/README.md b/crates/video/src/prores/README.md index e3a9ea989..9831e3282 100644 --- a/crates/video/src/prores/README.md +++ b/crates/video/src/prores/README.md @@ -16,6 +16,8 @@ use craft_video::prores::*; let frame: Frame = decode_frame(&sample)?; // 10-bit 4:2:2 / 12-bit 4:4:4 let f10 = decode_frame_with(&sample, &DecodeOptions { bit_depth: Some(10), threads: true })?; decode_frame_into(&sample, &DecodeOptions::default(), &mut frame_buf)?; // reuses planes +let limits = DecodeLimits { max_pixels: 8192 * 8192, max_frame_bytes: 512 * 1024 * 1024 }; +let bounded = decode_frame_with_limits(&sample, &DecodeOptions::default(), &limits)?; let hdr: FrameHeader = probe(&sample)?; // header only // Encode (progressive). @@ -28,6 +30,11 @@ alpha: Option>, interlace: Interlace, color: ColorInfo { primaries, tra aspect_ratio, frame_rate_code }` holds planar samples with no row padding (`chroma_width()` is `ceil(width/2)` for 4:2:2). The colour codes are H.273 code points straight from the frame header. +`DecodeLimits` bounds both frame area and aggregate decoded plane storage before allocation. +`decode_frame_with` uses an aggregate limit of 300 MiB and does not impose an additional area +limit; use `decode_frame_with_limits` when the application needs a stricter pixel policy or a +different finite memory ceiling. + `Profile` = `Proxy` (`apco`), `Lt` (`apcs`), `Standard` (`apcn`), `Hq` (`apch`), `P4444` (`ap4h`), `P4444Xq` (`ap4x`). The frame itself doesn't say which profile it is (only the container fourcc does), so the decoder doesn't need to know. diff --git a/crates/video/src/prores/decode.rs b/crates/video/src/prores/decode.rs index f4670af60..7125d4641 100644 --- a/crates/video/src/prores/decode.rs +++ b/crates/video/src/prores/decode.rs @@ -11,6 +11,21 @@ use crate::prores::{AlphaType, ChromaFormat, Error, Frame, Interlace, Result}; /// Maximum aggregate decoded plane storage for one ProRes frame (300 MiB; 8K ProRes 4444 with alpha is ~265 MiB). const MAX_FRAME_BYTES: usize = 300 * 1024 * 1024; +/// Resource limits for decoding one ProRes frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecodeLimits { + /// Maximum frame width × height. Checked before any plane allocation. + pub max_pixels: u64, + /// Maximum aggregate output plane storage in bytes. + pub max_frame_bytes: usize, +} + +impl Default for DecodeLimits { + fn default() -> Self { + Self { max_pixels: u64::MAX, max_frame_bytes: MAX_FRAME_BYTES } + } +} + /// Decoder options. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DecodeOptions { @@ -105,15 +120,28 @@ impl Scratch { /// Decode one ProRes frame. pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result { + decode_frame_with_limits(data, opts, &DecodeLimits::default()) +} + +/// Decode one ProRes frame with explicit geometry and aggregate-memory limits. +pub fn decode_frame_with_limits(data: &[u8], opts: &DecodeOptions, limits: &DecodeLimits) -> Result { let mut f = Frame::new(0, 0, ChromaFormat::Yuv422, 10, false)?; - decode_frame_into(data, opts, &mut f)?; + decode_frame_into_with_limits(data, opts, limits, &mut f)?; Ok(f) } /// Decode one ProRes frame into `out`, reusing its plane allocations (the fastest way to decode /// a sequence). On error the contents of `out` are unspecified. pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> Result<()> { + decode_frame_into_with_limits(data, opts, &DecodeLimits::default(), out) +} + +/// Decode one ProRes frame into `out` under explicit resource limits. +pub fn decode_frame_into_with_limits(data: &[u8], opts: &DecodeOptions, limits: &DecodeLimits, out: &mut Frame) -> Result<()> { let (hdr, mut off, frame_size) = FrameHeader::parse(data)?; + if u64::from(hdr.width) * u64::from(hdr.height) > limits.max_pixels { + return Err(Error::Unsupported("frame size")); + } let data = &data[..frame_size]; let depth = opts.bit_depth.unwrap_or(match hdr.chroma { ChromaFormat::Yuv422 => 10, @@ -131,7 +159,7 @@ pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> .checked_add(chroma.checked_mul(2).ok_or(Error::Invalid("chroma plane count overflows"))?) .and_then(|n| if hdr.alpha == AlphaType::None { Some(n) } else { n.checked_add(luma) }) .ok_or(Error::Invalid("frame plane count overflows"))?; - if samples.checked_mul(std::mem::size_of::()).is_none_or(|n| n > MAX_FRAME_BYTES) { + if samples.checked_mul(std::mem::size_of::()).is_none_or(|n| n > limits.max_frame_bytes) { return Err(Error::Invalid("frame dimensions exceed the decoder memory limit")); } let interlaced = hdr.interlace != Interlace::Progressive; @@ -564,6 +592,44 @@ mod alpha_decode_tests { assert_eq!(slice_quantiser(224).unwrap(), qscale(224)); } + #[test] + fn custom_limits_preserve_large_4444_geometry_before_allocating() { + let hdr = FrameHeader { + header_size: 20, + bitstream_version: 1, + encoder_id: *b"test", + width: 8192, + height: 8192, + chroma: ChromaFormat::Yuv444, + interlace: Interlace::Progressive, + aspect_ratio: 0, + frame_rate_code: 0, + color: crate::prores::ColorInfo::default(), + alpha: AlphaType::Bits16, + qmat_luma: [4; 64], + qmat_chroma: [4; 64], + luma_matrix_present: false, + chroma_matrix_present: false, + }; + let header = hdr.write(); + let frame_size = 8 + header.len(); + let mut data = Vec::with_capacity(frame_size); + data.extend_from_slice(&(frame_size as u32).to_be_bytes()); + data.extend_from_slice(&crate::prores::header::FRAME_ID); + data.extend_from_slice(&header); + + let defaults = DecodeLimits { max_pixels: 8192 * 8192, ..DecodeLimits::default() }; + assert!(matches!( + decode_frame_with_limits(&data, &DecodeOptions::default(), &defaults), + Err(Error::Invalid("frame dimensions exceed the decoder memory limit")) + )); + + let soundcraft = DecodeLimits { max_pixels: 8192 * 8192, max_frame_bytes: 512 * 1024 * 1024 }; + assert!(matches!(decode_frame_with_limits(&data, &DecodeOptions::default(), &soundcraft), Err(Error::Truncated))); + let too_few_pixels = DecodeLimits { max_pixels: 8192 * 8192 - 1, ..soundcraft }; + assert!(matches!(decode_frame_with_limits(&data, &DecodeOptions::default(), &too_few_pixels), Err(Error::Unsupported("frame size")))); + } + #[test] fn alpha_decoder_requires_complete_bounded_runs() { let mut out = [0u16; 1]; diff --git a/crates/video/src/prores/mod.rs b/crates/video/src/prores/mod.rs index 4af49f913..5979c19af 100644 --- a/crates/video/src/prores/mod.rs +++ b/crates/video/src/prores/mod.rs @@ -18,7 +18,9 @@ mod encode; pub mod header; mod tables; -pub use decode::{DecodeOptions, decode_frame, decode_frame_into, decode_frame_with, probe}; +pub use decode::{ + DecodeLimits, DecodeOptions, decode_frame, decode_frame_into, decode_frame_into_with_limits, decode_frame_with, decode_frame_with_limits, probe, +}; pub use encode::{Encoder, EncoderConfig}; pub use header::{FrameHeader, PictureHeader}; From 198697cfc8ef5477f9b8954572978a352010c6db Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:18:59 +0000 Subject: [PATCH 04/10] fix(cms): keep clippy compatible with workspace gate --- crates/cms/src/synth.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/cms/src/synth.rs b/crates/cms/src/synth.rs index a59058f08..b1535f78c 100644 --- a/crates/cms/src/synth.rs +++ b/crates/cms/src/synth.rs @@ -111,9 +111,9 @@ impl CmykModel { // Overprints with black: multiplicative in reflectance above a first-surface floor. let floor = 0.006; let mut prim = [[0.0; 3]; 16]; - for m in 0..16 { + for (m, slot) in prim.iter_mut().enumerate() { let p = base[m & 7]; - prim[m] = if m & 8 == 0 { + *slot = if m & 8 == 0 { p } else { std::array::from_fn(|i| { @@ -133,7 +133,7 @@ impl CmykModel { (v + 4.0 * self.tvi[i] * v * (1.0 - v)).clamp(0.0, 1.0) }); let mut s = [0.0; 3]; - for m in 0..16 { + for (m, prim) in self.prim_n.iter().enumerate() { let mut w = 1.0; for (i, ai) in a.iter().enumerate() { w *= if m & (1 << i) != 0 { *ai } else { 1.0 - ai }; @@ -141,7 +141,7 @@ impl CmykModel { if w == 0.0 { continue; } - for (acc, p) in s.iter_mut().zip(&self.prim_n[m]) { + for (acc, p) in s.iter_mut().zip(prim) { *acc += w * p; } } From 92d1bba5dea565afc0cbe15ab4915b594dec6a4f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:28:46 +0000 Subject: [PATCH 05/10] fix(proof): satisfy workspace clippy gate --- crates/proof/src/tr/mod.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/proof/src/tr/mod.rs b/crates/proof/src/tr/mod.rs index 5472fbe21..c728d5872 100644 --- a/crates/proof/src/tr/mod.rs +++ b/crates/proof/src/tr/mod.rs @@ -100,11 +100,7 @@ fn check(d: &TrDict, word: &str, opts: &crate::Options) -> bool { } // Compound words separated by hyphens (e.g., `afyon-karahisar`) - if w.contains('-') && w.split('-').all(|part| part.is_empty() || check(d, part, opts)) { - return true; - } - - false + w.contains('-') && w.split('-').all(|part| part.is_empty() || check(d, part, opts)) } #[cfg(test)] From 2253894e2f54f8117a7292cf17552eb470e9ecd7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:39:26 +0000 Subject: [PATCH 06/10] fix: satisfy Rust 1.100 workspace clippy --- crates/latex/src/tests.rs | 12 ++++++------ crates/video/src/hevc/decode/filter.rs | 5 +---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/crates/latex/src/tests.rs b/crates/latex/src/tests.rs index 55bde2489..ccf349c80 100644 --- a/crates/latex/src/tests.rs +++ b/crates/latex/src/tests.rs @@ -353,9 +353,9 @@ fn table_borders_and_borderless_round_trip() { let mut doc = Document::new(); let mut t_borderless = Table::new(2, 2, 400.0); t_borderless.props.style = None; - for r in 0..2 { - for c in 0..2 { - t_borderless.rows[r].cells[c].blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; + for row in &mut t_borderless.rows { + for cell in &mut row.cells { + cell.blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; } } doc.body = vec![Arc::new(Block::Table(t_borderless))]; @@ -369,9 +369,9 @@ fn table_borders_and_borderless_round_trip() { let mut doc_bordered = Document::new(); let mut t_bordered = Table::new(2, 2, 400.0); t_bordered.props.style = Some("TableGrid".to_string()); - for r in 0..2 { - for c in 0..2 { - t_bordered.rows[r].cells[c].blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; + for row in &mut t_bordered.rows { + for cell in &mut row.cells { + cell.blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))]; } } doc_bordered.body = vec![Arc::new(Block::Table(t_bordered))]; diff --git a/crates/video/src/hevc/decode/filter.rs b/crates/video/src/hevc/decode/filter.rs index d5b94161d..69f936bc1 100644 --- a/crates/video/src/hevc/decode/filter.rs +++ b/crates/video/src/hevc/decode/filter.rs @@ -522,10 +522,7 @@ fn sao_edge_ctb( return false; } } - if !pic.pps.loop_filter_across_tiles && pic.layout.tile_id[pic.layout.rs_to_ts[nrs] as usize] != pic.layout.tile_id[cur_ts as usize] { - return false; - } - true + pic.pps.loop_filter_across_tiles || pic.layout.tile_id[pic.layout.rs_to_ts[nrs] as usize] == pic.layout.tile_id[cur_ts as usize] }; // avail[dy][dx]: the CTB at (rx + dx - 1, ry + dy - 1) may be used. let (rx, ry) = (rs % pic.wctb, rs / pic.wctb); From d316f0fc2d9685ca7b8fdfbaa4bbed1eb49ac496 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:02:45 +0000 Subject: [PATCH 07/10] fix(xml): walk deep KRA and ORA trees iteratively --- crates/kra/src/lib.rs | 43 ++++++++++++++++++++++++------ crates/ora/src/lib.rs | 61 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 83 insertions(+), 21 deletions(-) diff --git a/crates/kra/src/lib.rs b/crates/kra/src/lib.rs index 7485b9dd5..12ae3468b 100644 --- a/crates/kra/src/lib.rs +++ b/crates/kra/src/lib.rs @@ -161,6 +161,16 @@ fn children<'a>(el: &'a El, container: &'static str, tag: &'static str) -> impl } fn node(el: &El, is_mask: bool, count: &mut usize) -> Result { + let mut out = node_fields(el, is_mask)?; + if !is_mask { + out.layers = layers(el, count)?; + out.masks = children(el, "masks", "mask").map(|m| node(m, true, count)).collect::>()?; + } + Ok(out) +} + +/// A node's own attributes, without its children. +fn node_fields(el: &El, is_mask: bool) -> Result { let owned = |name: &str| attr(el, name).map(str::to_string); let node_type = attr(el, "nodetype").unwrap_or("").to_string(); // Only the kinds whose pixels are read need sound numbers; a node an application will leave @@ -168,7 +178,7 @@ fn node(el: &El, is_mask: bool, count: &mut usize) -> Result { let read = matches!(node_type.as_str(), "paintlayer" | "grouplayer" | "transparencymask"); let lenient = |v: Result, default: f64| if read { v } else { Ok(v.unwrap_or(default)) }; let (x, y) = if is_mask && !read { (offset(el, "x").unwrap_or(0), offset(el, "y").unwrap_or(0)) } else { (offset(el, "x")?, offset(el, "y")?) }; - let mut out = Node { + let out = Node { node_type, name: owned("name"), visible: attr(el, "visible") != Some("0"), @@ -187,22 +197,39 @@ fn node(el: &El, is_mask: bool, count: &mut usize) -> Result { layers: Vec::new(), masks: Vec::new(), }; - if !is_mask { - out.layers = layers(el, count)?; - out.masks = children(el, "masks", "mask").map(|m| node(m, true, count)).collect::>()?; - } Ok(out) } -/// The `` of `el`. Recursion is bounded by [`MAX_XML_DEPTH`]. +/// The `` of `el`, walked with an explicit stack: groups can nest up to [`MAX_XML_DEPTH`] +/// deep, more than a small thread stack can recurse. fn layers(el: &El, count: &mut usize) -> Result> { + struct Frame<'a> { + node: Node, + rest: std::vec::IntoIter<&'a El>, + } + let mut root = children(el, "layers", "layer").collect::>().into_iter(); + let mut stack: Vec = Vec::new(); let mut out = Vec::new(); - for child in children(el, "layers", "layer") { + loop { + let next = match stack.last_mut() { + Some(frame) => frame.rest.next(), + None => root.next(), + }; + let Some(child) = next else { + let Some(frame) = stack.pop() else { break }; + match stack.last_mut() { + Some(parent) => parent.node.layers.push(frame.node), + None => out.push(frame.node), + } + continue; + }; *count += 1; if *count > MAX_NODES { return Err(invalid(format!("more than {MAX_NODES} layers"))); } - out.push(node(child, false, count)?); + let mut built = node_fields(child, false)?; + built.masks = children(child, "masks", "mask").map(|m| node(m, true, count)).collect::>()?; + stack.push(Frame { node: built, rest: children(child, "layers", "layer").collect::>().into_iter() }); } Ok(out) } diff --git a/crates/ora/src/lib.rs b/crates/ora/src/lib.rs index b2a91cc40..69d494d0d 100644 --- a/crates/ora/src/lib.rs +++ b/crates/ora/src/lib.rs @@ -189,28 +189,63 @@ fn props(el: &El) -> Result { }) } -/// The elements of `stack`. Recursion is bounded by [`MAX_XML_DEPTH`]. +/// The elements of `stack`, walked with an explicit stack: a stack can nest up to +/// [`MAX_XML_DEPTH`] deep, more than a small thread stack can recurse. fn stack(el: &El, count: &mut usize) -> Result> { + struct Frame<'a> { + props: Props, + isolation: Option, + children: Vec, + rest: std::vec::IntoIter<&'a El>, + } + let mut root = el.elements().collect::>().into_iter(); + let mut frames: Vec = Vec::new(); let mut out = Vec::new(); - for child in el.elements() { + loop { + let next = match frames.last_mut() { + Some(frame) => frame.rest.next(), + None => root.next(), + }; + let Some(child) = next else { + let Some(frame) = frames.pop() else { break }; + let node = Node::Stack(Stack { props: frame.props, isolation: frame.isolation, children: frame.children }); + match frames.last_mut() { + Some(parent) => parent.children.push(node), + None => out.push(node), + } + continue; + }; *count += 1; if *count > MAX_NODES { return Err(invalid(format!("more than {MAX_NODES} layers"))); } - out.push(match child.name.as_str() { - "layer" => Node::Layer(Layer { - props: props(child)?, - x: int(child, "x")?, - y: int(child, "y")?, - src: child.attr("src").map(|s| s.trim().to_string()), - }), - "stack" => Node::Stack(Stack { + match child.name.as_str() { + "layer" => { + let node = Node::Layer(Layer { + props: props(child)?, + x: int(child, "x")?, + y: int(child, "y")?, + src: child.attr("src").map(|s| s.trim().to_string()), + }); + match frames.last_mut() { + Some(parent) => parent.children.push(node), + None => out.push(node), + } + } + "stack" => frames.push(Frame { props: props(child)?, isolation: child.attr("isolation").map(|s| s.trim().to_string()), - children: stack(child, count)?, + children: Vec::new(), + rest: child.elements().collect::>().into_iter(), }), - other => Node::Other { tag: other.to_string(), name: child.attr("name").map(str::to_string) }, - }); + other => { + let node = Node::Other { tag: other.to_string(), name: child.attr("name").map(str::to_string) }; + match frames.last_mut() { + Some(parent) => parent.children.push(node), + None => out.push(node), + } + } + } } Ok(out) } From 6ef2334920b94fc91ab9f9fa37428138597c7499 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 23:58:12 +0000 Subject: [PATCH 08/10] plotter: expect registration offset command --- crates/plotter/tests/golden.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/plotter/tests/golden.rs b/crates/plotter/tests/golden.rs index 26aaae554..4d5029010 100644 --- a/crates/plotter/tests/golden.rs +++ b/crates/plotter/tests/golden.rs @@ -135,7 +135,7 @@ fn silhouette_registration_marks() { let p = silhouette::encode(&pro, &j).unwrap(); let (head, rest) = p.split_at_registration(); let text = |c: &[Command]| c.iter().map(Command::to_ascii).collect::>().join(" "); - assert!(text(head).ends_with(" FC18,1,1 TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB55,1 TB123,4600,3600,200,100")); + assert!(text(head).ends_with(" FC18,1,1 TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB54,0,0 TB55,1 TB123,4600,3600,200,100")); // The design is shifted so the first mark is the origin. assert_eq!(text(rest), "M100,100 D100,300 M100,0 SO0"); assert_eq!(p.registration_wait, Some(head.len())); @@ -148,7 +148,7 @@ fn silhouette_registration_marks() { reg.origin_right_mm = 0.0; assert!(ascii(®).ends_with(" TB124,4600,3600,0,0")); reg.search = false; - assert_eq!(ascii(®), "TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB55,1 TB23,4600,3600"); + assert_eq!(ascii(®), "TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB54,0,0 TB55,1 TB23,4600,3600"); } #[test] From 56a1f2b7c44d2e9ae4c121bb4bdb76f366e9ed77 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 07:11:12 +0000 Subject: [PATCH 09/10] Avoid premature saturation in H264 type 1 POC --- crates/video/src/h264/decode/slice.rs | 28 ++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/video/src/h264/decode/slice.rs b/crates/video/src/h264/decode/slice.rs index 61670f059..32366ccf9 100644 --- a/crates/video/src/h264/decode/slice.rs +++ b/crates/video/src/h264/decode/slice.rs @@ -475,26 +475,27 @@ impl PocState { } 1 => { let frame_num_offset = self.frame_num_offset(sh.idr, sh.frame_num, sps); - let n = sps.offset_for_ref_frame.len() as i32; - let mut abs_frame_num = if n != 0 { frame_num_offset.saturating_add(sh.frame_num as i32) } else { 0 }; + let n = sps.offset_for_ref_frame.len() as i64; + let mut abs_frame_num = if n != 0 { i64::from(frame_num_offset) + i64::from(sh.frame_num) } else { 0 }; if sh.nal_ref_idc == 0 && abs_frame_num > 0 { abs_frame_num -= 1; } - let mut expected = 0i32; + let mut expected = 0i64; if abs_frame_num > 0 { let cycle_cnt = (abs_frame_num - 1) / n; let in_cycle = (abs_frame_num - 1) % n; - let delta_cycle = sps.offset_for_ref_frame.iter().fold(0i32, |a, &o| a.saturating_add(o)); - expected = cycle_cnt.saturating_mul(delta_cycle); + let delta_cycle = sps.offset_for_ref_frame.iter().map(|&o| i64::from(o)).sum::(); + expected = cycle_cnt * delta_cycle; for &o in &sps.offset_for_ref_frame[..=in_cycle as usize] { - expected = expected.saturating_add(o); + expected += i64::from(o); } } if sh.nal_ref_idc == 0 { - expected = expected.saturating_add(sps.offset_for_non_ref_pic); + expected += i64::from(sps.offset_for_non_ref_pic); } - let top = expected.saturating_add(sh.delta_pic_order_cnt[0]); - let bottom = top.saturating_add(sps.offset_for_top_to_bottom_field).saturating_add(sh.delta_pic_order_cnt[1]); + let top = (expected + i64::from(sh.delta_pic_order_cnt[0])).clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; + let bottom = (i64::from(top) + i64::from(sps.offset_for_top_to_bottom_field) + i64::from(sh.delta_pic_order_cnt[1])) + .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; Poc { top, bottom, msb: 0, frame_num_offset } } _ => { @@ -662,4 +663,13 @@ mod poc_tests { let _ = poc.frame(); state.update(&sh, &poc); } + + #[test] + fn poc_type1_cancellation_happens_before_clamping() { + let sps = sps(1); + let sh = header(0, 0, 0, [-i32::MAX, 0], 1); + let state = PocState { prev_frame_num_offset: 2, ..PocState::default() }; + let poc = state.compute(&sh, &sps); + assert_eq!(poc.top, i32::MAX); + } } From 98144696139aafad86f63a776e147e989731f43e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 07:24:13 +0000 Subject: [PATCH 10/10] Bound extreme H264 POC cycle arithmetic --- crates/video/src/h264/decode/slice.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/video/src/h264/decode/slice.rs b/crates/video/src/h264/decode/slice.rs index 32366ccf9..874c575ca 100644 --- a/crates/video/src/h264/decode/slice.rs +++ b/crates/video/src/h264/decode/slice.rs @@ -485,16 +485,18 @@ impl PocState { let cycle_cnt = (abs_frame_num - 1) / n; let in_cycle = (abs_frame_num - 1) % n; let delta_cycle = sps.offset_for_ref_frame.iter().map(|&o| i64::from(o)).sum::(); - expected = cycle_cnt * delta_cycle; + expected = cycle_cnt.saturating_mul(delta_cycle); for &o in &sps.offset_for_ref_frame[..=in_cycle as usize] { - expected += i64::from(o); + expected = expected.saturating_add(i64::from(o)); } } if sh.nal_ref_idc == 0 { - expected += i64::from(sps.offset_for_non_ref_pic); + expected = expected.saturating_add(i64::from(sps.offset_for_non_ref_pic)); } - let top = (expected + i64::from(sh.delta_pic_order_cnt[0])).clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; - let bottom = (i64::from(top) + i64::from(sps.offset_for_top_to_bottom_field) + i64::from(sh.delta_pic_order_cnt[1])) + let top = expected.saturating_add(i64::from(sh.delta_pic_order_cnt[0])).clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; + let bottom = i64::from(top) + .saturating_add(i64::from(sps.offset_for_top_to_bottom_field)) + .saturating_add(i64::from(sh.delta_pic_order_cnt[1])) .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; Poc { top, bottom, msb: 0, frame_num_offset } } @@ -672,4 +674,14 @@ mod poc_tests { let poc = state.compute(&sh, &sps); assert_eq!(poc.top, i32::MAX); } + + #[test] + fn poc_type1_saturates_only_after_wide_cycle_arithmetic_exceeds_i64() { + let mut sps = sps(1); + sps.offset_for_ref_frame = vec![i32::MAX; 255]; + let sh = header(u32::MAX, 0, 0, [0, 0], 1); + let state = PocState { prev_frame_num_offset: i32::MAX, ..PocState::default() }; + let poc = state.compute(&sh, &sps); + assert_eq!(poc.top, i32::MAX); + } }