diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs index 8b619842d1..963ae3cdb5 100644 --- a/crates/cms/src/curve.rs +++ b/crates/cms/src/curve.rs @@ -293,15 +293,22 @@ fn table_inverse_with(t: &[f32], y: f32, increasing: bool, edge_start: f32, edge /// For a flat run at the table start (`start = true`) the last index of the run, or for a flat /// run at the end the first index of it, as a normalised input. +/// +/// A table that is inverted is monotonic, so the run is found by bisection, not by a scan: a long +/// constant table is inverted once per pixel, and scanning it each time made the cost the curve's +/// length times the image's. fn edge_index(t: &[f32], start: bool) -> f32 { let n = t.len(); if start { let v = t[0]; - let i = t.iter().position(|x| (*x - v).abs() > 1e-7).map_or(n - 1, |i| i - 1); + // The first value that differs, given they do so from there on. + let first_other = t.partition_point(|x| (*x - v).abs() <= 1e-7); + let i = if first_other >= n { n - 1 } else { first_other.saturating_sub(1) }; i as f32 / (n - 1) as f32 } else { let v = t[n - 1]; - let i = t.iter().rposition(|x| (*x - v).abs() > 1e-7).map_or(0, |i| i + 1); + // The start of the trailing run equal to the last value. + let i = t.partition_point(|x| (*x - v).abs() > 1e-7); i as f32 / (n - 1) as f32 } } @@ -362,6 +369,21 @@ mod tests { assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12); } + #[test] + fn inverting_a_long_flat_table_does_not_rescan_it_for_every_pixel() { + // Four million zeros and a one: a scan per call would be 8·10¹¹ steps for these calls. + let mut t = vec![0.0f32; 4_000_000]; + t.push(1.0); + let c = Curve::Table(t); + let started = std::time::Instant::now(); + let mut last = 0.0; + for _ in 0..200_000 { + last = c.eval_inverse(0.0); + } + assert!(last > 0.99, "the flat start ends at the last zero: {last}"); + assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed()); + } + #[test] fn inverse_table_matches_per_call_including_flat_runs() { // Batch inverse must equal calling eval_inverse64 per point. Include a table with flat diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index 99284772f3..01b2327df8 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -184,6 +184,7 @@ pub struct Profile { impl Profile { /// Parses ICC profile bytes. pub fn parse(bytes: &[u8]) -> Result { + CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES)); let mut p = parse_profile(bytes)?; p.bytes = Some(Arc::new(bytes.to_vec())); Ok(p) @@ -585,6 +586,24 @@ fn parse_profile(bytes: &[u8]) -> Result { Ok(p) } +/// Sampled-curve entries one profile may decode in all (64 MB of `f32`). Tags and curve groups can +/// point at the same encoded bytes, and each reference decodes its own table. +const MAX_CURVE_SAMPLES: usize = 16 << 20; + +thread_local! { + /// What is left of [`MAX_CURVE_SAMPLES`] for the profile being parsed on this thread. + static CURVE_SAMPLES_LEFT: std::cell::Cell = const { std::cell::Cell::new(MAX_CURVE_SAMPLES) }; +} + +/// Charges `n` decoded curve samples to the profile being parsed. +fn charge_curve_samples(n: usize) -> Result<(), CmsError> { + CURVE_SAMPLES_LEFT.with(|left| { + let rest = left.get().checked_sub(n).ok_or_else(|| CmsError::Invalid("the profile's curves hold too many samples".into()))?; + left.set(rest); + Ok(()) + }) +} + /// Parses a `curv` or `para` element; returns the curve and the element's byte length /// (unpadded). fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> { @@ -602,6 +621,7 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> { 1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)), _ => { let s = r.slice(12, n * 2)?; + charge_curve_samples(n)?; let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(); Ok((Curve::Table(t), len)) } @@ -959,3 +979,28 @@ mod tests { assert_eq!(eval(&cached(srgb, lab, opts).unwrap()), sr); } } + +#[cfg(test)] +mod curve_budget_tests { + use super::*; + + #[test] + fn aliased_curves_are_charged_for_each_decoded_table() { + // One 1M-sample `curv` element referenced seventeen times decodes 17M samples. + let mut element = b"curv\0\0\0\0".to_vec(); + element.extend((1u32 << 20).to_be_bytes()); + element.extend(std::iter::repeat_n(0u8, 2 << 20)); + CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES)); + let mut failed_at = None; + for i in 0..17 { + if parse_curve(&element).is_err() { + failed_at = Some(i); + break; + } + } + assert_eq!(failed_at, Some(16), "sixteen tables fit the budget, the seventeenth does not"); + // Each profile parse starts with a fresh budget. + CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES)); + assert!(parse_curve(&element).is_ok()); + } +} diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs index 5af4903c55..47828c82d5 100644 --- a/crates/psd/src/pixels.rs +++ b/crates/psd/src/pixels.rs @@ -245,12 +245,14 @@ impl<'a> Layer<'a> { let rect = self.record.rect; let (w, h) = rect.size()?; let n = w.checked_mul(h).ok_or(PsdError::LimitExceeded("layer size overflow"))?; - if n.checked_mul(4).is_none_or(|len| len as u64 > crate::compression::MAX_DECODED_BYTES) { - return Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES")); - } let depth = self.header.depth; let mode = self.header.color_mode; let cc = color_channel_count(mode)?; + // The planes (one per colour channel, filled in for channels the layer lacks, and alpha) + // coexist with the RGBA output, so the cap covers all of them, not the output alone. + if n.checked_mul(cc + 2 + 4).is_none_or(|len| len as u64 > crate::compression::MAX_DECODED_BYTES) { + return Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES")); + } let mut color = Vec::with_capacity(cc); for id in 0..cc as i16 { let plane = if self.record.channel(id).is_some() { diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index cab5ce2a05..6ea536aaa8 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -314,7 +314,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou let scatter = if layout == JpegLayout::Quads && samples == 1 { s.w.saturating_mul(s.h).saturating_mul(2) as u64 } else { 0 }; let budget = usize::try_from(limits.max_alloc.saturating_sub(scatter) / 2).unwrap_or(usize::MAX); let geom = s.w.saturating_mul(s.h).saturating_mul(samples).saturating_mul(4); - let (f, v) = ljpeg::decode(src, geom.min(budget))?; + let (f, mut v) = ljpeg::decode(src, geom.min(budget))?; if v.len() < need { return Err(RawError::malformed("lossless JPEG tile is smaller than its tile")); } @@ -329,6 +329,9 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou } return Ok(tile); } + // The frame may be declared larger than the tile; keep (and so charge) the tile. + v.truncate(s.w.saturating_mul(s.h).saturating_mul(samples)); + v.shrink_to_fit(); Ok(v) } } diff --git a/crates/raw/src/tiff.rs b/crates/raw/src/tiff.rs index ca190a92f0..df695e3346 100644 --- a/crates/raw/src/tiff.rs +++ b/crates/raw/src/tiff.rs @@ -305,6 +305,17 @@ impl<'a> Tiff<'a> { (!s.is_empty()).then_some(s) } + /// [`Tiff::ascii`] for diagnostics: reads at most `limit` bytes (and a few more, so a + /// character cut at the limit still decodes) of a value that may be gigabytes long and + /// shared by many entries. + pub fn ascii_prefix(&self, e: &Entry, limit: usize) -> Option { + let b = self.raw(e)?; + let b = b.get(..b.len().min(limit.saturating_add(4)))?; + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + let s = String::from_utf8_lossy(b.get(..end)?).trim().to_string(); + (!s.is_empty()).then_some(s) + } + pub fn tag_uint(&self, ifd: &Ifd, t: u16) -> Option { ifd.get(t).and_then(|e| self.uint(e, 0)) } @@ -345,7 +356,7 @@ pub(crate) fn dump(data: &[u8]) -> String { break 'ifds; } let preview: String = if e.typ == 2 { - let mut a = t.ascii(e).unwrap_or_default(); + let mut a = t.ascii_prefix(e, MAX_ASCII_PREVIEW).unwrap_or_default(); if a.len() > MAX_ASCII_PREVIEW { // Truncate at a char boundary at or below the cap (lossy UTF-8 can hold // multi-byte replacement chars). diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index 2492414d2a..d6c21e3ac3 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -424,15 +424,13 @@ fn sample_color(stops: &[(f32, [f32; 4])], mids: &[f32], t: f32) -> [f32; 4] { if t <= first.0 { return first.1; } - for (i, w) in stops.windows(2).enumerate() { - let (a, b) = (&w[0], &w[1]); - if t <= b.0 { - let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; - let k = midpoint_remap(u, mids.get(i).copied().unwrap_or(0.5)); - return std::array::from_fn(|ch| a.1[ch] + (b.1[ch] - a.1[ch]) * k); - } - } - last.1 + // The segment ending at the first stop at or past `t` (bisection: this runs per pixel, and a + // gradient can carry any number of stops). Past the last stop, or for a NaN, there is none. + let at = stops.partition_point(|s| s.0 < t); + let (Some(a), Some(b)) = (at.checked_sub(1).and_then(|k| stops.get(k)), stops.get(at)) else { return last.1 }; + let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; + let k = midpoint_remap(u, mids.get(at - 1).copied().unwrap_or(0.5)); + std::array::from_fn(|ch| a.1[ch] + (b.1[ch] - a.1[ch]) * k) } /// Opacity stops at `t` (stops sorted by location; 1.0 when empty). @@ -441,13 +439,10 @@ fn sample_opacity(stops: &[(f32, f32)], t: f32) -> f32 { if t <= first.0 { return first.1; } - for w in stops.windows(2) { - if t <= w[1].0 { - let u = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 }; - return w[0].1 + (w[1].1 - w[0].1) * u; - } - } - last.1 + let at = stops.partition_point(|s| s.0 < t); + let (Some(a), Some(b)) = (at.checked_sub(1).and_then(|k| stops.get(k)), stops.get(at)) else { return last.1 }; + let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; + a.1 + (b.1 - a.1) * u } /// Colour and alpha at `t`: colour stops with midpoints, alpha times opacity stops. Mirrors diff --git a/crates/vector/src/tests.rs b/crates/vector/src/tests.rs index 8ef86f18ef..815c7be5e6 100644 --- a/crates/vector/src/tests.rs +++ b/crates/vector/src/tests.rs @@ -583,3 +583,34 @@ fn tile_bands_and_band_tiles_near_i32_max_do_not_overflow() { assert_eq!(tiles.len(), 1); assert_eq!(tiles[0].0, band); } + +#[test] +fn gradient_stops_are_found_by_bisection_and_agree_with_a_scan() { + let stops: Vec<(f32, [f32; 4])> = (0..=8).map(|i| (i as f32 / 8.0, [i as f32 / 8.0, 0.0, 1.0 - i as f32 / 8.0, 1.0])).collect(); + let scan = |t: f32| { + let (first, last) = (stops[0], stops[stops.len() - 1]); + if t <= first.0 { + return first.1; + } + for w in stops.windows(2) { + if t <= w[1].0 { + let u = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 }; + return std::array::from_fn(|ch| w[0].1[ch] + (w[1].1[ch] - w[0].1[ch]) * u); + } + } + last.1 + }; + for i in -10..=110 { + let t = i as f32 / 100.0; + assert_eq!(sample_color(&stops, &[], t), scan(t), "t = {t}"); + } + assert_eq!(sample_color(&stops, &[], f32::NAN), stops[8].1, "a NaN position takes the last stop, as before"); + assert_eq!(sample_opacity(&[(0.0, 0.0), (1.0, 1.0)], 0.25), 0.25); + // A million stops are bisected: this is a per-pixel call. + let many: Vec<(f32, [f32; 4])> = (0..1_000_000).map(|i| (i as f32 / 1_000_000.0, [0.0; 4])).collect(); + let started = std::time::Instant::now(); + for i in 0..100_000 { + sample_color(&many, &[], 0.9 + i as f32 * 1e-7); + } + assert!(started.elapsed().as_secs() < 5, "{:?}", started.elapsed()); +}