From 2e5c1dd41d7a91602b765370d387d50629dcefdd Mon Sep 17 00:00:00 2001 From: darthnut <120891176+darthnut@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:31:29 -0700 Subject: [PATCH 1/3] Never crash: tone curves take NaN input and lookup tables shorter than two entries (#2579) MonotoneCurve::eval let NaN past both bound checks into the binary search, which then indexed one past the end (or, for a NaN with the sign bit set, underflowed to -1). to_lut(1) divided 0 by 0, and Lut1::eval underflowed on `n - 2` for a table under two entries, which its public `v` allows. NaN now comes back as NaN, as Lut1::eval already did; to_lut, Lut1::identity and Lut1::from_fn make at least two entries; and a shorter table built by hand reads as a constant (one entry) or the identity (none). No caller reaches these today. Item 3 of #2579. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/spline.rs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/color/src/spline.rs b/crates/color/src/spline.rs index c8860d950..5f9eee304 100644 --- a/crates/color/src/spline.rs +++ b/crates/color/src/spline.rs @@ -56,6 +56,10 @@ impl MonotoneCurve { } pub fn eval(&self, x: f64) -> f64 { + // NaN passes both bound checks below and would send the search past either end + if x.is_nan() { + return x; + } let n = self.xs.len(); if x <= self.xs[0] { return self.ys[0] + self.ms[0] * (x - self.xs[0]).min(0.0) * 0.0; @@ -76,8 +80,9 @@ impl MonotoneCurve { + (t3 - t2) * h * self.ms[i + 1] } - /// Sample into a LUT of `n` entries over `0..1`. + /// Sample into a LUT of `n` entries (at least 2) over `0..1`. pub fn to_lut(&self, n: usize) -> Lut1 { + let n = n.max(2); Lut1 { v: (0..n).map(|i| self.eval(i as f64 / (n - 1) as f64) as f32).collect() } } @@ -93,15 +98,22 @@ pub struct Lut1 { } impl Lut1 { + /// At least 2 entries, like [`Lut1::from_fn`]. pub fn identity(n: usize) -> Lut1 { - Lut1 { v: (0..n).map(|i| i as f32 / (n - 1) as f32).collect() } + Lut1::from_fn(n, |x| x) } + /// `f` sampled at `n` evenly spaced points over `0..1` (at least 2). pub fn from_fn(n: usize, f: impl Fn(f32) -> f32) -> Lut1 { + let n = n.max(2); Lut1 { v: (0..n).map(|i| f(i as f32 / (n - 1) as f32)).collect() } } + /// `v` is public, so a table shorter than 2 entries is read as constant (1) or the identity (0). #[inline] pub fn eval(&self, x: f32) -> f32 { let n = self.v.len(); + if n < 2 { + return self.v.first().copied().unwrap_or(x); + } let f = x.clamp(0.0, 1.0) * (n - 1) as f32; let i = (f as usize).min(n - 2); let t = f - i as f32; @@ -163,6 +175,20 @@ mod tests { assert!((c.eval(0.3) - 0.3).abs() < 2e-3); } + #[test] + fn nan_input_and_short_luts_do_not_panic() { + let c = MonotoneCurve::new(&[(0.0, 0.0), (0.5, 0.6), (1.0, 1.0)]); + assert!(c.eval(f64::NAN).is_nan()); + assert!(c.eval(-f64::NAN).is_nan()); + for n in [0, 1] { + assert_eq!(c.to_lut(n).v.len(), 2, "{n}"); + assert!(Lut1::identity(n).is_identity(), "{n}"); + assert_eq!(Lut1::from_fn(n, |x| x).v.len(), 2, "{n}"); + } + assert_eq!(Lut1 { v: vec![] }.eval(0.3), 0.3); + assert_eq!(Lut1 { v: vec![0.7] }.eval(0.3), 0.7); + } + proptest::proptest! { #[test] fn monotone_for_increasing_data(ys in proptest::collection::vec(0.0f64..1.0, 3..8)) { From 8b3ce31b665c16983129649ebcab298e8b893ba7 Mon Sep 17 00:00:00 2001 From: darthnut <120891176+darthnut@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:31:29 -0700 Subject: [PATCH 2/3] Never crash: a UTC offset of i16::MIN formats and validates without overflow (#2579) DateTime deserializes with public fields, so its offset can arrive without passing valid(). `i16::MIN.abs()` overflows: a panic in debug builds and a negative minute count in release, which to_iso() then printed. valid() and to_iso() use unsigned_abs(). Item 4 of #2579. Co-Authored-By: Claude Opus 5.5 --- crates/meta/src/datetime.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/meta/src/datetime.rs b/crates/meta/src/datetime.rs index 4b7d09634..e33600cda 100644 --- a/crates/meta/src/datetime.rs +++ b/crates/meta/src/datetime.rs @@ -31,7 +31,7 @@ impl DateTime { // chrono rejects impossible days (leap years and all); the time is checked // by hand so a leap second (`second == 60`) stays acceptable. let day_ok = self.year > 0 && chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day)).is_some(); - let ok = day_ok && self.hour < 24 && self.minute < 60 && self.second < 61 && self.offset_minutes.is_none_or(|o| o.abs() <= 18 * 60); + let ok = day_ok && self.hour < 24 && self.minute < 60 && self.second < 61 && self.offset_minutes.is_none_or(|o| o.unsigned_abs() <= 18 * 60); ok.then_some(self) } @@ -135,7 +135,8 @@ impl DateTime { } if let Some(o) = self.offset_minutes { let sign = if o < 0 { '-' } else { '+' }; - s += &format!("{sign}{:02}:{:02}", o.abs() / 60, o.abs() % 60); + let o = o.unsigned_abs(); + s += &format!("{sign}{:02}:{:02}", o / 60, o % 60); } s } @@ -189,6 +190,14 @@ mod tests { assert_eq!(DateTime::parse_offset("+99:00"), None); } + #[test] + fn extreme_offsets_do_not_overflow() { + // `DateTime` deserializes with public fields, so an offset can arrive without passing `valid()` + let d = DateTime { offset_minutes: Some(i16::MIN), ..DateTime::parse_iso("2024-06-01T13:45:59").unwrap() }; + assert_eq!(d.to_iso(), "2024-06-01T13:45:59-546:08"); + assert_eq!(d.valid(), None); + } + #[test] fn unicode_offsets_are_rejected_without_panicking() { for offset in ["+1€", "-1€", "+€1", "-€1"] { From 2f622737c4dc23a4155f62df9a3569cac7ff5de6 Mon Sep 17 00:00:00 2001 From: darthnut <120891176+darthnut@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:46:34 -0700 Subject: [PATCH 3/3] Never crash: an empty lookup table clamps its input to 0..1 like any other (#2579) Review follow-up: Lut1::eval returned the input unclamped for an empty hand-built table, before the 0..1 clamp the type documents. The clamp now comes first for every table. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/spline.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/color/src/spline.rs b/crates/color/src/spline.rs index 5f9eee304..c2c75f305 100644 --- a/crates/color/src/spline.rs +++ b/crates/color/src/spline.rs @@ -110,11 +110,12 @@ impl Lut1 { /// `v` is public, so a table shorter than 2 entries is read as constant (1) or the identity (0). #[inline] pub fn eval(&self, x: f32) -> f32 { + let x = x.clamp(0.0, 1.0); let n = self.v.len(); if n < 2 { return self.v.first().copied().unwrap_or(x); } - let f = x.clamp(0.0, 1.0) * (n - 1) as f32; + let f = x * (n - 1) as f32; let i = (f as usize).min(n - 2); let t = f - i as f32; self.v[i] + (self.v[i + 1] - self.v[i]) * t @@ -186,6 +187,8 @@ mod tests { assert_eq!(Lut1::from_fn(n, |x| x).v.len(), 2, "{n}"); } assert_eq!(Lut1 { v: vec![] }.eval(0.3), 0.3); + assert_eq!(Lut1 { v: vec![] }.eval(-1.0), 0.0, "inputs outside 0..1 clamp, as for any table"); + assert_eq!(Lut1 { v: vec![] }.eval(2.0), 1.0); assert_eq!(Lut1 { v: vec![0.7] }.eval(0.3), 0.7); }