Swatches: reject colour values beyond f32 range so presets survive a restart (#4150) (#4118)

Root cause: SwatchColor::from_param checked that each value was finite as
an f64, then cast it to f32. A value like 1e39 became inf, and an HSB hue
then wrapped (rem_euclid) to NaN. swatches.add stored Hsb([NaN, ..]), the
prefs file wrote it as null, and on the next launch load_presets_json
failed to parse the whole presets blob, so every user gradient, style,
shape, tool/document preset and swatch fell back to the defaults.

Fix: reject values whose magnitude exceeds f32::MAX with BadParams, for
every colour model.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
David MannandClaude Opus 5.5 authored and GitHub committed 2026-10-12 20:37:07 -04:00
1 parent b1b3dff8f1
commit b87180e14e
2 files changed
+37 -1

No files matched your search

+10 -1
View File
@@ -166,7 +166,14 @@ impl SwatchColor {
.and_then(Value::as_array)
.ok_or("a colour needs `values`")?
.iter()
.map(|x| x.as_f64().filter(|f| f.is_finite()).map(|f| f as f32).ok_or("colour values must be finite numbers"))
// Beyond f32 range a value turns into inf as f32 (an HSB hue then wraps to NaN, which
// the prefs file can't store), so reject it here.
.map(|x| {
x.as_f64()
.filter(|f| f.is_finite() && f.abs() <= f64::from(f32::MAX))
.map(|f| f as f32)
.ok_or("colour values must be finite numbers within f32 range")
})
.collect::<Result<_, _>>()?;
let need = |n: usize| if vals.len() == n { Ok(()) } else { Err(format!("a {model} colour has {n} values, got {}", vals.len())) };
let pct = |v: f32| (v / 100.0).clamp(0.0, 1.0);
@@ -403,6 +410,8 @@ mod tests {
json!({"model": "rgb", "values": ["a", 2, 3]}),
json!({"model": "xyz", "values": [1, 2, 3]}),
json!({"model": "gray", "values": []}),
json!({"model": "hsb", "values": [1e39, 100, 100]}),
json!({"model": "rgb", "values": [0, -1e39, 0]}),
] {
assert!(SwatchColor::from_param(&bad).is_err(), "{bad}");
}
+27
View File
@@ -289,3 +289,30 @@ fn library_limits() {
assert_eq!(total(&g), MAX_SWATCHES);
assert_eq!(g[0].name.chars().count(), MAX_NAME);
}
/// A value beyond f32 range used to pass the finite check, become inf as f32 and wrap to a NaN
/// hue, which the prefs file stores as `null`; the next launch then dropped every saved preset.
#[test]
fn values_beyond_f32_range_are_rejected_so_presets_survive_a_restart() {
let mut s = Session::new();
s.execute("swatches.add", json!({"name": "Keep me", "color": {"model": "lab", "values": [40, 12.5, -7.25]}})).unwrap();
let before = s.presets.swatches.clone();
for (model, values) in [
("hsb", json!([1e39, 100, 100])),
("hsb", json!([-1e39, 100, 100])),
("rgb", json!([1e39, 0, 0])),
("cmyk", json!([0, 0, 0, -1e300])),
("lab", json!([50, 1e39, 0])),
("gray", json!([1e39])),
] {
let r = s.execute("swatches.add", json!({"name": "Bad", "color": {"model": model, "values": values}}));
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "{model} {values}: {r:?}");
}
assert_eq!(s.presets.swatches, before, "a rejected colour leaves the library unchanged");
// The largest f32 hue is still accepted and stays finite.
s.execute("swatches.add", json!({"name": "Big hue", "color": {"model": "hsb", "values": [f64::from(f32::MAX), 100, 100]}})).unwrap();
let mut t = Session::new();
t.load_prefs_json(&s.prefs_to_json()).unwrap();
assert_eq!(t.presets.swatches, s.presets.swatches, "user swatches survive a save and reload");
assert!(names(&t).contains(&"Keep me".to_string()));
}