From 8e6a04baf69cf5607132d96013deef80a1ce725a Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 31 Oct 2026 11:27:13 -0400 Subject: [PATCH 1/5] M8: shape builders cap sizes, radii and angles at 1e9, so an absurd value cannot build millions of segments Found while testing crates/geom/src/lib.rs (no existing issue): the builders replaced NaN and infinities with 0 but took any finite size, and kurbo subdivides an arc in proportion to the sixth root of its radius, so ellipse(1e36, ...) built about a million cubics and 1e60 never returned (sizes arrive from files and commands). The sanitizer now also clamps magnitudes to MAX_COORD (1e9, the bound measure::finite_path already uses). Polyline::nearest returns None for a non-finite point instead of Some((NaN, NaN)). Tests cover 1e36, 1e300 and f64::MAX for every builder. Co-Authored-By: Claude Fable 5.1 --- crates/geom/src/lib.rs | 40 +++++++++++++++++++++++--------------- crates/geom/src/measure.rs | 2 +- crates/geom/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 012fca429..b5e7f7dc8 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -18,8 +18,16 @@ pub use transform::*; /// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`. pub type Radii = [f64; 4]; -fn finite(v: f64) -> f64 { - if v.is_finite() { v } else { 0.0 } +/// The largest magnitude a shape builder takes for a size, radius or angle. kurbo subdivides an +/// arc in proportion to the sixth root of its radius, so an uncapped radius means an unbounded +/// number of segments: 1e36 is about a million cubics and 1e60 never returns. Sizes come from +/// files and commands, so they are capped here (the sanity bound `measure::finite_path` uses). +pub const MAX_COORD: f64 = 1e9; + +/// `v` as a usable size, radius or angle: NaN and infinities are 0 and the magnitude is capped at +/// [`MAX_COORD`]. +fn bounded(v: f64) -> f64 { + if v.is_finite() { v.clamp(-MAX_COORD, MAX_COORD) } else { 0.0 } } /// A rectangle path at the origin with per-corner radii and corner smoothing (0..=1, Figma's @@ -28,15 +36,15 @@ fn finite(v: f64) -> f64 { /// Radii are clamped so adjacent corners never overlap; when smoothing doesn't fit, it is reduced /// first, then the radius. pub fn rounded_rect(width: f64, height: f64, radii: Radii, smoothing: f64) -> BezPath { - let w = finite(width).max(0.0); - let h = finite(height).max(0.0); + let w = bounded(width).max(0.0); + let h = bounded(height).max(0.0); let mut path = BezPath::new(); if w <= 0.0 && h <= 0.0 { return path; } - let s = finite(smoothing).clamp(0.0, 1.0); + let s = bounded(smoothing).clamp(0.0, 1.0); let max_r = w.min(h) / 2.0; - let radii = radii.map(|r| finite(r).clamp(0.0, max_r)); + let radii = radii.map(|r| bounded(r).clamp(0.0, max_r)); if radii.iter().all(|r| *r <= 0.0) { path.move_to((0.0, 0.0)); path.line_to((w, 0.0)); @@ -140,7 +148,7 @@ fn append_corner(path: &mut BezPath, corner: Point, din: Vec2, dout: Vec2, prof: /// first vertex at the top centre (Figma's polygon). pub fn polygon_points(width: f64, height: f64, count: u32) -> Vec { let n = count.clamp(3, 120); - let (rx, ry) = (finite(width) / 2.0, finite(height) / 2.0); + let (rx, ry) = (bounded(width) / 2.0, bounded(height) / 2.0); (0..n) .map(|i| { let t = -FRAC_PI_2 + TAU * f64::from(i) / f64::from(n); @@ -152,8 +160,8 @@ pub fn polygon_points(width: f64, height: f64, count: u32) -> Vec { /// The vertices of a star with `count` points and `inner_ratio` (0..1) inner radius. pub fn star_points(width: f64, height: f64, count: u32, inner_ratio: f64) -> Vec { let n = count.clamp(3, 120); - let k = finite(inner_ratio).clamp(0.001, 1.0); - let (rx, ry) = (finite(width) / 2.0, finite(height) / 2.0); + let k = bounded(inner_ratio).clamp(0.001, 1.0); + let (rx, ry) = (bounded(width) / 2.0, bounded(height) / 2.0); (0..n * 2) .map(|i| { let t = -FRAC_PI_2 + PI * f64::from(i) / f64::from(n); @@ -170,7 +178,7 @@ pub fn rounded_polygon(points: &[Point], radius: f64) -> BezPath { if n < 2 { return path; } - let radius = finite(radius).max(0.0); + let radius = bounded(radius).max(0.0); if radius <= 0.0 || n < 3 { for (i, p) in points.iter().enumerate() { if i == 0 { @@ -236,8 +244,8 @@ pub fn rounded_polygon(points: &[Point], radius: f64) -> BezPath { /// [`rounded_rect`], when a smoothed corner doesn't fit in half of its shorter edge, smoothing is /// reduced first, then the radius. Without smoothing this is [`rounded_polygon`]. pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPath { - let s = finite(smoothing).clamp(0.0, 1.0); - let radius = finite(radius).max(0.0); + let s = bounded(smoothing).clamp(0.0, 1.0); + let radius = bounded(radius).max(0.0); let n = points.len(); if s <= 0.0 || radius <= 0.0 || n < 3 { return rounded_polygon(points, radius); @@ -322,11 +330,11 @@ fn line_or_move(path: &mut BezPath, p: Point) { /// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in /// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1. pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath { - let (rx, ry) = (finite(width).max(0.0) / 2.0, finite(height).max(0.0) / 2.0); + let (rx, ry) = (bounded(width).max(0.0) / 2.0, bounded(height).max(0.0) / 2.0); let c = Point::new(rx, ry); - let sweep = finite(sweep).clamp(-TAU, TAU); - let start = finite(start); - let inner = finite(inner).clamp(0.0, 1.0); + let sweep = bounded(sweep).clamp(-TAU, TAU); + let start = bounded(start); + let inner = bounded(inner).clamp(0.0, 1.0); let full = sweep.abs() >= TAU - 1e-9; let mut path = BezPath::new(); if full && inner <= 0.0 { diff --git a/crates/geom/src/measure.rs b/crates/geom/src/measure.rs index 0c24aa9db..010d8499f 100644 --- a/crates/geom/src/measure.rs +++ b/crates/geom/src/measure.rs @@ -66,7 +66,7 @@ impl Polyline { /// The nearest point to `p`: (t by length, distance). pub fn nearest(&self, p: Point) -> Option<(f64, f64)> { let len = self.length(); - if len <= 1e-9 { + if len <= 1e-9 || !(p.x.is_finite() && p.y.is_finite()) { return None; } let mut best: Option<(f64, f64)> = None; diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 37e6ccab7..cbc5f0bc0 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -54,6 +54,46 @@ fn degenerate_inputs_dont_panic() { let _ = ellipse(f64::NAN, 10.0, f64::NAN, 100.0, 2.0); } +#[test] +fn absurd_sizes_build_bounded_paths() { + // kurbo subdivides an arc in proportion to the sixth root of its radius, so without a cap the + // segment count grows without bound with the input: a 1e36 ellipse was about a million + // elements and 1e60 never returned. Sizes are untrusted (files, commands), so they are capped. + for mag in [1e36, 1e300, f64::MAX] { + let paths = [ + ("ellipse", ellipse(mag, mag, 0.0, PI, 0.0)), + ("ring", ellipse(mag, mag, 0.0, TAU, 0.5)), + ("rounded_rect", rounded_rect(mag, mag, [mag; 4], 0.0)), + ("smoothed rect", rounded_rect(mag, mag, [mag; 4], 0.6)), + ("smoothed_polygon", smoothed_polygon(&polygon_points(mag, mag, 5), mag, 0.6)), + ]; + for (name, p) in paths { + assert!(p.elements().len() < 1_000, "{name} at {mag:e}: {} elements", p.elements().len()); + let b = p.bounding_box(); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= 1.01 * MAX_COORD), "{name} at {mag:e}: {b:?}"); + } + // Points are the caller's to keep sane; a huge radius is still capped. + let p = smoothed_polygon(&[Point::new(0.0, 0.0), Point::new(mag, 0.0), Point::new(mag, mag)], mag, 0.6); + assert!(p.elements().len() < 1_000, "huge points at {mag:e}: {} elements", p.elements().len()); + } + // Sizes a document can hold are untouched. + let p = rounded_rect(1e6, 1e6, [1e5; 4], 0.6); + let b = p.bounding_box(); + assert!((b.width() - 1e6).abs() < 1e-3 && (b.height() - 1e6).abs() < 1e-3, "{b:?}"); +} + +#[test] +fn nearest_refuses_a_non_finite_point() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((10.0, 0.0)); + let lines = measure::polylines(&p).unwrap(); + assert_eq!(lines[0].nearest(Point::new(f64::NAN, 0.0)), None); + assert_eq!(lines[0].nearest(Point::new(0.0, f64::INFINITY)), None); + let (t, d) = lines[0].nearest(Point::new(5.0, 3.0)).unwrap(); + assert!((t - 0.5).abs() < 1e-9 && (d - 3.0).abs() < 1e-9); +} + #[test] fn polygon_first_vertex_top_center() { let pts = polygon_points(100.0, 100.0, 3); From 33f32c11665de8befc5cc87711a5c5e3073be99f Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 00:14:06 -0400 Subject: [PATCH 2/5] Geom: the bounded-sizes test also covers the cap itself, NaN, infinities and negative sizes Review follow-up on #1774. Co-Authored-By: Claude Fable 5.1 --- crates/geom/src/tests.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index cbc5f0bc0..f50ea229b 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -59,7 +59,8 @@ fn absurd_sizes_build_bounded_paths() { // kurbo subdivides an arc in proportion to the sixth root of its radius, so without a cap the // segment count grows without bound with the input: a 1e36 ellipse was about a million // elements and 1e60 never returned. Sizes are untrusted (files, commands), so they are capped. - for mag in [1e36, 1e300, f64::MAX] { + // At the cap itself (1e9) an arc is still a few dozen segments. + for mag in [MAX_COORD, 1e36, 1e300, f64::MAX] { let paths = [ ("ellipse", ellipse(mag, mag, 0.0, PI, 0.0)), ("ring", ellipse(mag, mag, 0.0, TAU, 0.5)), @@ -76,6 +77,19 @@ fn absurd_sizes_build_bounded_paths() { let p = smoothed_polygon(&[Point::new(0.0, 0.0), Point::new(mag, 0.0), Point::new(mag, mag)], mag, 0.6); assert!(p.elements().len() < 1_000, "huge points at {mag:e}: {} elements", p.elements().len()); } + // NaN, infinities and negative sizes build nothing or a bounded path with a finite box. + for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1e36, -1.0] { + let paths = [ + ("ellipse", ellipse(v, v, v, v, v)), + ("rounded_rect", rounded_rect(v, v, [v; 4], v)), + ("smoothed_polygon", smoothed_polygon(&polygon_points(100.0, 100.0, 5), v, v)), + ]; + for (name, p) in paths { + assert!(p.elements().len() < 1_000, "{name} at {v}: {} elements", p.elements().len()); + let b = p.bounding_box(); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|x| x.is_finite()), "{name} at {v}: {b:?}"); + } + } // Sizes a document can hold are untouched. let p = rounded_rect(1e6, 1e6, [1e5; 4], 0.6); let b = p.bounding_box(); From 5c34af35f80722046119a26f300211e5cd83b4a2 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 02:00:21 -0500 Subject: [PATCH 3/5] Geom: the measurement bound includes the cap, as the builders clamp to it Co-Authored-By: Claude Fable 5.1 --- crates/geom/src/measure.rs | 6 ++++-- crates/geom/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/geom/src/measure.rs b/crates/geom/src/measure.rs index 010d8499f..03de47cd0 100644 --- a/crates/geom/src/measure.rs +++ b/crates/geom/src/measure.rs @@ -8,9 +8,11 @@ pub const FLATTEN_TOL: f64 = 0.05; /// The most flattened points a path is measured with; longer paths are refused. pub const MAX_SAMPLES: usize = 40_000; -/// Every coordinate is finite and of a sane magnitude. +/// Every coordinate is finite and of a sane magnitude: within [`crate::MAX_COORD`], the cap the +/// shape builders hold their sizes, radii and angles to (inclusive, so a path built at the cap +/// still measures). pub fn finite_path(path: &BezPath) -> bool { - let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9; + let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= crate::MAX_COORD && p.y.abs() <= crate::MAX_COORD; path.elements().iter().all(|el| match el { PathEl::MoveTo(p) | PathEl::LineTo(p) => ok(p), PathEl::QuadTo(a, b) => ok(a) && ok(b), diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index f50ea229b..3e9fdce1e 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -54,6 +54,27 @@ fn degenerate_inputs_dont_panic() { let _ = ellipse(f64::NAN, 10.0, f64::NAN, 100.0, 2.0); } +/// The measurement's sanity bound is the builders' cap, inclusive: a path built at `MAX_COORD` +/// has coordinates exactly there and still measures; one past it doesn't. +#[test] +fn paths_at_the_cap_still_measure() { + use crate::measure::{finite_path, polylines}; + let at_cap = rounded_rect(MAX_COORD, MAX_COORD, [0.0; 4], 0.0); + let b = at_cap.bounding_box(); + assert_eq!((b.x1, b.y1), (MAX_COORD, MAX_COORD), "corners exactly at the cap"); + assert!(finite_path(&at_cap)); + assert!(polylines(&at_cap).is_some(), "a rectangle at the cap measures"); + let mut line = BezPath::new(); + line.move_to(Point::new(-MAX_COORD, 0.0)); + line.line_to(Point::new(MAX_COORD, -MAX_COORD)); + assert!(polylines(&line).is_some(), "a line between the cap's corners measures"); + let mut past = BezPath::new(); + past.move_to(Point::new(0.0, 0.0)); + past.line_to(Point::new(MAX_COORD + 1.0, 0.0)); + assert!(!finite_path(&past)); + assert!(polylines(&past).is_none(), "a coordinate past the cap is refused"); +} + #[test] fn absurd_sizes_build_bounded_paths() { // kurbo subdivides an arc in proportion to the sixth root of its radius, so without a cap the From 5f89b05b7441ef819f9408c8592429d8d195d89b Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 02:59:39 -0500 Subject: [PATCH 4/5] Format: the unmeasurable text path case sits past the geometry cap Co-Authored-By: Claude Fable 5.1 --- crates/format/src/fig/text_path.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/format/src/fig/text_path.rs b/crates/format/src/fig/text_path.rs index 8a93f7e49..90c631514 100644 --- a/crates/format/src/fig/text_path.rs +++ b/crates/format/src/fig/text_path.rs @@ -298,8 +298,9 @@ mod tests { }; assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + // Past the cap the builders hold their sizes to (`figcraft_geom::MAX_COORD`, inclusive). let huge = Network { - vertices: vec![vertex(0.0, 0.0), vertex(1e9, 0.0)], + vertices: vec![vertex(0.0, 0.0), vertex(2e9, 0.0)], segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], ..Network::default() }; From a6e7920b24b6bc1508778068c437408e46f4483b Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 03:50:41 -0500 Subject: [PATCH 5/5] Geometry: measurement allows a capped arc's control points past the cap Co-Authored-By: Claude Fable 5.1 --- crates/geom/src/measure.rs | 13 +++++++++---- crates/geom/src/tests.rs | 18 ++++++++++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/geom/src/measure.rs b/crates/geom/src/measure.rs index 03de47cd0..717ec9d69 100644 --- a/crates/geom/src/measure.rs +++ b/crates/geom/src/measure.rs @@ -8,11 +8,16 @@ pub const FLATTEN_TOL: f64 = 0.05; /// The most flattened points a path is measured with; longer paths are refused. pub const MAX_SAMPLES: usize = 40_000; -/// Every coordinate is finite and of a sane magnitude: within [`crate::MAX_COORD`], the cap the -/// shape builders hold their sizes, radii and angles to (inclusive, so a path built at the cap -/// still measures). +/// How far out a measured coordinate may lie. The shape builders hold their sizes, radii and +/// angles to [`crate::MAX_COORD`], so a path built at the cap has its points there and the +/// control points of its curves a little past them (the cubics approximating an arc reach past +/// the arc's box by up to about a tenth of the radius). Half as much again keeps every built path +/// measurable and still refuses the absurd. +pub const MAX_MEASURED_COORD: f64 = crate::MAX_COORD * 1.5; + +/// Every coordinate is finite and of a sane magnitude (within [`MAX_MEASURED_COORD`]). pub fn finite_path(path: &BezPath) -> bool { - let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= crate::MAX_COORD && p.y.abs() <= crate::MAX_COORD; + let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MAX_MEASURED_COORD && p.y.abs() <= MAX_MEASURED_COORD; path.elements().iter().all(|el| match el { PathEl::MoveTo(p) | PathEl::LineTo(p) => ok(p), PathEl::QuadTo(a, b) => ok(a) && ok(b), diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 3e9fdce1e..24f47e1c5 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -68,11 +68,25 @@ fn paths_at_the_cap_still_measure() { line.move_to(Point::new(-MAX_COORD, 0.0)); line.line_to(Point::new(MAX_COORD, -MAX_COORD)); assert!(polylines(&line).is_some(), "a line between the cap's corners measures"); + // A curve at the cap: the cubics approximating the arc have control points past the cap, and + // the path still measures. + let arc = ellipse(MAX_COORD, 1.0, -0.15, 0.2, 0.0); + let reach = arc + .elements() + .iter() + .flat_map(|el| match el { + kurbo::PathEl::CurveTo(a, b, _) => vec![a.x, b.x], + _ => vec![], + }) + .fold(0.0, f64::max); + assert!(reach > MAX_COORD, "a control point lies past the cap: {reach}"); + assert!(finite_path(&arc)); + assert!(polylines(&arc).is_some(), "an arc at the cap measures"); let mut past = BezPath::new(); past.move_to(Point::new(0.0, 0.0)); - past.line_to(Point::new(MAX_COORD + 1.0, 0.0)); + past.line_to(Point::new(2.0 * MAX_COORD, 0.0)); assert!(!finite_path(&past)); - assert!(polylines(&past).is_none(), "a coordinate past the cap is refused"); + assert!(polylines(&past).is_none(), "a coordinate well past the cap is refused"); } #[test]