diff --git a/crates/keyframe/src/tests.rs b/crates/keyframe/src/tests.rs index 312703239..9b9d13e88 100644 --- a/crates/keyframe/src/tests.rs +++ b/crates/keyframe/src/tests.rs @@ -130,6 +130,24 @@ fn gradient_tracks_sample_separately() { assert_eq!((empty.color_at(0.3), empty.opacity_at(0.3)), ([1.0; 4], 1.0)); } +/// A gradient's stop count multiplies every painted pixel's sampling: plain JSON with more than +/// `MAX_GRADIENT_STOPS` is refused, and a longer stored list is sampled through its first ones. +#[test] +fn gradient_stop_count_is_bounded() { + use crate::value::MAX_GRADIENT_STOPS; + let g = Value::Gradient(Gradient::default()); + let many: Vec = (0..=MAX_GRADIENT_STOPS).map(|_| serde_json::json!("#ff0000")).collect(); + assert_eq!(g.coerce_json(&serde_json::Value::Array(many.clone())), None); + assert!(g.coerce_json(&serde_json::Value::Array(many[..MAX_GRADIENT_STOPS].to_vec())).is_some()); + let ops: Vec = (0..=MAX_GRADIENT_STOPS).map(|i| serde_json::json!([i as f64 / MAX_GRADIENT_STOPS as f64, 1.0])).collect(); + assert_eq!(g.coerce_json(&serde_json::json!({"colors": ["#000000", "#ffffff"], "opacities": ops})), None); + // A stored gradient with a huge tail: stops past the limit don't take part in sampling. + let mut colors: Vec<(f64, [f32; 4])> = (0..MAX_GRADIENT_STOPS).map(|i| (i as f64 / MAX_GRADIENT_STOPS as f64 * 0.5, [0.0, 0.0, 0.0, 1.0])).collect(); + colors.extend((0..100_000).map(|_| (1.0, [1.0, 1.0, 1.0, 1.0]))); + let big = Gradient { colors, opacities: vec![(0.0, 1.0)] }; + assert_eq!(big.sample(1.0), [0.0, 0.0, 0.0, 1.0]); +} + /// #612: the editor saves the typed form, so one colour stop (which the plain JSON parser /// rejects) and each stop's RGBA come back exactly. #[test] diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index 6609aed68..73540de4b 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -210,7 +210,13 @@ impl Gradient { } } +/// Most stops (colour or opacity) a gradient is sampled through. Gradients are sampled per +/// pixel, so the stop count multiplies every painted pixel's work: stops past this are ignored +/// when sampling, and a gradient written as JSON with more is refused. +pub const MAX_GRADIENT_STOPS: usize = 256; + fn sample_stops(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option { + let stops = &stops[..stops.len().min(MAX_GRADIENT_STOPS)]; let first = stops.first()?; if t <= first.0 { return Some(first.1); @@ -243,7 +249,7 @@ fn gradient_json(j: &serde_json::Value) -> Option { } fn stops(a: &[J]) -> Option> { let n = a.len(); - if n < 2 { + if !(2..=MAX_GRADIENT_STOPS).contains(&n) { return None; } a.iter() @@ -259,6 +265,7 @@ fn gradient_json(j: &serde_json::Value) -> Option { J::Object(o) => { let colors = stops(o.get("colors")?.as_array()?)?; let op = match o.get("opacities") { + Some(J::Array(a)) if a.len() > MAX_GRADIENT_STOPS => return None, Some(J::Array(a)) => Some( a.iter() .map(|s| {