diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs
index 8292024b1..21b9cf647 100644
--- a/crates/heif/src/lib.rs
+++ b/crates/heif/src/lib.rs
@@ -277,6 +277,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool)
})
}
+/// How many times `max_pixels` the coded pictures of one item may hold together: a grid's tiles
+/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1.
+const GRID_BUDGET: u64 = 2;
+
/// The decoded pictures of an item: one, or a grid's tiles in `dimg` order.
struct Coded<'a> {
frames: Vec,
@@ -297,6 +301,24 @@ impl<'a> Coded<'a> {
let first = *ids.first().ok_or_else(|| Error::Malformed("grid lists no tiles".into()))?;
let first_props = if first == id { p.clone() } else { ctx.props(first).map_err(err)? };
let chroma_format = first_props.hvcc.as_ref().map_or(1, |c| c.chroma_format);
+ // #1518: the decoder allocates each picture at its SPS's uncropped size, which a
+ // conformance window (or a slice picking another SPS) can make far larger than the
+ // declared one, and a grid holds all its tiles at once. Both are refused before decoding.
+ let mut total = 0u64;
+ for t in &ids {
+ let tp = ctx.props(*t).map_err(err)?;
+ let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?;
+ let sizes = vui::coded_sizes(&hvcc.parameter_sets()).ok_or_else(|| Error::Malformed("unreadable sequence parameter set".into()))?;
+ let mut largest = 0u64;
+ for (w, h) in sizes {
+ heic_rs::image::check_pixels(w, h, limit).map_err(err)?;
+ largest = largest.max(u64::from(w) * u64::from(h));
+ }
+ total = total.saturating_add(largest);
+ }
+ if total > limit.saturating_mul(GRID_BUDGET) {
+ return Err(Error::Limit(format!("the coded pictures hold {total} pixels, over the {limit}-pixel limit")));
+ }
let decode_one = |t: &u32| -> Result {
let tp = ctx.props(*t).map_err(err)?;
let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?;
diff --git a/crates/heif/src/tests_synthetic.rs b/crates/heif/src/tests_synthetic.rs
index e229af926..3259dc96d 100644
--- a/crates/heif/src/tests_synthetic.rs
+++ b/crates/heif/src/tests_synthetic.rs
@@ -146,6 +146,17 @@ fn limits_are_checked_before_decoding() {
assert!(matches!(r, Err(Error::Limit(_))), "{r:?}");
}
+/// #1518: a grid's tiles together are held to the pixel budget before any is decoded. A
+/// 33 × 33 canvas fits 1089 pixels, but its four 32 × 32 tiles hold 4096: over twice the limit.
+#[test]
+fn grid_tiles_are_held_to_the_pixel_budget_together() {
+ let bytes = build(&Spec::new(33, 33, &grey));
+ let r = decode(&bytes, &Options { max_pixels: 33 * 33, ..Default::default() });
+ assert!(matches!(r, Err(Error::Limit(_))), "{r:?}");
+ let d = decode(&bytes, &Options { max_pixels: 2 * 32 * 32, ..Default::default() }).unwrap();
+ assert_eq!((d.width, d.height), (33, 33));
+}
+
#[test]
fn truncated_and_corrupted_files_are_errors_not_panics() {
let bytes = build(&Spec { props: vec![FULL_601, Prop::Irot(1)], thumbnail: Some(&grey), ..Spec::new(64, 64, &grey) });
diff --git a/crates/heif/src/vui.rs b/crates/heif/src/vui.rs
index d732f2863..226604303 100644
--- a/crates/heif/src/vui.rs
+++ b/crates/heif/src/vui.rs
@@ -33,6 +33,41 @@ pub fn signal(parameter_sets: &[&[u8]]) -> Option {
parse_sps(&unescape(sps.get(2..)?))
}
+/// The uncropped picture size (`pic_width/height_in_luma_samples`) of every SPS among
+/// `parameter_sets`: what the decoder allocates before it applies the conformance window, so the
+/// pixel budget is checked against it (heic-rs reports only the first SPS, cropped). `None`: an
+/// SPS this reader can't follow.
+pub fn coded_sizes(parameter_sets: &[&[u8]]) -> Option> {
+ parameter_sets
+ .iter()
+ .filter(|n| n.first().is_some_and(|b| (b >> 1) & 0x3f == SPS_NUT))
+ .map(|sps| {
+ let rbsp = unescape(sps.get(2..)?);
+ let r = &mut Bits { data: &rbsp, pos: 0 };
+ sps_size(r).map(|(w, h, _)| (w, h))
+ })
+ .collect()
+}
+
+/// Reads an SPS up to and including the picture size, leaving `r` after it: width, height and
+/// `sps_max_sub_layers_minus1`.
+fn sps_size(r: &mut Bits) -> Option<(u32, u32, u32)> {
+ r.skip(4)?; // sps_video_parameter_set_id
+ let max_sub_layers_minus1 = r.u(3)?;
+ if max_sub_layers_minus1 > 6 {
+ return None;
+ }
+ r.skip(1)?; // sps_temporal_id_nesting_flag
+ profile_tier_level(r, max_sub_layers_minus1)?;
+ r.ue()?; // sps_seq_parameter_set_id
+ if r.ue()? == 3 {
+ r.skip(1)?; // separate_colour_plane_flag
+ }
+ let width = r.ue()?; // pic_width_in_luma_samples
+ let height = r.ue()?; // pic_height_in_luma_samples
+ Some((width, height, max_sub_layers_minus1))
+}
+
/// Removes emulation prevention bytes (`00 00 03` → `00 00`).
fn unescape(nal: &[u8]) -> Vec {
let mut out = Vec::with_capacity(nal.len());
@@ -165,19 +200,7 @@ fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option Option {
let r = &mut Bits { data: rbsp, pos: 0 };
- r.skip(4)?; // sps_video_parameter_set_id
- let max_sub_layers_minus1 = r.u(3)?;
- if max_sub_layers_minus1 > 6 {
- return None;
- }
- r.skip(1)?; // sps_temporal_id_nesting_flag
- profile_tier_level(r, max_sub_layers_minus1)?;
- r.ue()?; // sps_seq_parameter_set_id
- if r.ue()? == 3 {
- r.skip(1)?; // separate_colour_plane_flag
- }
- r.ue()?; // pic_width_in_luma_samples
- r.ue()?; // pic_height_in_luma_samples
+ let (_, _, max_sub_layers_minus1) = sps_size(r)?;
if r.flag()? {
for _ in 0..4 {
r.ue()?; // conf_win_*_offset