fix(io): purge degenerate arcs/splines that OOM on load

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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-10 08:39:50 +03:00
1 parent e6ab0e70ac
commit 9cdb5ea5fd
2 files changed
+106

No files matched your search

+86
View File
@@ -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)));
}
}
+20
View File
@@ -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<[f32; 3]>> = Vec::new();
let mut cur_stroke: Vec<[f32; 3]> = Vec::new();