From 9cdb5ea5fdff2cc87ec6efe7c2d70f7545c6a042 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 10 Jun 2026 08:39:50 +0300 Subject: [PATCH] fix(io): purge degenerate arcs/splines that OOM on load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large survey drawings (105k entities, multi-million unit extents) could exhaust all RAM + swap on one core during load and get OOM-killed before display. Two parser-garbage entities slipped past the corrupt-entity purge and blew up truck's parameter_division tessellation: - Near-zero-sweep arcs: the zero-sweep guard only caught sweep < 1e-9, but a tiny non-zero sweep (e.g. 1.6e-6 rad) still places start/mid/end within truck's coincidence tolerance and recurses unboundedly. Gate on arc length (radius x sweep) instead so large-radius small-sweep arcs (still visible curves) survive while sub-precision arcs drop. - Desync splines with 100k control points and an invalid knot vector had no guard at all. Add a Spline arm rejecting the 100k desync signature, non-finite control points, degree < 1, and bad knot vectors. Also cap complex-linetype dash expansion (complex_lt::apply_along) at 1e6 pattern repeats, falling back to a solid stroke — a latent OOM of the same class on map-scale entities carrying a finely-scaled pattern. Adds regression tests for the arc-length and spline guards. Co-Authored-By: Claude Opus 4.8 --- src/io/mod.rs | 86 +++++++++++++++++++++++++++++++++++++++++ src/scene/complex_lt.rs | 20 ++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/io/mod.rs b/src/io/mod.rs index db75de0c4..377557e90 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -323,6 +323,14 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool { // to a single point in WCS — truck's circle_arc on three // coincident vertices recurses unboundedly in parameter_division. || (a.end_angle - a.start_angle).abs() < 1.0e-9 + // Near-zero sweep is the same trap with a wider mouth: a tiny but + // non-zero sweep (e.g. 1.6e-6 rad) still places start/mid/end + // within truck's coincidence tolerance, so parameter_division + // recurses and allocates until OOM. Gate on arc *length* + // (radius × sweep), not sweep alone, so a legitimately large- + // radius small-sweep arc (still a visible curve) survives while + // sub-precision arcs are dropped. + || a.radius.abs() * (a.end_angle - a.start_angle).abs() < 1.0e-6 || !finite_unit_normal(&a.normal) } E::Ellipse(e) => { @@ -340,6 +348,27 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool { || !e.minor_axis_ratio.is_finite() || e.minor_axis_ratio.abs() < 1.0e-10 } + E::Spline(s) => { + // Parser desync emits exactly-100_000-control-point splines with a + // garbage knot vector. Building a truck NURBS/B-spline from one and + // tessellating it runs `parameter_division` into an unbounded + // allocation — single-threaded, 32 GB+ — long before the drawing + // finishes loading. Reject the desync signature plus any spline + // truck can't build: non-finite control points, or a knot vector + // that's non-finite, non-monotonic, or the wrong length + // (truck requires `knots.len() == ctrl.len() + degree + 1`). + let n = s.control_points.len(); + let degree_bad = s.degree < 1; + let deg = s.degree.max(0) as usize; + let knots_bad = !s.knots.is_empty() + && (s.knots.iter().any(|k| !k.is_finite()) + || s.knots.windows(2).any(|w| w[1] < w[0]) + || s.knots.len() != n + deg + 1); + n >= MAX_VERTS + || degree_bad + || s.control_points.iter().any(|p| !finite_vec3(p)) + || knots_bad + } _ => false, } } @@ -444,3 +473,60 @@ mod layer_roundtrip_tests { assert!(roundtrip_layers("dwg", 3), "DWG dropped colliding new layers (issue #67)"); } } + +#[cfg(test)] +mod corrupt_guard_tests { + use super::*; + use acadrust::entities::{Arc, EntityType, Spline}; + use acadrust::types::Vector3; + + // A near-zero-sweep arc: sweep 1.56e-6 rad on a 3.9e-3 radius. The angles + // are individually finite and the radius is in range, so the old + // (end-start) < 1e-9 check passed it through — but start/mid/end land + // within truck's coincidence tolerance and parameter_division allocates + // until OOM. The arc-length floor must reject it. + #[test] + fn rejects_near_degenerate_arc() { + let mut a = Arc::new(); + a.center = Vector3::new(2880.84, 891.83, 0.0); + a.radius = 0.0038974142851181423; + a.start_angle = 1.0401656235942365; + a.end_angle = 1.0401671831670538; + a.normal = Vector3::new(0.0, 0.0, 1.0); + assert!(is_entity_corrupt(&EntityType::Arc(a))); + } + + // A large-radius small-sweep arc is still a visible curve and must survive: + // radius 1e6 × sweep 1e-4 ≈ 100 units of arc. + #[test] + fn keeps_large_radius_small_sweep_arc() { + let mut a = Arc::new(); + a.radius = 1.0e6; + a.start_angle = 0.0; + a.end_angle = 1.0e-4; + a.normal = Vector3::new(0.0, 0.0, 1.0); + assert!(!is_entity_corrupt(&EntityType::Arc(a))); + } + + // Parser desync emits 100_000-control-point splines; building a truck + // NURBS from one and tessellating it OOMs. The control-point cap rejects it. + #[test] + fn rejects_desync_spline() { + let pts = vec![Vector3::new(0.0, 0.0, 0.0); 100_000]; + let s = Spline::from_control_points(3, pts); + assert!(is_entity_corrupt(&EntityType::Spline(s))); + } + + // A normal cubic spline (4 control points, valid clamped knots) survives. + #[test] + fn keeps_valid_spline() { + let pts = vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 1.0, 0.0), + Vector3::new(2.0, -1.0, 0.0), + Vector3::new(3.0, 0.0, 0.0), + ]; + let s = Spline::from_control_points(3, pts); + assert!(!is_entity_corrupt(&EntityType::Spline(s))); + } +} diff --git a/src/scene/complex_lt.rs b/src/scene/complex_lt.rs index 975272346..f6c1eb4c7 100644 --- a/src/scene/complex_lt.rs +++ b/src/scene/complex_lt.rs @@ -47,6 +47,26 @@ pub fn apply_along( return vec![]; } + // Guard against pattern blow-up. The per-segment walk below emits at least + // one stroke vertex per pattern element, so the total vertex count scales + // with `path_len / pattern_len`. On a large-extent drawing (e.g. a city/ + // street map) carrying a finely-scaled complex linetype, that ratio can + // reach billions — the strokes Vec grows until the process is OOM-killed, + // single-threaded, before the drawing ever finishes loading. Past a sane + // repeat count the dashes are sub-pixel anyway, so render the base wire + // solid: returning empty here makes the caller fall back to the solid base. + const MAX_PATTERN_REPEATS: f32 = 1_000_000.0; + let path_len: f32 = path_pts + .windows(2) + .map(|w| { + let d = [w[1][0] - w[0][0], w[1][1] - w[0][1], w[1][2] - w[0][2]]; + (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() + }) + .sum(); + if path_len / pattern_len > MAX_PATTERN_REPEATS { + return vec![]; + } + let mut strokes: Vec> = Vec::new(); let mut cur_stroke: Vec<[f32; 3]> = Vec::new();