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 <noreply@anthropic.com>
This commit is contained in:
Trevor BrownandClaude Fable 5.1 committed 2026-10-31 11:27:13 -04:00
1 parent e04c5d2274
commit 8e6a04baf6
3 files changed
+65 -17

No files matched your search

+24 -16
View File
@@ -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<Point> {
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<Point> {
/// 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<Point> {
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 {
+1 -1
View File
@@ -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;
+40
View File
@@ -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);