From 8538dd740f58e51ce672ac6ff8674673e683cef3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:38:36 +0200 Subject: [PATCH] Refuse edits that leave a layer with non-finite geometry Found while testing crates/engine/src/cmd/arrange.rs:132 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/finite_geometry_tests.rs | 40 ++++++++++++++++++++++ crates/engine/src/lib.rs | 21 +++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/finite_geometry_tests.rs diff --git a/crates/engine/src/finite_geometry_tests.rs b/crates/engine/src/finite_geometry_tests.rs new file mode 100644 index 00000000..416b250c --- /dev/null +++ b/crates/engine/src/finite_geometry_tests.rs @@ -0,0 +1,40 @@ +//! Commands never leave a layer with a non-finite position or size: such a file saves but can't be +//! reopened (JSON writes NaN and infinity as null). + +use serde_json::json; + +use crate::Session; + +fn rect(s: &mut Session) -> crate::NodeId { + let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 10, "width": 100, "height": 50})).unwrap(); + crate::params::id_of(&r["id"]).unwrap() +} + +fn assert_roundtrips(s: &Session) { + let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + assert!(figcraft_format::load_bytes(&bytes).is_ok(), "the saved file reopens"); +} + +#[test] +fn huge_moves_are_refused_and_the_file_still_reopens() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 0})).ok(); + let second = s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 1e308})); + assert!(second.is_err(), "a move past the f64 range is refused"); + let n = s.doc().unwrap().node(r).unwrap(); + assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{:?}", n.transform); + assert_roundtrips(&s); +} + +#[test] +fn huge_scale_is_refused_and_the_file_still_reopens() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})).ok(); + let second = s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})); + assert!(second.is_err(), "a size past the f64 range is refused"); + let n = s.doc().unwrap().node(r).unwrap(); + assert!(n.width.is_finite() && n.height.is_finite(), "{} x {}", n.width, n.height); + assert_roundtrips(&s); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..18498146 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -878,7 +878,8 @@ impl Session { let result = guard::run(id, || (spec.run)(self, params)) .and_then(|v| self.check_instance_structure(id, &before).map(|()| v)) .and_then(|v| self.check_preview(id, &before).map(|()| v)) - .and_then(|v| self.check_live_access(id, &before).map(|()| v)); + .and_then(|v| self.check_live_access(id, &before).map(|()| v)) + .and_then(|v| self.check_finite_geometry(id, &before).map(|()| v)); match &result { Ok(_) => { self.after_command(spec, before); @@ -916,6 +917,22 @@ impl Session { Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into())) } + /// A layer's position and size stay finite: infinity and NaN are written as `null`, so a file + /// holding them saves but can't be reopened. Refuse such an edit (it is rolled back). + fn check_finite_geometry(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { + let Some((bdoc, _, _, uid)) = before else { return Ok(()) }; + let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) }; + if Arc::ptr_eq(&ds.doc, bdoc) { + return Ok(()); + } + let broken = |n: &Node| !(n.transform.as_coeffs().iter().all(|c| c.is_finite()) && n.width.is_finite() && n.height.is_finite()); + let untouched = |n: &Node| bdoc.node_arc(n.id).zip(ds.doc.node_arc(n.id)).is_some_and(|(a, b)| Arc::ptr_eq(a, b)); + match ds.doc.nodes().find(|n| broken(n) && !untouched(n)) { + Some(n) => Err(bad(id, format!("\"{}\" would get a position or size beyond the number range", n.name))), + None => Ok(()), + } + } + /// A live session's document you may only view (or that is on its way to a server) is /// read-only: refuse an edit (it is rolled back). fn check_live_access(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { @@ -1528,6 +1545,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod finite_geometry_tests; +#[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests;