mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 01:47:08 -06:00
security: bisect flat-table and gradient-stop lookups; charge ICC curve samples; bound PSD planes, RAW ljpeg retention and TIFF dump preview
This commit is contained in:
+24
-2
@@ -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
|
||||
|
||||
@@ -184,6 +184,7 @@ pub struct Profile {
|
||||
impl Profile {
|
||||
/// Parses ICC profile bytes.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Profile, CmsError> {
|
||||
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<Profile, CmsError> {
|
||||
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<usize> = 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -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<String> {
|
||||
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<u32> {
|
||||
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).
|
||||
|
||||
+11
-16
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user