From 8a2b8f4bdf214c7f3e3ac3ea43a1ed35c78a5d24 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:43:04 +0300 Subject: [PATCH 01/57] io: the four symbol tables honour the same record cap as their peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LAYER, LTYPE, STYLE and DIMSTYLE records were appended with no cap while the VIEW, UCS and VPORT arms of the same loop guard at MAX_OBJECTS — so a hostile DXF could grow the layer table (each record found by a linear case-insensitive name scan) without bound. The same guard now applies to all seven table kinds. Behaviour is unchanged for every legal drawing: no drawing approaches a million table records, so no regression test can reach the cap at unit scale (the same reason the peer guards shipped without one). The quadratic cost of the name lookups themselves is a separate, larger change (resolving table names through a map while reading) deliberately left to the maintainers. --- crates/io/src/dxf_read.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 7615bc569..705a7cc08 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1422,7 +1422,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { match kind.as_str() { "LAYER" => { let name = t.s(2).unwrap_or_default(); - if name.is_empty() { + if name.is_empty() || d.layers.len() >= MAX_OBJECTS { continue; } let aci = t.i(62).unwrap_or(7); @@ -1459,7 +1459,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { } "LTYPE" => { let name = t.s(2).unwrap_or_default(); - if name.is_empty() || d.linetype(&name).is_some() { + if name.is_empty() || d.linetype(&name).is_some() || d.linetypes.len() >= MAX_OBJECTS { continue; } let pattern = ltype_pattern(&tg, <ype_styles); @@ -1467,7 +1467,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { } "STYLE" => { let name = t.s(2).unwrap_or_default(); - if name.is_empty() { + if name.is_empty() || d.text_styles.len() >= MAX_OBJECTS { continue; } // 70 bit 4: vertical; 71 bits 2/4: backwards / upside down. @@ -1549,7 +1549,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { } "DIMSTYLE" => { let name = t.s(2).unwrap_or_default(); - if name.is_empty() { + if name.is_empty() || d.dim_styles.len() >= MAX_OBJECTS { continue; } // Every variable with a DIMSTYLE group code; values the style can't take are From ea6d7beca208b8d12c4b21be694021931b4dea16 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 23:58:01 -0400 Subject: [PATCH 02/57] ci: switch to shared nix workflow from craft-common-actions --- .github/workflows/nix.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587a..01cb9cd66 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main From 4c75b0c4586b5f89564100ba277bb57cc61a19e3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:43:01 +0000 Subject: [PATCH 03/57] Treat polyline bulges with a sub-EPS sweep as straight segments Found while testing crates/geom/src/curve.rs:435 (bulge_to_arc) together with crates/geom/src/lib.rs:45-48 (ccw_sweep) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/geom/src/curve.rs | 4 ++++ crates/geom/src/tests.rs | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/crates/geom/src/curve.rs b/crates/geom/src/curve.rs index fdd2cf55d..40344b351 100644 --- a/crates/geom/src/curve.rs +++ b/crates/geom/src/curve.rs @@ -437,6 +437,10 @@ pub fn bulge_to_arc(a: Vec2, b: Vec2, bulge: f64) -> Option<(Arc, bool)> { return None; } let theta = 4.0 * bulge.atan(); // signed included angle + // A sweep at or below EPS would be read back as a full circle by `ccw_sweep`; it is a straight segment. + if theta.abs() <= EPS { + return None; + } let chord = a.dist(b); let r = chord / (2.0 * (theta / 2.0).sin().abs()); let mid = a.mid(b); diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index dd7b806c8..8b8a0bad2 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -74,6 +74,16 @@ fn bulge_quarter_arc() { assert!(close(arc.sweep(), PI / 2.0)); } +#[test] +fn tiny_bulge_is_not_a_full_circle() { + let a = Vec2::new(0.0, 0.0); + let b = Vec2::new(10.0, 0.0); + let p = Polyline { vertices: vec![PolyVertex::with_bulge(a, 1e-10), PolyVertex::new(b)], closed: false }; + assert!((p.len() - 10.0).abs() < 1e-6, "len={}", p.len()); + let bb = p.bounds(); + assert!(bb.max.x < 10.001 && bb.max.y < 0.001 && bb.min.y > -0.001, "bounds={bb:?}"); +} + #[test] fn polyline_area_and_length() { let p = Polyline::from_points(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)], true); From d9adce038cf4dddf0d2bd6687563e3abfda29d49 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 19 Oct 2026 17:26:39 +0500 Subject: [PATCH 04/57] AutoLISP: `=` compares the symbols `type` returns `(= (type x) 'LIST)` is how an AutoLISP routine tests a type, because `type` returns a symbol. `=` coerced both of its arguments to numbers, so the symbol failed the coercion and the routine stopped at its first type test: _$ (= (type 1) 'INT) bad argument type: numberp: INT `=` and `/=` now compare numbers and strings as before, and any other atom -- a symbol, `nil`, an entity name, a selection set, a file handle -- by what it is, the way `eq` does. Two consequences, both closer to AutoLISP than raising was: a mismatch of kinds answers `nil` rather than failing, so `(= 1 "1")` and `(= '(1) '(1))` answer (comparing list structure is `equal`'s job), and `(= nil nil)` is `T`. The ordering comparisons still take numbers and strings only, as AutoLISP has them: `(< 'a 'b)` remains `bad argument type: numberp: A`. Fix: #1597 --- crates/engine/src/lisp/builtins.rs | 25 ++++++++++++++++++++-- crates/engine/src/lisp/tests.rs | 33 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lisp/builtins.rs b/crates/engine/src/lisp/builtins.rs index f55351478..73aea3635 100644 --- a/crates/engine/src/lisp/builtins.rs +++ b/crates/engine/src/lisp/builtins.rs @@ -307,11 +307,32 @@ fn chain(a: &[Value], ok: fn(std::cmp::Ordering) -> bool) -> R { } Ok(Value::t()) } +/// `=` and `/=`: numbers and strings compare as [`compare`] does, and any other atom compares by +/// what it is, as `eq` does. AutoLISP answers `(= (type x) 'LIST)` -- the idiom for a type test, +/// since `type` returns a symbol -- where coercing that symbol to a number failed the whole +/// routine with "bad argument type" instead (#1597). A mismatch is `nil`, not an error, so +/// `(= 1 "1")` and `(= '(1) '(1))` answer too. The ordering comparisons stay numbers and strings +/// only, as AutoLISP has them. +fn eq_chain(a: &[Value], want_equal: bool) -> R { + arity(a, 1, usize::MAX)?; + for w in a.windows(2) { + let [x, y] = w else { continue }; + let same = match (x, y) { + (Value::Str(_), Value::Str(_)) => compare(x, y)?.is_eq(), + _ if x.as_number().is_some() && y.as_number().is_some() => compare(x, y)?.is_eq(), + _ => eq(x, y), + }; + if same != want_equal { + return Ok(Value::Nil); + } + } + Ok(Value::t()) +} fn num_eq(a: &[Value]) -> R { - chain(a, |o| o.is_eq()) + eq_chain(a, true) } fn num_ne(a: &[Value]) -> R { - chain(a, |o| o.is_ne()) + eq_chain(a, false) } fn lt(a: &[Value]) -> R { chain(a, |o| o.is_lt()) diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index c02b48b17..e8eb135b9 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -70,6 +70,39 @@ fn arithmetic_follows_autolisp() { assert_eq!(eval(&mut s, "(+ 1 nil)"), Err("bad argument type: numberp: nil".into())); } +/// `=` answers for the symbols `type` returns, which is how AutoLISP routines test a type: +/// `(= (type x) 'INT)`. Coercing the symbol to a number failed the whole routine with "bad +/// argument type" instead, so a routine written for AutoLISP stopped at its first type test +/// (#1597). The ordering comparisons are still numbers and strings, as AutoLISP has them. +#[test] +fn equality_compares_symbols_as_autolisp_does() { + let mut s = session(); + assert_eq!(ok(&mut s, "(= (type 1) 'INT)"), "T"); + assert_eq!(ok(&mut s, "(= (type 1.0) 'INT)"), "nil"); + assert_eq!(ok(&mut s, "(= (type '(1 2)) 'LIST)"), "T"); + assert_eq!(ok(&mut s, "(= (type \"a\") 'STR)"), "T"); + assert_eq!(ok(&mut s, "(/= (type 1) 'REAL)"), "T"); + assert_eq!(ok(&mut s, "(= 'a 'a 'a)"), "T"); + assert_eq!(ok(&mut s, "(= 'a 'b)"), "nil"); + + // The routine from the report runs. + ok(&mut s, "(defun isint (a) (if (= (type a) 'INT) T nil))"); + assert_eq!(ok(&mut s, "(isint 3)"), "T"); + assert_eq!(ok(&mut s, "(isint 3.0)"), "nil"); + assert_eq!(ok(&mut s, "(isint \"3\")"), "nil"); + + // A mismatch of kinds is nil, not an error; numbers and strings are unchanged. + assert_eq!(ok(&mut s, "(= 1 \"1\")"), "nil"); + assert_eq!(ok(&mut s, "(= '(1) '(1))"), "nil", "`=` is not `equal`"); + assert_eq!(ok(&mut s, "(= nil nil)"), "T"); + assert_eq!(ok(&mut s, "(= 1 1.0)"), "T"); + assert_eq!(ok(&mut s, "(= \"a\" \"a\" \"a\")"), "T"); + assert_eq!(ok(&mut s, "(= 1 1 2)"), "nil"); + + // Ordering still refuses what AutoLISP refuses. + assert_eq!(eval(&mut s, "(< 'a 'b)"), Err("bad argument type: numberp: A".into())); +} + #[test] fn lists_and_strings() { let mut s = session(); From b72680cfb75b4db901a6ed1db7cb03658a05012b Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 19 Oct 2026 19:13:23 +0500 Subject: [PATCH 05/57] AutoLISP: an entsel prompt shows the pick box whatever its message says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether the cursor shows the pick box came from `Prompt::picks_objects()`, which read the prompt's wording: a selection-set prompt, or a point prompt whose message starts with "Select". An `entsel`/`nentsel` prompt takes a point and is not a selection set, so it fell to the wording -- and an AutoLISP routine writes its own message, conventionally starting with a newline: (nentsel) "Select object: " pick box (nentsel "\nSelect second line or polyline: ") starts with "\n" none (entsel "\nPick a line: ") says no such word none A prompt now says that it picks one object rather than being read for it, and `GetRequest::prompt()` sets that for `GetKind::EntSel`, so all three show the pick box. The wording test is read past leading whitespace as well, so a command machine's prompt beginning with a newline is not caught by the same thing. Point modifiers (`.x`, `.y`…) are offered at a point prompt that is not a pick, so they no longer appear at an `entsel` prompt with a message -- as they never did at one without. Fix: #1598 --- crates/engine/src/lisp/input.rs | 6 ++++++ crates/engine/src/lisp/tests.rs | 27 +++++++++++++++++++++++++++ crates/engine/src/prompt.rs | 14 ++++++++++---- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lisp/input.rs b/crates/engine/src/lisp/input.rs index fca32e5b8..d12dcbbce 100644 --- a/crates/engine/src/lisp/input.rs +++ b/crates/engine/src/lisp/input.rs @@ -67,6 +67,12 @@ impl GetRequest { let mut p = Prompt::new(message, accept).base_opt(base); p.keywords = self.initget.keywords.clone(); p.raw = true; + // `entsel`/`nentsel` pick one object. The routine writes the message, so the prompt says + // so itself rather than being read for the word "Select" -- with a message of its own + // ("\nSelect the second line: ", or no mention of selecting at all) the pick box went + // missing, while the same call without one got it from the default "Select object: " + // (#1598). + p.picks_one = matches!(self.kind, GetKind::EntSel); p } diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index c02b48b17..86cc0b0cd 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -452,6 +452,33 @@ fn hostile_lisp_never_panics() { assert!(!s.lisp_running()); } +/// `entsel` and `nentsel` pick one object, so the cursor shows the pick box at their prompt. It +/// was read off the message's first word, and a routine's own message begins with a newline -- so +/// the same call showed the pick box without a message and lost it with one (#1598). +#[test] +fn an_entsel_prompt_picks_objects_whatever_its_message_says() { + let mut s = session(); + // No message: the default "Select object: ". + s.cmdline("(nentsel)").unwrap(); + assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text()); + s.cancel(); + + // The reported case: the routine's own message, starting with a newline. + s.cmdline("(nentsel \"\nSelect second line or polyline: \")").unwrap(); + assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text()); + s.cancel(); + + // Wording that never says "select" picks an object just the same. + s.cmdline("(entsel \"\nPick a line: \")").unwrap(); + assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text()); + s.cancel(); + + // A point prompt is not a pick, and keeps its point modifiers. + s.cmdline("(getpoint \"\nSpecify a point: \")").unwrap(); + assert!(!s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text()); + s.cancel(); +} + #[test] fn a_waiting_routine_is_a_prompt_with_a_rubber_band() { let mut s = session(); diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index a41bcb705..5108af6d3 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -62,6 +62,10 @@ pub struct Prompt { /// pointer boxes. #[serde(skip_serializing_if = "std::ops::Not::not")] pub distance: bool, + /// The prompt picks one object whatever it says: an AutoLISP `entsel`/`nentsel`, whose message + /// the routine writes, so it cannot be read for the word "Select" (#1598). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub picks_one: bool, } /// Parse `@distance bool { - self.accept.select || (self.accept.point && self.message.starts_with("Select")) + self.picks_one || self.accept.select || (self.accept.point && self.message.trim_start().starts_with("Select")) } /// "Specify next point or [Undo/Close] :". pub fn display(&self) -> String { From 27861ad0edf7b000a0195270db43df6accb5649a Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 20 Oct 2026 00:31:05 +0500 Subject: [PATCH 06/57] Read a prompt's wording only where the engine wrote it The pick-box heuristic read the message for a leading "Select", and this branch had widened it past leading whitespace so that a routine's own message would be read the same way. That makes a plain point prompt look like a pick: (getpoint "\nSelect a point: ") showed the pick box and lost its point modifiers, since pointmod refuses them wherever picks_objects holds. A routine writes all of its own message (raw), and says through picks_one when it picks an object, so the wording needs reading only where the engine wrote it. Covered for both spellings a routine might use, with and without the leading newline. --- crates/engine/src/lisp/tests.rs | 12 ++++++++---- crates/engine/src/prompt.rs | 7 ++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index 86cc0b0cd..6a1051c19 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -473,10 +473,14 @@ fn an_entsel_prompt_picks_objects_whatever_its_message_says() { assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text()); s.cancel(); - // A point prompt is not a pick, and keeps its point modifiers. - s.cmdline("(getpoint \"\nSpecify a point: \")").unwrap(); - assert!(!s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text()); - s.cancel(); + // A point prompt is not a pick, and keeps its point modifiers -- including one whose own + // message happens to begin with the word the built-in commands are read by. `picks_one` says + // which calls pick, so the wording is only ever read where the engine wrote it (#1635 review). + for point in ["(getpoint \"\nSpecify a point: \")", "(getpoint \"\nSelect a point: \")", "(getpoint \"Select a point: \")"] { + s.cmdline(point).unwrap(); + assert!(!s.current_prompt().unwrap().picks_objects(), "{point}: {}", s.prompt_text()); + s.cancel(); + } } #[test] diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index 5108af6d3..b9c45eae7 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -135,10 +135,11 @@ impl Prompt { /// The prompt picks objects: a "Select objects" prompt, one that says it picks a single object /// ([`Prompt::picks_one`]), or a point prompt whose click selects one ("Select object to /// offset", "Select first line"…, the commands that pick a single object themselves). The UI - /// shows the pick box there. A routine's own prompt usually begins with a newline, so the - /// wording is read past the leading whitespace. + /// shows the pick box there. The wording is only read where the engine wrote it: a routine + /// writes its own message (`raw`), which may open with "Select" and still ask for a point, + /// and says through `picks_one` when it picks instead (#1635 review). pub fn picks_objects(&self) -> bool { - self.picks_one || self.accept.select || (self.accept.point && self.message.trim_start().starts_with("Select")) + self.picks_one || self.accept.select || (self.accept.point && !self.raw && self.message.starts_with("Select")) } /// "Specify next point or [Undo/Close] :". pub fn display(&self) -> String { From 81f0aa53ffe5b67fb9aa5cc921f3f3577e2a5d77 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:27:04 +0200 Subject: [PATCH 07/57] Keep a layout's plot window corners when saving DXF (#1667) --- crates/doc/src/tables.rs | 3 +++ crates/io/src/dxf_read.rs | 1 + crates/io/src/dxf_write.rs | 8 ++++---- crates/io/tests/layout_plot_window.rs | 25 +++++++++++++++++++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 crates/io/tests/layout_plot_window.rs diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index 498296237..45e7a469b 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -752,6 +752,8 @@ pub struct PageSetup { pub landscape: bool, /// "layout" | "extents" | "display" | "window" | "limits". pub plot_area: String, + /// Plot window corners `[x1, y1, x2, y2]` (DXF plot layout groups 48, 49, 140, 141); used when `plot_area` is "window". + pub plot_window: [f64; 4], pub scale_to_fit: bool, /// Paper units per drawing unit. pub scale: f64, @@ -779,6 +781,7 @@ impl Default for PageSetup { margins_mm: [6.35, 6.35, 6.35, 6.35], landscape: true, plot_area: "layout".into(), + plot_window: [0.0; 4], scale_to_fit: false, scale: 1.0, plot_style_table: String::new(), diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 92d803e17..2bd7e8d8d 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -2248,6 +2248,7 @@ fn page_setup(t: &T) -> PageSetup { _ => "layout", } .into(); + p.plot_window = [t.fd(48, 0.0), t.fd(49, 0.0), t.fd(140, 0.0), t.fd(141, 0.0)].map(|v| if v.is_finite() { v } else { 0.0 }); p.plot_style_table = t.s(7).unwrap_or_default(); p } diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 521654e3d..7991d013b 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -2565,10 +2565,10 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { (45, page.height_mm), (46, 0.0), (47, 0.0), - (48, 0.0), - (49, 0.0), - (140, 0.0), - (141, 0.0), + (48, page.plot_window[0]), + (49, page.plot_window[1]), + (140, page.plot_window[2]), + (141, page.plot_window[3]), (142, scale), (143, 1.0), ] { diff --git a/crates/io/tests/layout_plot_window.rs b/crates/io/tests/layout_plot_window.rs new file mode 100644 index 000000000..448fa83e5 --- /dev/null +++ b/crates/io/tests/layout_plot_window.rs @@ -0,0 +1,25 @@ +//! #1667: a layout whose page setup plots a window (DXF plot type 4) lost the window corners +//! (groups 48, 49, 140, 141) on save: they were written as zero. + +use cadcraft_doc::{Drawing, Layout, PageSetup}; +use cadcraft_io::{read, write_dxf}; + +#[test] +fn plot_window_corners_round_trip_through_dxf() { + let mut d = Drawing::new_metric(); + let mut l = Layout::new("WIN", 3); + l.page = PageSetup { + width_mm: 297.0, + height_mm: 420.0, + margins_mm: [0.0; 4], + plot_area: "window".into(), + plot_window: [0.0, -297.0, 420.0, 0.0], + ..PageSetup::default() + }; + d.layouts.push(l); + + let back = read(write_dxf(&d).as_bytes(), "win.dxf").unwrap(); + let page = &back.layouts.iter().find(|l| l.name == "WIN").unwrap().page; + assert_eq!(page.plot_area, "window"); + assert_eq!(page.plot_window, [0.0, -297.0, 420.0, 0.0]); +} From d600a8035434c77800dc89eed0e899e607f91f26 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:27:34 +0200 Subject: [PATCH 08/57] Multileader sizes no longer multiplied by DIMSCALE (#1648) --- crates/engine/src/cmd/annotate.rs | 12 ++++++------ crates/engine/tests/mleader_content.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 7fa795377..b1c732c2c 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -427,13 +427,13 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option< let d = s.doc().ok()?; let cur = d.header.str("CMLEADERSTYLE", "Standard"); let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default(); - // An annotative style's sizes are paper sizes: the annotation scale applies when drawn. - let k = if st.annotative { 1.0 } else { d.header.f64("DIMSCALE", 1.0) }; - let th = st.text_height * k; + // The style's sizes are the drawn sizes: a multileader style has no scale of its own and + // DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD. + let th = st.text_height; let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 }; let attach = if dir > 0.0 { 4 } else { 6 }; - let dogleg = if landing { st.dogleg * k } else { 0.0 }; - let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap * k) * dir, 0.0); + let dogleg = if landing { st.dogleg } else { 0.0 }; + let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0); Some(EntityKind::MLeader(MLeader { leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()], landing: v3(*landing_pt), @@ -451,7 +451,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option< columns: None, }), style: st.name.clone(), - arrow_size: st.arrow_size * k, + arrow_size: st.arrow_size, spline: false, block: None, })) diff --git a/crates/engine/tests/mleader_content.rs b/crates/engine/tests/mleader_content.rs index 6187504e2..c8b9a6995 100644 --- a/crates/engine/tests/mleader_content.rs +++ b/crates/engine/tests/mleader_content.rs @@ -121,3 +121,20 @@ fn json_form_takes_leader_type_and_block() { assert!(s.execute("mleader", &bad).is_err(), "{bad}"); } } + +/// DIMSCALE belongs to dimension styles: a multileader keeps its style's text height, arrowhead +/// and dogleg whatever DIMSCALE is (#1648). +#[test] +fn mleader_sizes_ignore_dimscale() { + let mut s = Session::new(); + s.execute("mleaderstyle", &json!({"name": "M5", "textHeight": 5, "arrowSize": 5, "current": true})).unwrap(); + s.execute("setvar", &json!({"name": "DIMSCALE", "value": 20})).unwrap(); + s.execute("mleader", &json!({"points": [[0, 0], [30, 20]], "text": "CADCRAFT"})).unwrap(); + let m = last_mleader(&s); + assert_eq!(m.arrow_size, 5.0); + let st = s.doc().unwrap().mleader_styles.iter().find(|x| x.name == "M5").unwrap().clone(); + assert_eq!(m.dogleg, st.dogleg); + let t = m.text.unwrap(); + assert_eq!(t.height, 5.0); + assert!((t.insert.x - (30.0 + st.dogleg + st.landing_gap)).abs() < 1e-9, "{}", t.insert.x); +} From 27bff9cc30c7988e0e438e49c0c9addfe810da7c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:27:13 +0200 Subject: [PATCH 09/57] Write the ACAD_TABLE *T block relative to its insertion point (#1644) --- crates/io/src/dxf_write.rs | 7 +++++-- crates/io/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 521654e3d..053b15f68 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1441,8 +1441,11 @@ fn table_size(t: &Table) -> (usize, usize) { /// The anonymous `*T` block of a table: cell fills, borders and texts as its cell styles draw /// them, for readers that only draw the block. -fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec { - let g = cadcraft_render::table_geometry(d, t); +/// +/// The block is relative to its own origin: the ACAD_TABLE reference adds the insertion point. +pub(crate) fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec { + let at_origin = Table { insert: Vec3::ZERO, ..t.clone() }; + let g = cadcraft_render::table_geometry(d, &at_origin); let c = Common { layer: layer.into(), color: Color::ByBlock, ..Common::default() }; let mut out = Vec::new(); for f in &g.fills { diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 8b7f56474..2c741b803 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1343,6 +1343,23 @@ fn tables_roundtrip_with_title_header_and_merges() { assert_eq!(again.blocks.len(), back.blocks.len()); } +#[test] +fn table_block_is_relative_to_the_insertion_point() { + let d = Drawing::new_imperial(); + let t = table_sample(); + let ents = crate::dxf_write::table_block_entities(&d, &t, "0"); + let mut xs = Vec::new(); + for e in &ents { + if let EntityKind::Line(l) = &e.kind { + xs.extend([l.a.x, l.b.x]); + } + } + assert!(!xs.is_empty()); + let (min, max) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), &x| (lo.min(x), hi.max(x))); + // Columns total 8 wide; the block starts at its own origin, not at the insert x of 5. + assert!(min.abs() < 1e-9 && (max - 8.0).abs() < 1e-9, "block x range {min}..{max}"); +} + #[test] fn text_style_flags_roundtrip() { let mut d = Drawing::new_imperial(); From a7bb28d72298a023f0264429aad00357dbfda6ff Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:29:49 +0200 Subject: [PATCH 10/57] DWG: write the current text style's own handle, so AutoCAD opens with it current (#1650) --- crates/dwg/src/lib.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index bae5c6c06..7cbd40fc7 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -494,6 +494,16 @@ mod native { } } + /// The current text style (`$TEXTSTYLE`) by its name, the same way as [`current_dimstyle`]: + /// the handle read with the DXF needn't be the style's in the document, in which case the DWG + /// writer fell back to Standard and AutoCAD opened the drawing with Standard current (#1650). + fn current_text_style(doc: &mut acadrust::CadDocument) { + let name = doc.header.current_text_style_name.clone(); + if let Some(h) = doc.text_styles.iter().find(|ts| ts.name.eq_ignore_ascii_case(&name)).map(|ts| ts.handle) { + doc.header.current_text_style_handle = h; + } + } + fn convert(dxf: &[u8], version: Option) -> Result, String> { let data = dxf.to_vec(); let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) @@ -512,6 +522,7 @@ mod native { dimension_flags(&mut doc, true); drop_unwritable(&mut doc); current_dimstyle(&mut doc); + current_text_style(&mut doc); std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc))) .map_err(|_| "the DWG writer failed on this drawing".to_string())? .map_err(|e| format!("DWG write: {e}")) @@ -585,6 +596,22 @@ mod tests { assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018")); } + /// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not + /// Standard: the DXF names the style, and the handle read with it was Standard's (#1650). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn current_text_style_reaches_the_dwg() { + let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n"; + let style = " 0\nSTYLE\n 5\n1F0\n100\nAcDbSymbolTableRecord\n100\nAcDbTextStyleTableRecord\n 2\nROMANS\n 70\n0\n"; + let style = format!("{style} 40\n0\n 41\n1\n 50\n0\n 71\n0\n 42\n2.5\n 3\nromans.shx\n 4\n\n"); + let dxf = format!("{header} 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nSTYLE\n 70\n1\n{style} 0\nENDTAB\n 0\nENDSEC\n 0\nEOF\n"); + let dwg = dxf_to_dwg(dxf.as_bytes()).unwrap(); + let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap(); + let romans = doc.text_styles.iter().find(|ts| ts.name == "ROMANS").map(|ts| ts.handle); + assert!(romans.is_some(), "ROMANS is missing from the DWG"); + assert_eq!(Some(doc.header.current_text_style_handle), romans); + } + #[test] fn garbage_is_an_error_not_a_crash() { assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err()); From e86eacf094fefda1cdb6ba5137a336f29deb5d99 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 22:18:26 -0400 Subject: [PATCH 11/57] Release 0.10.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3eb9cacc3..dd6d3649c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -547,7 +547,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadcraft" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -569,7 +569,7 @@ dependencies = [ [[package]] name = "cadcraft-cli" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -580,14 +580,14 @@ dependencies = [ [[package]] name = "cadcraft-color" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-constraints" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cadcraft-doc", "cadcraft-geom", @@ -597,7 +597,7 @@ dependencies = [ [[package]] name = "cadcraft-doc" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cadcraft-color", "cadcraft-geom", @@ -608,14 +608,14 @@ dependencies = [ [[package]] name = "cadcraft-dwg" -version = "0.9.0" +version = "0.10.0" dependencies = [ "acadrust", ] [[package]] name = "cadcraft-dxf" -version = "0.9.0" +version = "0.10.0" dependencies = [ "encoding_rs", "log", @@ -624,7 +624,7 @@ dependencies = [ [[package]] name = "cadcraft-engine" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cadcraft-color", "cadcraft-constraints", @@ -644,7 +644,7 @@ dependencies = [ [[package]] name = "cadcraft-fonts" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cadcraft-geom", "harfrust", @@ -654,14 +654,14 @@ dependencies = [ [[package]] name = "cadcraft-geom" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-io" -version = "0.9.0" +version = "0.10.0" dependencies = [ "acadrust", "cadcraft-color", @@ -681,7 +681,7 @@ dependencies = [ [[package]] name = "cadcraft-mcp" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -692,7 +692,7 @@ dependencies = [ [[package]] name = "cadcraft-render" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -706,7 +706,7 @@ dependencies = [ [[package]] name = "cadcraft-ui-egui" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -728,7 +728,7 @@ dependencies = [ [[package]] name = "cadcraft-web" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -4811,7 +4811,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index ccf8b9d70..16db80cf9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] # default-members [workspace.package] -version = "0.9.0" +version = "0.10.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 4d826ed91fc2c23fa7da53375c30a550d09436b9 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:49:58 +0300 Subject: [PATCH 12/57] Control server: wait after a failed accept instead of retrying at once The accepting thread dropped failed accepts with incoming().flatten() and asked again at once. When accepting keeps failing, for example while the process has no file descriptors left, that loop keeps a core busy until it recovers. Wait 100 ms after a failed accept. The loop goes through a small helper so the wait can be tested without running out of file descriptors. --- apps/cadcraft/src/control_server.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/cadcraft/src/control_server.rs b/apps/cadcraft/src/control_server.rs index 4433d76e5..e678762cc 100644 --- a/apps/cadcraft/src/control_server.rs +++ b/apps/cadcraft/src/control_server.rs @@ -61,6 +61,16 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { } } +/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for +/// example while the process has no file descriptors left, and retrying at once would keep a +/// core busy until it recovers. +const ACCEPT_RETRY: Duration = Duration::from_millis(100); + +/// The connections `incoming` accepts, calling `wait` after each failed accept. +fn accepted(incoming: impl Iterator>, mut wait: impl FnMut()) -> impl Iterator { + incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok()) +} + /// Accept connections on `listener` under `limits`; requests arrive on the returned receiver. fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver { let (tx, rx) = sync_channel::(limits.queue.max(1)); @@ -80,7 +90,7 @@ impl Drop for Slot { fn accept_loop(listener: TcpListener, tx: SyncSender, ctx: egui::Context, limits: Limits) { let open = Arc::new(AtomicUsize::new(0)); - for mut stream in listener.incoming().flatten() { + for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) { let _ = stream.set_write_timeout(Some(limits.write_timeout)); if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients { open.fetch_sub(1, Ordering::SeqCst); @@ -222,6 +232,18 @@ fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context mod tests { use super::*; + /// A failed accept is followed by a wait before the next one, so a listener that keeps + /// failing does not spin, and the connections around the failures still come through. + #[test] + fn a_failed_accept_waits_before_the_next() { + let failed = || Err(std::io::Error::other("no file descriptors left")); + let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)]; + let mut waits = 0; + let connections: Vec = super::accepted(incoming.into_iter(), || waits += 1).collect(); + assert_eq!(connections, [1, 2]); + assert_eq!(waits, 3, "one wait per failed accept"); + } + #[test] fn lines_are_read_up_to_the_limit() { let mut buf = Vec::new(); From f2b473dc51c7bd4d7577a0a07819285b99077842 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 20 Oct 2026 11:32:50 -0400 Subject: [PATCH 13/57] docs: add SonarCloud and CodeRabbit badges to README (#1566) * docs: add SonarCloud and CodeRabbit badges to README * docs: put SonarCloud and CodeRabbit badges on their own line --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 4bf49072c..4adb943f5 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ License: MIT OR Apache-2.0 Agent-drivable over MCP Status: early development +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

From 7fef8cbe2fb8dbafdcf6ebda8aa3ba0623bcbec1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 20 Oct 2026 11:39:39 -0400 Subject: [PATCH 14/57] Skip CodeRabbit reviews on Dependabot PRs (#1727) Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot]. Co-authored-by: Claude Opus 5.5 --- .coderabbit.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..2408084cf --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" From 82424e47c9993d954e5bb9af7da5e8995c5930d7 Mon Sep 17 00:00:00 2001 From: Philipp Thomann Date: Wed, 21 Oct 2026 07:23:16 +0200 Subject: [PATCH 15/57] DXF: keep foreign objects whose handles are above 2^48 AutoCAD writes handles above 2^48 (whole drawings whose handles start at 0xDAD1...). dxf_raw dropped every foreign object with such a handle, so these drawings lost their dynamic blocks and all other unmodelled objects on read, DXF to DXF as well. DXF handles are up to 16 hex digits; the whole 64-bit range is now believed (null still is not). Modelled entities and table records already accepted such handles. Test: the DOOR fixture with every handle moved above 2^48 keeps its dynamic block through a save (red before the change). Co-Authored-By: Claude Opus 5.5 --- crates/io/src/dxf_raw.rs | 7 +++---- crates/io/tests/dynblock_read.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs index 05e88403b..129183ef6 100644 --- a/crates/io/src/dxf_raw.rs +++ b/crates/io/src/dxf_raw.rs @@ -51,8 +51,6 @@ const MAX_OBJECTS: usize = 1_000_000; const MAX_XDATA_GROUPS: usize = 16_384; /// Longest ownership chain followed. const MAX_DEPTH: usize = 64; -/// Handles above this are not believed (hostile files); references to them are dropped. -const MAX_HANDLE: u64 = 1 << 48; /// Record types that structure a file: never written as an entity of another type. const STRUCTURAL: &[&str] = &["", "SECTION", "ENDSEC", "EOF", "TABLE", "ENDTAB", "BLOCK", "ENDBLK", "SEQEND", "VERTEX", "ATTRIB", "CLASS"]; @@ -85,9 +83,10 @@ fn tag(r: &RawTag) -> Tag { Tag::new(r.code, value.unwrap_or_else(|| Value::Str(r.value.clone()))) } -/// A handle value we believe: hexadecimal, not null, not absurdly large. +/// A handle value we believe: hexadecimal and not null. DXF handles are up to 16 hex digits and +/// AutoCAD writes some above 2^48 (e.g. 0xDAD1…), so the whole 64-bit range is believed. fn handle(s: &str) -> Option { - Handle::parse_hex(s).filter(|h| h.0 != 0 && h.0 <= MAX_HANDLE) + Handle::parse_hex(s).filter(|h| h.0 != 0) } /// Group codes whose values are handles of other objects (DXF Reference, group code ranges). diff --git a/crates/io/tests/dynblock_read.rs b/crates/io/tests/dynblock_read.rs index 40914fe16..3d7569d3d 100644 --- a/crates/io/tests/dynblock_read.rs +++ b/crates/io/tests/dynblock_read.rs @@ -536,3 +536,29 @@ fn dragging_a_distance_grip_changes_the_distance() { assert!(drag_grip(&mut d, h("30"), 2, GripPart::End, Vec2::new(130.0, 0.0)).is_err(), "not a reference"); assert_eq!(d, before); } + +/// The DOOR drawing with every handle moved above 2^48: AutoCAD writes such handles (seen in +/// drawings whose handles start at 0xDAD1…), and the dynamic block's objects must still be kept. +fn far_handles(src: &str) -> String { + const SHIFT: u64 = 0x000D_AD10_0000_0000; + let is_handle = |code: i32| matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480 | 481 | 1005); + let mut out = String::new(); + for line in src.lines() { + let (code, value) = line.trim().split_once(' ').unwrap_or((line.trim(), "")); + let moved = match (code.parse::(), u64::from_str_radix(value, 16)) { + (Ok(c), Ok(h)) if is_handle(c) && h != 0 => format!("{:X}", h + SHIFT), + _ => value.to_string(), + }; + out.push_str(&format!("{code} {moved}\n")); + } + out +} + +#[test] +fn a_dynamic_block_with_handles_above_2_pow_48_is_kept() { + let src = far_handles(&fixture()); + let d = read_dxf(dxf(&src).as_bytes()).unwrap(); + let before = dynamic_block(&d, "Door").expect("the dynamic block with far handles"); + let again = read_dxf(write_dxf(&d).as_bytes()).unwrap(); + assert_eq!(by_node(dynamic_block(&again, "Door").unwrap()), by_node(before)); +} From 345eb60ef3f318496c6f717da25051496a4ab2aa Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 13:57:10 -0400 Subject: [PATCH 16/57] test --- sonar-project.properties | 1 + 1 file changed, 1 insertion(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..3159dc1d9 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1 @@ +sonar.projectKey=storytold_cadcraft From 896a390b4272e89bd5de416f3afaf44292670fd4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 13:57:30 -0400 Subject: [PATCH 17/57] ci: add SonarCloud scan workflow and config --- sonar-project.properties | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sonar-project.properties b/sonar-project.properties index 3159dc1d9..9d0fd304a 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1 +1,4 @@ sonar.projectKey=storytold_cadcraft +sonar.organization=storytold +sonar.sources=. +sonar.exclusions=**/target/**,**/vendor/**,**/node_modules/**,**/*.min.js,**/dist/**,**/assets/**,**/docs/** From 47a8fb02e75b6c5c34f1615a746a5946f6ef4475 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 15:46:46 -0400 Subject: [PATCH 18/57] ci: add SonarCloud scan via shared reusable workflow Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main with secrets: inherit. Adds SonarCloud badge to README where missing and removes the redundant sonar-project.properties (config now passed by the reusable workflow). --- .github/workflows/sonarcloud.yml | 20 ++++++++++++++++++++ sonar-project.properties | 4 ---- 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/sonarcloud.yml delete mode 100644 sonar-project.properties diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..ecee45a6d --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index 9d0fd304a..000000000 --- a/sonar-project.properties +++ /dev/null @@ -1,4 +0,0 @@ -sonar.projectKey=storytold_cadcraft -sonar.organization=storytold -sonar.sources=. -sonar.exclusions=**/target/**,**/vendor/**,**/node_modules/**,**/*.min.js,**/dist/**,**/assets/**,**/docs/** From 54d96834d582de8889044325a941b8443ad36bab Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 16:00:22 -0400 Subject: [PATCH 19/57] ci: add sonar-project.properties for CI-based analysis Marks the project ineligible for SonarCloud Automatic Analysis, resolving the CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds. --- sonar-project.properties | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..0e9e4fa3b --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_cadcraft +sonar.organization=storytold From fdcd9610f20e814c70bf499a4a7d64ca90fa80a6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 17:06:31 -0400 Subject: [PATCH 20/57] security: enforce HTTPS on curl downloads (SonarCloud S6506) Add --proto '=https' to curl invocations so redirects cannot downgrade to plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions). --- packaging/linux/package.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index c11fd3b3f..d6fa10b9d 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -113,7 +113,7 @@ if has appimage; then if [ -z "$TOOL" ]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi fi From 38750c6eb8c741e2328040bd27dc85a686193f67 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 22 Oct 2026 17:57:20 +0700 Subject: [PATCH 21/57] README: the app icon above the title --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 4adb943f5..80b7d5ea5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@

+

+ CADCraft icon: a drafting set square and compass arc on a blue grid tile +

+

CADCraft

From f4e6873130fc32c7dba2eafd64cdcede7e5b816e Mon Sep 17 00:00:00 2001 From: Antoine Boxho Date: Thu, 22 Oct 2026 19:05:42 +0100 Subject: [PATCH 22/57] Merge pull request #1568 from C00kieMonsta/readme-official-source README: verify official releases and download checksums (fixes #1554) --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 80b7d5ea5..655b944f5 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,14 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of **Download CADCraft** from GitHub: the [latest release](https://github.com/storytold/cadcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/cadcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. +> [!WARNING] +> Download official CADCraft builds from the [storytold/cadcraft releases](https://github.com/storytold/cadcraft/releases). Verify that the repository owner is `storytold`. Before you run a download, check that its checksum matches the line for that file in the release's `SHA256SUMS.txt`: +> +> ```sh +> shasum -a 256 cadcraft--macos-universal.dmg # macOS (on Linux: sha256sum FILE) +> Get-FileHash .\cadcraft--windows-x64.msi # Windows PowerShell +> ``` + ### Windows | Build | Installer | Portable | From acd109d97c62d95e48462dfe53d14f9a46920b50 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 14:09:16 -0400 Subject: [PATCH 23/57] Use chrono instead of hand-rolled UTC conversion (#1789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the three civil_from_days copies (logging timestamp, Drawing Recovery time, parity checklist date) with chrono as a workspace dependency. Output formats are unchanged and pinned by the existing tests. AGENTS.md now says time goes through chrono. Fixes #1785 * Never panic on a clock outside chrono's range; keep 5-digit years unprefixed chrono's From unwraps, so a clock past year 262143 panicked inside the logger. timestamp and xtask's date now use the fallible DateTime::from_timestamp and read such a clock as the epoch, as the old saturating code did. xtask's date also clamps a pre-epoch clock to 1970-01-01 again. Years past 9999 are written with {:04} like before, because %Y would add a '+'. Tests cover all three cases. * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * logging/autosave/parity: keep extended-year dates, no epoch fallback Applies the stricter no-epoch-fallback rule (from wordcraft#3427 / filmcraft#2662, superseding the earlier epoch-or-last-valid suggestion) so all sibling PRs agree: a timestamp the old code converted past chrono's year 262143 range must keep its real proleptic-Gregorian date, not collapse to 1970-01-01. Each site uses the 400-year Gregorian cycle (146_097 days) with chrono as the only calendar logic: chrono converts the day within one cycle, whole cycles carry in the year (i128, saturated into i64). This also removes the panic FredApps flagged — `DateTime::::from(SystemTime)` could panic past chrono's range; `from_num_days_from_ce_opt` within one cycle never does. Verified the cycle result equals the old formula across a wide range and at the extremes. - apps/cadcraft/src/logging.rs `timestamp`: cycle `civil_from_days(u64)`; pre-1970 epoch clamp and ms/time-of-day format kept. Pins: +10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z (was 1970-...); checked_add u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch). - crates/ui-egui/src/autosave.rs `utc`: cycle converter. Pins: utc(10_000_000_000_000) = "318857-05-20 17:46"; utc(u64::MAX) = "584554051223-11-09 07:00" (was "1970-01-01 00:00"). - xtask/src/parity.rs `date`: cycle converter; pre-1970 epoch clamp kept. Pins: day(115_740_740) = "318857-05-20"; checked_add u64::MAX/2 s -> "292277026596-12-04" (was epoch). * Don't build a year-318857 SystemTime unchecked in the logging test Windows' SystemTime only reaches about year 30828, so UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before timestamp() ran and failed the Windows test job. Build it with checked_add and assert where the platform can hold it. The year-10000 case fits everywhere and stays unconditional. * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does. --- AGENTS.md | 1 + Cargo.lock | 12 +++++++ Cargo.toml | 2 ++ apps/cadcraft/Cargo.toml | 1 + apps/cadcraft/src/logging.rs | 52 ++++++++++++++++++++---------- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/autosave.rs | 41 +++++++++++++++++------- xtask/Cargo.toml | 1 + xtask/src/parity.rs | 58 +++++++++++++++++++++++----------- 9 files changed, 123 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a8d4badc0..cd6de23e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence. - **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`. diff --git a/Cargo.lock b/Cargo.lock index dd6d3649c..0c35238a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -552,6 +552,7 @@ dependencies = [ "cadcraft-engine", "cadcraft-io", "cadcraft-ui-egui", + "chrono", "eframe", "egui", "hidapi", @@ -714,6 +715,7 @@ dependencies = [ "cadcraft-fonts", "cadcraft-geom", "cadcraft-render", + "chrono", "craft-ui", "egui", "egui-wgpu", @@ -825,6 +827,15 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -4813,6 +4824,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" name = "xtask" version = "0.10.0" dependencies = [ + "chrono", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 16db80cf9..e037ea70c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" # rendering / formats diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index ec527437d..886eac1ec 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -18,6 +18,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } [build-dependencies] winresource = "0.1" diff --git a/apps/cadcraft/src/logging.rs b/apps/cadcraft/src/logging.rs index 6678dec33..4113de54f 100644 --- a/apps/cadcraft/src/logging.rs +++ b/apps/cadcraft/src/logging.rs @@ -93,7 +93,10 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the +/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic +/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the +/// output never changes for a value the old formatter accepted and logging never panics. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let secs = since.as_secs(); @@ -102,20 +105,25 @@ pub fn timestamp(t: SystemTime) -> String { format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd -/// clock can't overflow). -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run +/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years +/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the +/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and +/// reproducing the old saturating `civil_from_days` result exactly without hand-rolled day math. +/// 1970-01-01 is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -385,8 +393,20 @@ mod tests { // 2000 is a leap year (divisible by 400), 2100 is not. assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); - // Before the epoch (a clock set wrong) clamps instead of panicking. + // Before the epoch (a clock set wrong) clamps to the epoch instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date + // the old formatter gave, rather than falling back to the epoch. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 630188d73..18721a963 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -23,6 +23,7 @@ egui-wgpu = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } image = { workspace = true } [dev-dependencies] diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index c7ea384d7..a0db8d0d7 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -111,23 +111,36 @@ impl CadApp { } } -/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC). +/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC). Past chrono's year 262143 range the proleptic +/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch. fn utc(secs: u64) -> String { - let days = (secs / 86_400) as i64; let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60); - // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let mo = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(mo <= 2); + let (y, mo, d) = civil_from_days(secs / 86_400); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}") } +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// A recovery file's mtime can in principle land past `NaiveDate`'s ±262143-year range; the old +/// hand-rolled converter still produced a date there, so falling back to the epoch would change +/// the shown time. The Gregorian calendar repeats every 400 years (146_097 days), so chrono +/// converts the day within one cycle and whole cycles go back into the year (an `i128`, then +/// saturated into `i64`), keeping chrono as the only calendar logic and reproducing the old +/// result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } +} + /// The Drawing Recovery window, while autosave files from a crash are waiting. pub fn show(app: &mut CadApp, ctx: &egui::Context) { if app.recovery.items.is_empty() { @@ -233,5 +246,9 @@ mod tests { assert_eq!(super::utc(0), "1970-01-01 00:00"); assert_eq!(super::utc(951_782_400 + 3_660), "2000-02-29 01:01"); assert_eq!(super::utc(1_791_676_800), "2026-10-11 00:00"); + assert_eq!(super::utc(253_402_300_800), "10000-01-01 00:00"); + // Past chrono's range the proleptic Gregorian date is kept, not the epoch (old output). + assert_eq!(super::utc(10_000_000_000_000), "318857-05-20 17:46"); + assert_eq!(super::utc(u64::MAX), "584554051223-11-09 07:00"); } } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index df7dfa877..701013cdc 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,3 +7,4 @@ publish = false [dependencies] serde_json = { workspace = true } +chrono = { workspace = true } diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 6adee8bdb..39f8e8713 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -28,25 +28,39 @@ pub fn leaves(catalog: &str) -> Vec { /// Where the generated checklist goes. pub const OUT: &str = "docs/parity-checklist.md"; -/// Today's UTC date as `YYYY-MM-DD` (from the system clock; no date crate needed). +/// Today's UTC date as `YYYY-MM-DD` (from the system clock). fn today() -> String { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let (y, m, d) = civil_from_days((secs / 86_400) as i64); + date(std::time::SystemTime::now()) +} + +/// `t` as a UTC `YYYY-MM-DD`. A clock before 1970 reads as 1970-01-01 (the `unwrap_or(0)` clamp); +/// past chrono's year 262143 range the proleptic Gregorian date is kept via the 400-year cycle +/// rather than falling back to the epoch. +fn date(t: std::time::SystemTime) -> String { + let secs = t.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let (y, m, d) = civil_from_days(secs / 86_400); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. format!("{y:04}-{m:02}-{d:02}") } -/// Days since 1970-01-01 → (year, month, day) in the proleptic Gregorian calendar. -fn civil_from_days(z: i64) -> (i64, u32, u32) { - let z = z + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = yoe + era * 400 + i64::from(m <= 2); - (y, m, d) +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// `date` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run past +/// `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years (146_097 +/// days), so chrono converts the day within one cycle and whole cycles go back into the year (an +/// `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and reproducing +/// the old `civil_from_days` result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } } pub fn run(root: &Path) -> Result<(), String> { @@ -116,9 +130,17 @@ mod tests { #[test] fn civil_dates() { - assert_eq!(civil_from_days(0), (1970, 1, 1)); - assert_eq!(civil_from_days(11_016), (2000, 2, 29)); - assert_eq!(civil_from_days(20_736), (2026, 10, 10)); + let day = |d: u64| date(std::time::UNIX_EPOCH + std::time::Duration::from_secs(d * 86_400)); + assert_eq!(day(0), "1970-01-01"); + assert_eq!(day(11_016), "2000-02-29"); + assert_eq!(day(20_736), "2026-10-10"); + assert_eq!(date(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400)), "1970-01-01"); + // Past chrono's range keeps the proleptic Gregorian date, not the epoch (old `civil_from_days`). + assert_eq!(day(115_740_740), "318857-05-20"); + // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. + if let Some(far) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(u64::MAX / 2)) { + assert_eq!(date(far), "292277026596-12-04"); + } } #[test] From 8f988f630dd63ce5d9c997f5ef28a09dd1bdfcde Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 15:37:25 -0400 Subject: [PATCH 24/57] Release 0.11.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c35238a9..4fc46f3c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -547,7 +547,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadcraft" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -570,7 +570,7 @@ dependencies = [ [[package]] name = "cadcraft-cli" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -581,14 +581,14 @@ dependencies = [ [[package]] name = "cadcraft-color" -version = "0.10.0" +version = "0.11.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-constraints" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cadcraft-doc", "cadcraft-geom", @@ -598,7 +598,7 @@ dependencies = [ [[package]] name = "cadcraft-doc" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cadcraft-color", "cadcraft-geom", @@ -609,14 +609,14 @@ dependencies = [ [[package]] name = "cadcraft-dwg" -version = "0.10.0" +version = "0.11.0" dependencies = [ "acadrust", ] [[package]] name = "cadcraft-dxf" -version = "0.10.0" +version = "0.11.0" dependencies = [ "encoding_rs", "log", @@ -625,7 +625,7 @@ dependencies = [ [[package]] name = "cadcraft-engine" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cadcraft-color", "cadcraft-constraints", @@ -645,7 +645,7 @@ dependencies = [ [[package]] name = "cadcraft-fonts" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cadcraft-geom", "harfrust", @@ -655,14 +655,14 @@ dependencies = [ [[package]] name = "cadcraft-geom" -version = "0.10.0" +version = "0.11.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-io" -version = "0.10.0" +version = "0.11.0" dependencies = [ "acadrust", "cadcraft-color", @@ -682,7 +682,7 @@ dependencies = [ [[package]] name = "cadcraft-mcp" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -693,7 +693,7 @@ dependencies = [ [[package]] name = "cadcraft-render" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -707,7 +707,7 @@ dependencies = [ [[package]] name = "cadcraft-ui-egui" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -730,7 +730,7 @@ dependencies = [ [[package]] name = "cadcraft-web" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -4822,7 +4822,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.10.0" +version = "0.11.0" dependencies = [ "chrono", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index e037ea70c..c87f38ffe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] # default-members [workspace.package] -version = "0.10.0" +version = "0.11.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 345fb27a693f96e3a0279be746df731c322f9bff Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 17:03:46 -0400 Subject: [PATCH 25/57] security: write files atomically through an exclusive temp file (CWE-59) (#1840) * security: write files atomically through an exclusive temp file (CWE-59, CWE-377) Save, export, plot, autosave and the audit log each wrote their output by putting bytes in a predictable sibling temporary path (`.cadcraft-tmp`, `.tmp`, or the companion `.adt`) and renaming it over the real file. Opening that pre-existing temporary path for a truncating write follows a symlink planted there by another local user, letting them redirect the write onto any file the victim can write before the rename. Add a shared `atomic_write` helper that creates a fresh temporary file with `create_new` (O_CREAT|O_EXCL) in the destination directory, writes through the retained handle, flushes/syncs, and atomically renames it into place. An existing entry (symlink or not) makes the exclusive create fail instead of being followed. Route autosave, save, export, plot and the audit log through it. The macOS CI cert importer likewise wrote the decoded PKCS#12 container to a fixed name in a possibly shared temp directory; it now creates a private per-run directory with `mktemp -d` and creates the file with noclobber. Addresses findings rows 1, 25, 40 (CWE-59) and 19 (CWE-377). * fix: preserve permissions during atomic replacement * fix: restrict temporary permissions during exclusive creation --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- crates/engine/src/atomic_write.rs | 163 ++++++++++++++++++++++++++++++ crates/engine/src/autosave.rs | 6 +- crates/engine/src/cmd/audit.rs | 5 +- crates/engine/src/cmd/exchange.rs | 7 +- crates/engine/src/cmd/file.rs | 7 +- crates/engine/src/cmd/layout.rs | 6 +- crates/engine/src/lib.rs | 2 + packaging/macos/import-cert.sh | 9 +- 8 files changed, 188 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/atomic_write.rs diff --git a/crates/engine/src/atomic_write.rs b/crates/engine/src/atomic_write.rs new file mode 100644 index 000000000..ff9e141d3 --- /dev/null +++ b/crates/engine/src/atomic_write.rs @@ -0,0 +1,163 @@ +//! Safe, atomic file replacement. +//! +//! Save, export, plot, autosave and the audit log all write a file by first putting the bytes in +//! a sibling temporary file and then renaming it over the destination, so a failed or partial +//! write never truncates the real file. The temporary file must be created *exclusively*: opening +//! a predictable, pre-existing temporary path for a truncating write lets another local user plant +//! a symlink there and redirect the write onto a file the victim can write but the attacker chose +//! (CWE-59). [`write_atomic`] creates a fresh temporary file with `create_new` (`O_CREAT|O_EXCL`), +//! so an existing entry at that path — symlink or not — makes the create fail rather than follow +//! it. The handle is retained for the whole write, and only its own fresh file is renamed into +//! place. + +#![cfg(not(target_arch = "wasm32"))] + +use std::fs::{File, OpenOptions, Permissions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Monotonic counter making temporary names unique within this process without a RNG dependency. +static SEQ: AtomicU64 = AtomicU64::new(0); + +/// How many fresh temporary names to try before giving up (each collision is another local race). +const MAX_TRIES: u32 = 32; + +/// Write `bytes` to `path`, replacing it atomically and without ever following a pre-existing +/// temporary symlink. +/// +/// A fresh temporary file is created with exclusive semantics in `path`'s own directory, the bytes +/// are written through the retained handle and flushed, and the temporary is then renamed over +/// `path`. On any failure the temporary is removed and the original `path` is left untouched. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + let permissions = match std::fs::symlink_metadata(path) { + Ok(meta) if meta.is_file() => Some(meta.permissions()), + Ok(_) => None, + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => return Err(e), + }; + let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf); + let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?; + let write = (|| { + if let Some(permissions) = permissions { + file.set_permissions(permissions)?; + } + file.write_all(bytes)?; + file.flush()?; + file.sync_all() + })(); + if let Err(e) = write { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + drop(file); + if let Err(e) = std::fs::rename(&tmp, path) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + Ok(()) +} + +/// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path. +/// +/// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing +/// entry (including a symlink), closing the plant-a-symlink race. Names are unpredictable enough to +/// avoid collisions in practice, and genuine collisions are retried with a new name. +fn create_temp(dir: &Path, permissions: Option<&Permissions>) -> io::Result<(File, PathBuf)> { + let pid = std::process::id(); + let mut last_err = None; + for _ in 0..MAX_TRIES { + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0); + let tmp = dir.join(format!(".cadcraft-tmp-{pid}-{seq}-{nanos}")); + match create_exclusive(&tmp, permissions) { + Ok(file) => return Ok((file, tmp)), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + last_err = Some(e); + continue; + } + Err(e) => return Err(e), + } + } + Err(last_err.unwrap_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, "could not create a unique temporary file"))) +} + +fn create_exclusive(path: &Path, permissions: Option<&Permissions>) -> io::Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if let Some(permissions) = permissions { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + // Restrict access in the create syscall: chmod afterwards leaves a window in which a + // different user can open the inode and retain read access to bytes written later. + options.mode(permissions.mode() & 0o777); + } + #[cfg(not(unix))] + let _ = permissions; + options.open(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replaces_contents_atomically() { + let dir = std::env::temp_dir().join(format!("cadcraft-aw-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("out.bin"); + std::fs::write(&dest, b"old").unwrap(); + write_atomic(&dest, b"new bytes").unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), b"new bytes"); + // No temporary files left behind. + let leftovers: Vec<_> = + std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).filter(|n| n.to_string_lossy().starts_with(".cadcraft-tmp")).collect(); + assert!(leftovers.is_empty(), "left temp files: {leftovers:?}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[cfg(unix)] + #[test] + fn does_not_follow_a_planted_temp_symlink() { + use std::os::unix::fs::symlink; + let dir = std::env::temp_dir().join(format!("cadcraft-aw-sym-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + let victim = dir.join("victim"); + std::fs::write(&victim, b"precious").unwrap(); + let planted = dir.join(".cadcraft-tmp-plant"); + symlink(&victim, &planted).unwrap(); + assert_eq!(create_exclusive(&planted, None).unwrap_err().kind(), io::ErrorKind::AlreadyExists); + let dest = dir.join("drawing.dxf"); + write_atomic(&dest, b"drawing bytes").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"precious", "victim file was overwritten"); + assert_eq!(std::fs::read(&dest).unwrap(), b"drawing bytes"); + std::fs::remove_dir_all(&dir).ok(); + } + #[cfg(unix)] + #[test] + fn replacement_preserves_private_permissions() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("cadcraft-aw-mode-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("private.adt"); + std::fs::write(&dest, b"old").unwrap(); + std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_atomic(&dest, b"private report").unwrap(); + assert_eq!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(std::fs::read(&dest).unwrap(), b"private report"); + std::fs::remove_dir_all(dir).unwrap(); + } + #[cfg(unix)] + #[test] + fn private_temp_is_restricted_from_creation() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("cadcraft-aw-create-mode-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("private-temp"); + let permissions = Permissions::from_mode(0o600); + let file = create_exclusive(&path, Some(&permissions)).unwrap(); + assert_eq!(file.metadata().unwrap().permissions().mode() & 0o077, 0); + drop(file); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index cee1453d1..cbb5e56ba 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -339,9 +339,9 @@ mod native { if let Some(dir) = Path::new(path).parent() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; } - let tmp = format!("{path}.tmp"); - std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?; - std::fs::rename(&tmp, path).map_err(|e| e.to_string()) + // Create a fresh temporary file exclusively and rename it into place, so a planted + // `.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59). + crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string()) } impl Autosaver { diff --git a/crates/engine/src/cmd/audit.rs b/crates/engine/src/cmd/audit.rs index ac1774842..0a6805eb5 100644 --- a/crates/engine/src/cmd/audit.rs +++ b/crates/engine/src/cmd/audit.rs @@ -142,7 +142,10 @@ fn write_audit_log(s: &Session, report: &crate::audit::Report) { let Some(path) = &path_str else { return }; let adt_path = std::path::PathBuf::from(path).with_extension("adt"); let content = report.format_adt(&title, path_str.as_deref()); - if let Err(e) = std::fs::write(&adt_path, content) { + // Write through an exclusively-created temporary and rename over the companion path, so a + // planted `.adt` symlink beside the drawing cannot redirect the report onto another file + // writable by the victim (CWE-59). + if let Err(e) = crate::atomic_write::write_atomic(&adt_path, content.as_bytes()) { log::warn!("AUDITCTL: could not write audit log {}: {e}", adt_path.display()); } } diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index 5f3d30654..2829c0558 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -79,10 +79,9 @@ fn extension(path: &str) -> String { fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> { #[cfg(not(target_arch = "wasm32"))] { - let tmp = format!("{path}.cadcraft-tmp"); - std::fs::write(&tmp, bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?; - std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?; - Ok(()) + // Exclusively create a fresh temporary file and rename it into place so a planted + // `.cadcraft-tmp` symlink cannot redirect the export write (CWE-59). + crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}"))) } #[cfg(target_arch = "wasm32")] { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4d184c794..062840690 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -403,10 +403,9 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result Result<()> { #[cfg(not(target_arch = "wasm32"))] { - let tmp = format!("{path}.cadcraft-tmp"); - std::fs::write(&tmp, bytes).map_err(|e| bad("save", format!("{path}: {e}")))?; - std::fs::rename(&tmp, path).map_err(|e| bad("save", format!("{path}: {e}")))?; - Ok(()) + // Exclusively create a fresh temporary file and rename it over `path`, so a planted + // `.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59). + crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}"))) } #[cfg(target_arch = "wasm32")] { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index cac585209..b009f687a 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -826,9 +826,9 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] { let path = _path; - let tmp = format!("{path}.cadcraft-tmp"); - std::fs::write(&tmp, &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?; - std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?; + // Exclusively create a fresh temporary file and rename it into place so a planted + // `.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59). + crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?; Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages })) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1a2dfddfa..a4ac9aafd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,6 +9,8 @@ #![forbid(unsafe_code)] pub mod assoc; +#[cfg(not(target_arch = "wasm32"))] +pub mod atomic_write; pub mod audit; pub mod autosave; pub mod blockedit; diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index e36c0c1b8..16bb38b52 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -18,11 +18,16 @@ if [ -z "${APPLE_CERTIFICATE:-}" ]; then fi TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +# Create a private per-run directory (mode 0700 via the umask above) so the decoded signing +# container cannot be read by, or pre-created by, another local user in a shared temp directory. +CERT_DIR="$(mktemp -d "$TMP/cadcraft-signing.XXXXXXXX")" KEYCHAIN="$TMP/cadcraft-signing.keychain-db" -CERT="$TMP/cadcraft-signing.p12" +CERT="$CERT_DIR/cadcraft-signing.p12" KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" -trap 'rm -f "$CERT"' EXIT +trap 'rm -f "$CERT"; rm -rf "$CERT_DIR"' EXIT +# Create the certificate file privately before writing, refusing to follow a pre-existing entry. +( set -C; : >"$CERT" ) printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT" security create-keychain -p "$KC_PASS" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" From 683eb5e6e75c2ee6a05b4de2273e737338692c00 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 17:04:35 -0400 Subject: [PATCH 26/57] security: do not follow directory symlinks in cargo xtask stats (CWE-400) (#1845) * security: do not follow directory symlinks in `cargo xtask stats` (CWE-400) The stats walker descended with `Path::is_dir()`, which follows symlinks, and kept no record of visited directories. A small acyclic graph of directory aliases planted in a workspace package (for example through a contribution a developer inspects) therefore caused exponentially repeated enumeration and file reads, consuming CPU and I/O far beyond the repository's real size. `walk` now inspects each entry's own `file_type()` and skips symlinks before descending or reading, so directory aliases are never re-scanned. A traversal budget (`MAX_ENTRIES`) bounds total work as defense in depth. Addresses finding row 4. * fix: report statistics traversal exhaustion --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- xtask/src/stats.rs | 65 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs index f40b1300a..a5b6e7dfe 100644 --- a/xtask/src/stats.rs +++ b/xtask/src/stats.rs @@ -10,20 +10,41 @@ struct Counts { proptests: usize, } -fn walk(dir: &Path, c: &mut Counts) { +/// Upper bound on directory entries visited in one `walk`, so even a crafted tree of aliases can +/// never make the stats command run unbounded work. +const MAX_ENTRIES: usize = 2_000_000; + +fn walk(dir: &Path, c: &mut Counts) -> Result<(), String> { + let mut budget = MAX_ENTRIES; + walk_inner(dir, c, &mut budget) +} + +fn walk_inner(dir: &Path, c: &mut Counts, budget: &mut usize) -> Result<(), String> { let Ok(rd) = std::fs::read_dir(dir) else { - return; + return Ok(()); }; for e in rd.flatten() { + if *budget == 0 { + return Err(format!("stats traversal budget exhausted at {}", dir.display())); + } + *budget -= 1; + // Use the entry's own type and never follow symlinks: a directory symlink would otherwise + // be re-scanned as a fresh directory, so a small graph of aliases could cause exponential + // repeated enumeration and reads (CWE-400). + let Ok(ft) = e.file_type() else { continue }; + if ft.is_symlink() { + continue; + } let p = e.path(); let name = e.file_name(); let name = name.to_string_lossy(); - if p.is_dir() { + if ft.is_dir() { // Nested fuzz workspaces and build output are not part of the crate. if name != "target" && name != "fuzz" && !name.starts_with('.') { - walk(&p, c); + walk_inner(&p, c, budget)?; } - } else if name.ends_with(".rs") + } else if ft.is_file() + && name.ends_with(".rs") && let Ok(s) = std::fs::read_to_string(&p) { c.files += 1; @@ -33,6 +54,7 @@ fn walk(dir: &Path, c: &mut Counts) { c.proptests += pt; } } + Ok(()) } /// `#[test]` attributes and `proptest!` blocks (each block may hold many @@ -71,7 +93,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { continue; }; let mut c = Counts::default(); - walk(&dir, &mut c); + walk(&dir, &mut c)?; let ex = if exact { exact_tests(&name) } else { None }; rows.push((name, c, ex)); } @@ -108,11 +130,40 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { #[cfg(test)] mod tests { - use super::count_tests; + use super::{Counts, count_tests, walk}; #[test] fn counts_attributes_and_proptest_blocks() { let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n"; assert_eq!(count_tests(src), (2, 1)); } + + #[cfg(unix)] + #[test] + fn walk_does_not_follow_directory_symlinks() { + use std::os::unix::fs::symlink; + let base = std::env::temp_dir().join(format!("cadcraft-xtask-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let real = base.join("real"); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("a.rs"), "fn a() {}\n").unwrap(); + // A directory symlink pointing back at `real` would be re-scanned if followed. + symlink(&real, base.join("alias")).unwrap(); + let mut c = Counts::default(); + walk(&base, &mut c).unwrap(); + assert_eq!(c.files, 1, "the aliased directory was followed and counted twice"); + std::fs::remove_dir_all(&base).ok(); + } + #[test] + fn walk_reports_budget_exhaustion() { + let base = std::env::temp_dir().join(format!("cadcraft-stats-budget-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(base.join("a.rs"), "fn a() {}\n").unwrap(); + let error = super::walk_inner(&base, &mut Counts::default(), &mut 0).unwrap_err(); + assert!(error.contains("budget exhausted")); + let mut c = Counts::default(); + super::walk_inner(&base, &mut c, &mut 1).unwrap(); + assert_eq!(c.files, 1); + std::fs::remove_dir_all(base).unwrap(); + } } From 0158712bfd542e588b9e612e2c8469e3bffa1193 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 17:05:16 -0400 Subject: [PATCH 27/57] security: bound geometry-kernel degree and work (CWE-407) (#1848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security: bound geometry-kernel degree and work (CWE-407) Several geometry routines let a crafted spline or region cause quadratic or worse CPU work: - B-spline basis evaluation is O(degree²) per sample and tessellation takes O(degree) samples, so a HATCH/DXF spline edge declaring a very high degree (DXF allows up to 100 000) turned a tiny spline into billions of iterations. Add `MAX_DEGREE = 64` and treat higher-degree splines as invalid, so `eval`/`tessellate` fall back to the control polygon. The render clip boundary also rejects such degrees up front (rows 18, 28). - `Spline::derivative` re-ran the O(knots) validity scan on every call, and arc-length integration calls it once per sample, making a valid spline's measurement quadratic. Split off `derivative_unchecked` and have integration (validated once by its public entry points) use it (row 24). - `infer_knot_param` matched each fit parameter against the whole knot vector from the start. Both are ascending, so replace it with a linear merge (row 16). - Fillet seed collection did not stop the enclosing run-pair loops when the per-call candidate cap was hit, and refined duplicate candidates. Bound total seeds across pairs and deduplicate before Newton refinement (row 36). - Region grid intersection counted only distinct pairs against its budget, so a long segment replicated across many cells re-visited the same pair unbounded. Charge every candidate-pair visit against a budget (row 41). New spline degree tests added; all geom and render tests pass, clippy clean. Addresses findings rows 16, 18, 24, 28, 36, 41. * fix: preserve spline knot and fillet search behavior --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- crates/geom/src/fillet.rs | 58 ++++++++++++++++++++++++++--------- crates/geom/src/lib.rs | 2 +- crates/geom/src/nurbs.rs | 15 +++++++-- crates/geom/src/region.rs | 10 ++++++ crates/geom/src/spline.rs | 38 ++++++++++++++++++++++- crates/geom/src/spline_fit.rs | 31 +++++++++++++++++-- crates/render/src/clip.rs | 2 +- 7 files changed, 134 insertions(+), 22 deletions(-) diff --git a/crates/geom/src/fillet.rs b/crates/geom/src/fillet.rs index abfea035b..d31757f79 100644 --- a/crates/geom/src/fillet.rs +++ b/crates/geom/src/fillet.rs @@ -158,7 +158,7 @@ fn seg_cross(a0: Vec2, a1: Vec2, b0: Vec2, b1: Vec2) -> Option<(f64, f64)> { } /// Crossings of two sampled polylines, as parameter seeds, with chunked box culling. -fn crossings(a: &[(f64, Vec2)], b: &[(f64, Vec2)], out: &mut Vec<(f64, f64)>) { +fn crossings(a: &[(f64, Vec2)], b: &[(f64, Vec2)], out: &mut Vec<(f64, f64)>, work: &mut usize) { const CHUNK: usize = 16; let boxes = |s: &[(f64, Vec2)]| -> Vec<(usize, Bounds2)> { (0..s.len().saturating_sub(1)) @@ -169,12 +169,16 @@ fn crossings(a: &[(f64, Vec2)], b: &[(f64, Vec2)], out: &mut Vec<(f64, f64)>) { let (ba, bb) = (boxes(a), boxes(b)); for (ia, xa) in &ba { for (ib, xb) in &bb { + let Some(left) = work.checked_sub(1) else { return }; + *work = left; if !xa.intersects(xb) { continue; } for i in *ia..(*ia + CHUNK).min(a.len().saturating_sub(1)) { let (Some(&(ta0, a0)), Some(&(ta1, a1))) = (a.get(i), a.get(i + 1)) else { continue }; for j in *ib..(*ib + CHUNK).min(b.len().saturating_sub(1)) { + let Some(left) = work.checked_sub(1) else { return }; + *work = left; let (Some(&(tb0, b0)), Some(&(tb1, b1))) = (b.get(j), b.get(j + 1)) else { continue }; if let Some((u, v)) = seg_cross(a0, a1, b0, b1) { out.push((ta0 + (ta1 - ta0) * u, tb0 + (tb1 - tb0) * v)); @@ -255,24 +259,35 @@ pub fn tangent_circles(c1: &TanCurve, c2: &TanCurve, r: f64, near: &[Vec2]) -> V let (Some(r1), Some(r2)) = (c1.range(&win), c2.range(&win)) else { return Vec::new() }; let signs: &[(f64, f64)] = if r > 0.0 { &[(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] } else { &[(0.0, 0.0)] }; let mut out: Vec = Vec::new(); + let mut work = 16_000_000usize; for (s1, s2) in signs { let (d1, d2) = (s1 * r, s2 * r); let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2)); - let mut seeds = Vec::new(); + // Refine each pair before visiting the next: raw duplicate seeds in an early run must + // not consume all candidate slots. A separate request-wide budget bounds actual work. for a in &o1 { for b in &o2 { - crossings(a, b, &mut seeds); - } - } - for seed in seeds { - let Some((t1, t2)) = refine(c1, &r1, d1, c2, &r2, d2, seed, tol) else { continue }; - let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue }; - let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3); - if !out.iter().any(near_dup) { - out.push(CornerFit { t1, t2, p1, p2, center }); - } - if out.len() >= MAX_SOLUTIONS { - return out; + let mut seeds = Vec::new(); + crossings(a, b, &mut seeds, &mut work); + seeds.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1))); + seeds.dedup_by(|x, y| (x.0 - y.0).abs() < 1e-9 && (x.1 - y.1).abs() < 1e-9); + for seed in seeds { + // Newton refinement has a fixed iteration cap and several curve evaluations. + let Some(left) = work.checked_sub(128) else { return out }; + work = left; + let Some((t1, t2)) = refine(c1, &r1, d1, c2, &r2, d2, seed, tol) else { continue }; + let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue }; + let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3); + if !out.iter().any(near_dup) { + out.push(CornerFit { t1, t2, p1, p2, center }); + } + if out.len() >= MAX_SOLUTIONS { + return out; + } + } + if work == 0 { + return out; + } } } } @@ -353,3 +368,18 @@ mod tests { assert!(tangent_circles(&bad, &s, 1.0, &[]).is_empty()); } } + +#[cfg(test)] +mod seed_budget_tests { + use super::*; + #[test] + fn crossing_visits_have_an_independent_work_limit() { + let a = [(0.0, Vec2::new(0.0, 0.0)), (1.0, Vec2::new(1.0, 1.0))]; + let b = [(0.0, Vec2::new(0.0, 1.0)), (1.0, Vec2::new(1.0, 0.0))]; + let mut seeds = Vec::new(); + crossings(&a, &b, &mut seeds, &mut 0); + assert!(seeds.is_empty()); + crossings(&a, &b, &mut seeds, &mut 10); + assert_eq!(seeds, vec![(0.5, 0.5)]); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 8d305f79d..e62f478fb 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -25,7 +25,7 @@ pub use curve::{ pub use fillet::{CornerFit, TanCurve, tangent_circles}; pub use intersect::{circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite}; pub use mat::{Mat3, Mat4}; -pub use spline::Spline; +pub use spline::{MAX_DEGREE as MAX_SPLINE_DEGREE, Spline}; pub use spline_fit::{FitOptions, KnotParam}; pub use vec::{Vec2, Vec3}; diff --git a/crates/geom/src/nurbs.rs b/crates/geom/src/nurbs.rs index b5e1e866b..5268203b1 100644 --- a/crates/geom/src/nurbs.rs +++ b/crates/geom/src/nurbs.rs @@ -124,8 +124,15 @@ impl Spline { if !self.is_valid() { return Vec2::ZERO; } + self.derivative_unchecked(t) + } + + /// First derivative without re-validating the spline. The caller must have confirmed + /// [`Spline::is_valid`] already; integration calls this once per sample, and re-running the + /// O(knots) validity scan each time would make measuring a valid spline quadratic (CWE-407). + fn derivative_unchecked(&self, t: f64) -> Vec2 { let p = self.degree; - let n = self.control.len() - 1; + let Some(n) = self.control.len().checked_sub(1) else { return Vec2::ZERO }; let (lo, hi) = self.domain(); let t = if t.is_finite() { t.clamp(lo, hi) } else { lo }; let span = find_span(n, p, t, &self.knots); @@ -470,9 +477,11 @@ impl Spline { out } - /// Arc length of one integration panel. + /// Arc length of one integration panel. The spline is validated once by the public callers + /// ([`Spline::length_between`]/[`Spline::length`]) before any panel is measured, so the speed + /// closure uses the unchecked derivative to avoid re-scanning the knots per sample. fn panel_length(&self, a: f64, b: f64) -> f64 { - let speed = |t: f64| self.derivative(t).len(); + let speed = |t: f64| self.derivative_unchecked(t).len(); adaptive_gauss(a, b, gauss(a, b, speed), &speed, MAX_GAUSS_DEPTH) } diff --git a/crates/geom/src/region.rs b/crates/geom/src/region.rs index 70eca56d2..696a97de2 100644 --- a/crates/geom/src/region.rs +++ b/crates/geom/src/region.rs @@ -114,9 +114,19 @@ fn build(polys: &[Vec], tol: f64) -> Option { } let mut splits: Vec> = vec![vec![0.0, 1.0]; n]; let mut tested = std::collections::HashSet::new(); + // Count every candidate-pair *visit*, including repeats of a pair that shares several grid + // cells, not only the distinct pairs actually tested. A long segment replicated across many + // cells makes the same pair reappear in each, so a budget on distinct pairs alone leaves the + // repeated hashing unbounded (CWE-407). + let mut visits: u64 = 0; + const MAX_PAIR_VISITS: u64 = 60_000_000; for list in grid.values() { for (k, &i) in list.iter().enumerate() { for &j in list.iter().skip(k + 1) { + visits += 1; + if visits > MAX_PAIR_VISITS { + return None; + } if i == j || !tested.insert((i.min(j), i.max(j))) { continue; } diff --git a/crates/geom/src/spline.rs b/crates/geom/src/spline.rs index aaca36c1b..8ae2b73eb 100644 --- a/crates/geom/src/spline.rs +++ b/crates/geom/src/spline.rs @@ -3,6 +3,12 @@ use serde::{Deserialize, Serialize}; use crate::spline_fit::FitOptions; use crate::{Bounds2, Vec2}; +/// Largest B-spline degree treated as valid. Basis evaluation is O(degree²) per sample and +/// tessellation takes O(degree) samples, so an unbounded degree (DXF/HATCH can declare up to +/// 100 000) turns a tiny spline into billions of iterations (CWE-407). Real splines are low +/// degree (AutoCAD caps at 11); above this a spline evaluates to its control polygon instead. +pub const MAX_DEGREE: usize = 64; + /// A (possibly rational) B-spline curve, DXF SPLINE style. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Spline { @@ -57,7 +63,11 @@ impl Spline { pub fn is_valid(&self) -> bool { let n = self.control.len(); - n > self.degree && self.degree >= 1 && self.knots.len() == n + self.degree + 1 && self.knots.windows(2).all(|w| w.first() <= w.get(1)) + n > self.degree + && self.degree >= 1 + && self.degree <= MAX_DEGREE + && self.knots.len() == n + self.degree + 1 + && self.knots.windows(2).all(|w| w.first() <= w.get(1)) } pub fn domain(&self) -> (f64, f64) { @@ -192,3 +202,29 @@ pub(crate) fn solve(mut a: Vec>, mut b: Vec) -> Option> { } Some(x) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_huge_degree_spline_is_invalid_and_cheap_to_tessellate() { + // A HATCH/DXF spline edge can declare a very high degree with matching knots. Such a + // spline is treated as invalid, so eval/tessellate fall back to the control polygon + // instead of doing O(degree^2) work per sample (CWE-407, rows 18/28). + let degree = 50_000usize; + let control = vec![Vec2::ZERO; degree + 2]; + let knots = clamped_uniform_knots(control.len(), degree); + let s = Spline { degree, knots, control: control.clone(), ..Spline::default() }; + assert!(!s.is_valid(), "a degree-{degree} spline must be rejected as invalid"); + // Returns the control polygon rather than sampling, so this is fast. + assert_eq!(s.tessellate(1e-3).len(), control.len()); + } + + #[test] + fn a_normal_low_degree_spline_is_valid() { + let s = Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(2.0, 0.0), Vec2::new(3.0, 1.0)], 3); + assert!(s.is_valid()); + assert!(s.tessellate(1e-3).len() > 4); + } +} diff --git a/crates/geom/src/spline_fit.rs b/crates/geom/src/spline_fit.rs index 5f42a01ad..4abdc977c 100644 --- a/crates/geom/src/spline_fit.rs +++ b/crates/geom/src/spline_fit.rs @@ -172,8 +172,21 @@ impl Spline { return true; } } - // Other writers use the parameters themselves as interior knots. - u.len() > 2 && u.iter().skip(1).take(u.len() - 2).all(|q| norm.iter().any(|k| close(*k, *q))) + // Other writers use the parameters themselves as interior knots. Both `u` and + // `norm` are ascending, so a single linear merge decides containment instead of + // re-scanning `norm` from the start for every parameter, which is quadratic on an + // otherwise valid spline (CWE-407). + if u.len() <= 2 { + return false; + } + let interior = u.get(1..u.len() - 1).unwrap_or(&[]); + let mut j = 0usize; + interior.iter().all(|q| { + while j < norm.len() && norm[j] < *q && !close(norm[j], *q) { + j += 1; + } + norm.get(j).is_some_and(|k| close(*k, *q)) + }) }) .unwrap_or(KnotParam::Chord) } @@ -895,3 +908,17 @@ mod tests { assert!(s.fit_opts.start_tangent.is_some_and(|t| t.near(Vec2::Y, 1e-12))); } } + +#[cfg(test)] +mod knot_boundary_tests { + use super::*; + #[test] + fn uniform_knots_skip_the_strict_lower_tolerance_boundary() { + let q = 2.0 / 3.0; + let mut s = Spline::from_control(vec![Vec2::ZERO; 5], 1); + s.fit = vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(9.0, 0.0)]; + s.knots = vec![0.0, 0.0, 1.0 / 3.0, q - 1e-6, q, 1.0, 1.0]; + assert!(s.is_valid()); + assert_eq!(s.infer_knot_param(), KnotParam::Uniform); + } +} diff --git a/crates/render/src/clip.rs b/crates/render/src/clip.rs index 0b4c215df..73d89fdc3 100644 --- a/crates/render/src/clip.rs +++ b/crates/render/src/clip.rs @@ -215,7 +215,7 @@ pub fn boundary_polygon(k: &EntityKind) -> Option> { g.tessellate(r * e.ratio.min(1.0) * 2e-3, &mut pts); } EntityKind::Spline(s) => { - if s.control.len() > 100_000 || s.knots.len() > 100_000 { + if s.control.len() > 100_000 || s.knots.len() > 100_000 || s.degree > cadcraft_geom::MAX_SPLINE_DEGREE { return None; } pts = s.tessellate(tol_for(Bounds2::from_points(s.control.iter().copied()))); From 7c72ec45a9f66ffe237d3d9d5ffcdbb6aa4b2b07 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 17:08:02 -0400 Subject: [PATCH 28/57] security: bound drawing document-model work and allocation (CWE-400/407) (#1850) * security: bound drawing document-model work and allocation (CWE-400/407) Several document-model operations let crafted drawing data cause quadratic or exponential work, or multiply retained allocations: - Polyline grip generation rebuilt the whole segment vector inside the loop over every vertex, so enumerating one large polyline's grips was quadratic. Compute the segments once before the loop (row 35). - Associative-array name sync searched the whole replacement vector for every placement. Index replacements by placement coordinate once (last entry wins), turning the per-item lookup O(1) (row 44). - Dimension-extent bounds recursively expanded the referenced block without the shared bounds cache the Insert path uses, so a branching or self-referencing dimension block cost one pass per reference. Route it through the same `(name, depth)` cache (row 37). - Dynamic-block parsing parsed every graph-member occurrence into its own owned parameter, so a graph repeating a member multiplied one record into gigabytes. Deduplicate member handles before resolving them (row 7). - Stretch-action application linearly searched the vertex list and cloned the index vector for every selection occurrence. Index vertices by handle and skip handles already handled in the action's selection (row 30). New tests for polyline grips; all doc tests pass, clippy clean. Addresses findings rows 7, 30, 35, 37, 44. * fix: retain first dynamic stretch vertex selection --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- crates/doc/src/array.rs | 9 ++++++++- crates/doc/src/dynblock.rs | 6 +++++- crates/doc/src/dynblock/apply.rs | 10 +++++++++- crates/doc/src/entity.rs | 23 ++++++++++++++++++++++- crates/doc/src/extents.rs | 18 +++++++++++++++--- 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/array.rs b/crates/doc/src/array.rs index 57d4da532..b663785ed 100644 --- a/crates/doc/src/array.rs +++ b/crates/doc/src/array.rs @@ -248,10 +248,17 @@ impl ArrayParams { return false; } let Some(items) = self.items() else { return false }; + // Index the replacement entries by placement so each item is matched in O(1) instead of + // scanning the whole `replaced` vector per item, which is quadratic when both the items + // and the replacements are large (CWE-407). Last entry wins, as `.rev().find` did. + let mut last_replaced: std::collections::HashMap<(u32, u32, u32), usize> = std::collections::HashMap::with_capacity(self.replaced.len()); + for (idx, r) in self.replaced.iter().enumerate() { + last_replaced.insert((r.i, r.j, r.level), idx); + } let mut source: Option<&str> = None; for (it, e) in items.iter().zip(&ents) { let EntityKind::Insert(ins) = &e.kind else { return false }; - match self.replaced.iter_mut().rev().find(|r| (r.i, r.j, r.level) == (it.i, it.j, it.level)) { + match last_replaced.get(&(it.i, it.j, it.level)).and_then(|&idx| self.replaced.get_mut(idx)) { Some(r) => r.block.clone_from(&ins.block), None => match source { None => source = Some(&ins.block), diff --git a/crates/doc/src/dynblock.rs b/crates/doc/src/dynblock.rs index 6b501a83a..6887459a4 100644 --- a/crates/doc/src/dynblock.rs +++ b/crates/doc/src/dynblock.rs @@ -334,7 +334,11 @@ fn graph_members<'a>(objects: &HashMap, xdict: Handle } let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?; let members = section(§ions(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value)); - Some(members.take(MAX_NODES).filter_map(|h| objects.get(&h).copied()).collect()) + // Deduplicate member handles before resolving them: a graph can list the same member many + // times, and parsing each occurrence into its own owned parameter multiplies a single large + // record into gigabytes of retained allocations (CWE-400). + let mut seen = std::collections::HashSet::new(); + Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect()) } /// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a diff --git a/crates/doc/src/dynblock/apply.rs b/crates/doc/src/dynblock/apply.rs index b727c1535..70ef484eb 100644 --- a/crates/doc/src/dynblock/apply.rs +++ b/crates/doc/src/dynblock/apply.rs @@ -464,7 +464,15 @@ fn apply( if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) { continue; } + // Index this action's vertex lists by handle once, and skip handles already handled in + // this action's selection, so a selection that repeats a handle does not re-scan the + // vertex list and re-clone its index vector per occurrence (CWE-400). + let vtx_by_handle: HashMap> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect(); + let mut handled: HashSet = HashSet::new(); for h in &a.selection { + if !handled.insert(*h) { + continue; + } if let Some(node) = param_of.get(h) { // A parameter in the selection is carried along: its points in the frame. Which // points are in it is decided where the dynamic block has them (AutoCAD evaluates @@ -489,7 +497,7 @@ fn apply( continue; } let Some(c) = copy_of(h) else { continue }; - let idx = vertices.iter().find(|(vh, _)| vh == h).map(|(_, i)| i.clone()); + let idx = vtx_by_handle.get(h).map(|i| (*i).clone()); moves.entry(c).or_default().push((idx, v)); } } diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 4d22c1753..87c7e45b7 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -1408,9 +1408,12 @@ impl EntityKind { EntityKind::LwPolyline(p) => { let mut g: Vec = Vec::new(); let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + // Compute the segments once, not once per vertex: rebuilding the whole segment + // vector inside the loop is quadratic in the vertex count (CWE-407). + let segs = pl.segments(); for (i, v) in p.vertices.iter().enumerate() { g.push(v.p); - if let Some(s) = pl.segments().get(i) { + if let Some(s) = segs.get(i) { g.push(s.mid()); } } @@ -1468,3 +1471,21 @@ fn transform_text(t: &mut Text, m: &Mat3, s: f64, rot: f64, mirror: bool) { // MIRRTEXT = 0 (the default): mirrored text keeps reading left to right. t.rotation = cadcraft_geom::norm_angle(t.rotation + if mirror { 0.0 } else { rot }); } + +#[cfg(test)] +mod grip_tests { + use super::*; + use cadcraft_geom::PolyVertex; + + #[test] + fn polyline_grips_are_vertices_and_midpoints() { + // Grips are each vertex plus the midpoint of each segment; the segment vector is computed + // once rather than rebuilt per vertex (CWE-407, row 35). + let verts: Vec = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)].iter().map(|&(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(); + let e = EntityKind::LwPolyline(LwPolyline { vertices: verts, closed: false, const_width: 0.0, elevation: 0.0, plinegen: false }); + let g = e.grips(); + // 3 vertices + 2 segment midpoints. + assert_eq!(g.len(), 5, "{g:?}"); + assert!(g.contains(&Vec2::new(0.0, 0.0)) && g.contains(&Vec2::new(5.0, 0.0))); + } +} diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index 0fbd73c81..d7c7c7a8a 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -165,9 +165,21 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap< if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n)) && depth < MAX_BLOCK_DEPTH { - for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) { - b = b.union(&bounds_in(d, be, depth + 1, blocks)); - } + // Reuse the shared block-bounds cache (as the Insert branch does) so a dimension + // block with branching or self-referencing children costs one pass per definition + // per depth, not one per reference (which grows exponentially) (CWE-400). + let inner = match blocks.get(&(blk.name.as_str(), depth)) { + Some(cached) => *cached, + None => { + let mut inner = Bounds2::EMPTY; + for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) { + inner = inner.union(&bounds_in(d, be, depth + 1, blocks)); + } + blocks.insert((blk.name.as_str(), depth), inner); + inner + } + }; + b = b.union(&inner); } b } From 2dbc7699a07c2c8833e57e668d4e31d722e31f2e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 17:08:25 -0400 Subject: [PATCH 29/57] security: budget the rendering pipeline against DoS geometry (CWE-400/407/789) (#1849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security: budget the rendering pipeline against DoS geometry (CWE-400/407/789) Several render paths let crafted drawing data spin the rendering thread or allocate without bound: - Hatch dash sweeping reset its per-span step counter and used emitted geometry as its only limit, so gap-only families (which emit nothing) could multiply CPU work. Skip families that cannot draw and charge every dash step against a shared per-hatch work budget (row 10). - Hatch polygon deduplication compared loops with an O(n²) cyclic match and every loop against every kept one. Charge vertex comparisons against a shared budget and stop deduplicating when spent (row 12). - Table coverage marked every merge rectangle, so overlapping merges re-marked covered cells quadratically. Bound marking to a small multiple of the cell count (row 11). - Broken-table part layout expanded sparse rows to full width before checking whether detailed layout is allowed. Only expand when within the cell limit; otherwise leave cells sparse and let layout draw an outline (row 17). - Dimension text substitution copied the base for every `<>` placeholder with no size bound. Estimate the expansion and cap it at a fixed text budget (row 32). - Linetype range generation cycled all-gap patterns forever (nothing emitted, so the emit limit never tripped). Return continuous for all-gap patterns and bound visited pattern elements (row 38). - Viewport contents built with a fresh expansion counter, so a layout could far exceed the documented block-expansion limit. Share one budget across the whole build, seeded with what the enclosing list already spent (row 22). New tests for the all-gap linetype path; all render tests pass, clippy clean. Addresses findings rows 10, 11, 12, 17, 22, 32, 38. * fix: preserve bounded render fallbacks and text output --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- crates/render/src/dim.rs | 59 +++++++++++++++++++++++- crates/render/src/hatch.rs | 73 +++++++++++++++++++++++++---- crates/render/src/lib.rs | 7 ++- crates/render/src/linetype.rs | 17 ++++++- crates/render/src/table.rs | 86 ++++++++++++++++++++++++++++------- 5 files changed, 213 insertions(+), 29 deletions(-) diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index f38e7ea0d..e79b7b4e6 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -414,7 +414,14 @@ fn tol_height_code(st: &DimStyle) -> String { } fn post(template: &str, meas: &str) -> String { - if template.contains("<>") { template.replace("<>", meas) } else { format!("{meas}{template}") } + if template.contains("<>") { + bounded_replace(template, meas) + } else { + let mut out = String::new(); + append_dim_text(&mut out, meas); + append_dim_text(&mut out, template); + out + } } fn apply_override(base: &str, d: &Dimension) -> String { @@ -423,10 +430,40 @@ fn apply_override(base: &str, d: &Dimension) -> String { } else if d.text.trim().is_empty() { String::new() } else { - d.text.replace("<>", base) + bounded_replace(&d.text, base) } } +/// Largest dimension text produced by substituting the measurement into style/override text. Two +/// modest strings can otherwise expand into gigabytes when the template holds many `<>` markers +/// (CWE-789); past this the output is truncated rather than allocated unbounded. +const MAX_DIM_TEXT: usize = 64 * 1024; + +/// Replace every `<>` in `template` with `value`, with the expanded length checked against +/// [`MAX_DIM_TEXT`] before allocating so a many-placeholder template cannot blow up memory. +fn bounded_replace(template: &str, value: &str) -> String { + let mut out = String::new(); + let mut rest = template; + while let Some(i) = rest.find("<>") { + if !append_dim_text(&mut out, &rest[..i]) || !append_dim_text(&mut out, value) { + return out; + } + rest = &rest[i + 2..]; + } + append_dim_text(&mut out, rest); + out +} + +// Returns false when truncation consumed the remaining UTF-8-safe space. +fn append_dim_text(out: &mut String, text: &str) -> bool { + let mut end = text.len().min(MAX_DIM_TEXT.saturating_sub(out.len())); + while !text.is_char_boundary(end) { + end -= 1; + } + out.push_str(&text[..end]); + end == text.len() +} + fn ext_line(origin: Vec2, foot: Vec2, st: &DimStyle, k: f64) -> Option> { let v = foot - origin; let len = v.len(); @@ -1322,3 +1359,21 @@ mod tests { assert!(g.lines.is_empty() && g.fills.is_empty(), "{:?}", g.lines); } } + +#[cfg(test)] +mod text_budget_tests { + use super::*; + #[test] + fn bounds_post_without_markers() { + let text = post(&"x".repeat(MAX_DIM_TEXT * 2), "12"); + assert_eq!(text.len(), MAX_DIM_TEXT); + assert!(text.starts_with("12x")); + } + #[test] + fn oversized_value_is_substituted_and_truncated_at_a_character_boundary() { + let text = bounded_replace("prefix <> mm", &"é".repeat(MAX_DIM_TEXT)); + assert!(text.starts_with("prefix é")); + assert!(!text.contains("<>")); + assert!(text.len() <= MAX_DIM_TEXT && text.len() >= MAX_DIM_TEXT - 1); + } +} diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index e8d753757..b297618ab 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -80,18 +80,47 @@ fn same_polygon(a: &[Vec2], b: &[Vec2]) -> bool { } fn dedup_polygons(loops: &mut Vec>) { + // `same_polygon` is O(n²) in a polygon's vertex count (every cyclic alignment, both windings), + // and each loop is compared against every unique one kept so far, so adversarial boundary + // geometry could force quadratic-on-quadratic work. Charge every vertex comparison against a + // shared budget; once it is spent, stop deduplicating and keep the remaining loops as-is + // (CWE-407). + let mut budget: u64 = MAX_DEDUP_COMPARISONS; let mut unique: Vec> = Vec::with_capacity(loops.len()); for l in loops.drain(..) { if l.len() < 3 { continue; } - if !unique.iter().any(|u| same_polygon(u, &l)) { + let mut dup = false; + for u in &unique { + if u.len() != l.len() { + continue; + } + // Each `same_polygon` call costs about 2·len² comparisons; stop if that would overrun. + let cost = 2u64.saturating_mul((u.len() as u64).saturating_mul(l.len() as u64)); + match budget.checked_sub(cost) { + Some(left) => budget = left, + None => { + // Out of budget: keep `l` without further comparison. + budget = 0; + break; + } + } + if same_polygon(u, &l) { + dup = true; + break; + } + } + if !dup { unique.push(l); } } *loops = unique; } +/// Vertex comparisons `dedup_polygons` may perform before it stops deduplicating. +const MAX_DEDUP_COMPARISONS: u64 = 50_000_000; + /// The tessellated boundary loops that take part in the fill under the hatch's island /// detection style (DXF group 75), filled with odd parity: 0 normal keeps every loop (islands /// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2 @@ -261,6 +290,9 @@ const MAX_PATTERN_LINES: usize = 1024; /// (a hostile or mis-scaled pattern can't exhaust memory). A view-limited build makes at most the /// runs of a full build, so it never leaves out runs that a full build draws. const MAX_RUNS: usize = 1_000_000; +/// Shared budget on dash-processing steps across all of a hatch's families and spans, counted +/// independently of emitted geometry so gap-only patterns cannot spin without bound (CWE-400). +const MAX_HATCH_WORK: u64 = 50_000_000; /// Upper bound on the lines one family sweeps over the whole hatch; past it the family is left /// out (a full-detail build of a pattern far too fine for its area). const MAX_FAMILY_LINES: i64 = 20_000; @@ -351,7 +383,16 @@ pub(crate) fn pattern_lines_until( let scale = scale_of(h); let px = if px.is_finite() && px > 0.0 { px } else { 0.0 }; let mut out = Vec::new(); + // Shared work budget across all families and spans, counting dash-processing steps even when + // they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound + // their cost (CWE-400). + let mut work: u64 = MAX_HATCH_WORK; for fam in &lines { + // A family with dashes but no positive (ink) element can never draw: skip it so its gaps + // are not swept at all. + if !fam.dashes.is_empty() && !fam.dashes.iter().any(|d| *d >= 0.0) { + continue; + } let ang = fam.angle.to_radians() + h.angle; let origin = h.origin + Vec2::new(fam.origin.0, fam.origin.1).rotate(h.angle) * scale; let delta = Vec2::new(fam.delta.0, fam.delta.1).rotate(ang) * scale; @@ -360,8 +401,8 @@ pub(crate) fn pattern_lines_until( continue; } let dashes: Vec = fam.dashes.iter().map(|d| d * scale).collect(); - Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out); - if out.len() >= MAX_RUNS || is_set(cancel) { + Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work); + if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) { break; } } @@ -422,7 +463,7 @@ impl Family<'_> { self.origin + p.rotate(self.ang) } - fn sweep(&self, loops: &[Vec], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec>) { + fn sweep(&self, loops: &[Vec], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec>, work: &mut u64) { let local: Vec> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect(); let bb = Bounds2::from_points(local.iter().flatten().copied()); if bb.is_empty() { @@ -456,7 +497,7 @@ impl Family<'_> { let draws = self.dashes.iter().any(|d| *d >= 0.0); let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws); for k in k0..=k1 { - if out.len() >= MAX_RUNS || is_set(cancel) { + if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) { return; } let y = k as f64 * self.spacing; @@ -484,21 +525,22 @@ impl Family<'_> { if solid_line { out.push(vec![self.to_world(Vec2::new(x0, y)), self.to_world(Vec2::new(x1, y))]); } else { - self.dash(x0, x1, y, offset, pat_len, out); + self.dash(x0, x1, y, offset, pat_len, out, work); } } } } /// The dashes and dots of the span `x0..x1` of the line at `y`, phase anchored at x = `offset`. - fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec>) { + fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec>, work: &mut u64) { let start_rep = ((x0 - offset) / pat_len).floor(); let mut x = offset + start_rep * pat_len; let mut guard = 0; 'outer: while x < x1 { for d in self.dashes { guard += 1; - if guard > 100_000 || out.len() >= MAX_RUNS { + *work = work.saturating_sub(1); + if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 { break 'outer; } let len = d.abs(); @@ -560,3 +602,18 @@ mod tests { assert!((area - 100.0).abs() < 1e-6, "triangulated area must be 100.0, not zero"); } } + +#[cfg(test)] +mod dedup_budget_tests { + use super::*; + #[test] + fn unequal_loops_do_not_spend_the_comparison_budget() { + // The old estimate exhausted the budget on this unequal pair before seeing the triangle. + let a = vec![Vec2::ZERO; 5_000]; + let b = vec![Vec2::ZERO; 5_001]; + let tri = vec![Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)]; + let mut loops = vec![a, b, tri.clone(), tri]; + dedup_polygons(&mut loops); + assert_eq!(loops.len(), 3); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b952fe18e..81da83585 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -735,13 +735,17 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou let eb = cadcraft_doc::entity_bounds(d, me, 0); eb.is_empty() || eb.intersects(&win) }; + // One expansion budget for the whole build: seed it with what the enclosing list has already + // spent so a viewport's contents cannot start over with a fresh allowance and exceed the + // documented limit (CWE-400). Both the serial sub-builder and the parallel chunks share it. + let budget = std::sync::atomic::AtomicUsize::new(b.list.expanded); let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None, - shared_budget: None, + shared_budget: Some(&budget), transparency: 0, }; #[cfg(not(target_arch = "wasm32"))] @@ -754,7 +758,6 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou // As `build_parallel`: chunks built side by side, joined in drawing order. use rayon::prelude::*; let ents: Vec<&std::sync::Arc> = d.model.iter().collect(); - let budget = std::sync::atomic::AtomicUsize::new(0); let opts = b.opts; let parts: Vec = ents .par_chunks(PARALLEL_CHUNK) diff --git a/crates/render/src/linetype.rs b/crates/render/src/linetype.rs index acd576dc9..8a8cab347 100644 --- a/crates/render/src/linetype.rs +++ b/crates/render/src/linetype.rs @@ -94,8 +94,16 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option= 0.0) { + return Some(Vec::new()); + } + // Bound the pattern elements visited, independent of emitted ranges, so a pattern that emits + // little still cannot cycle hundreds of millions of times. + let mut visits: u64 = 0; for d in lt.pattern.iter().map(|d| d.length * scale).cycle() { - if pos >= len || out.len() > 2_000_000 { + visits += 1; + if pos >= len || out.len() > 2_000_000 || visits > 4_000_000 { break; } if d >= 0.0 { @@ -110,6 +118,13 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option Vec> { + coverage(t).unwrap_or_else(|partial| partial) +} + +// No partial coverage may reach owner assignment: it would revisit unbounded merge rectangles. +fn coverage(t: &Table) -> Result>, Vec>> { let rows = t.row_heights.len().min(10_000); let cols = t.col_widths.len().min(10_000); if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS { - return vec![Vec::new(); rows]; + return Err(vec![Vec::new(); rows]); } let mut cov = vec![vec![false; cols]; rows]; + // Coverage marking visits each merge's whole rectangle. Overlapping merges re-mark cells that + // are already covered, so bound the total marking steps to a small multiple of the cell count + // rather than letting overlapping declarations cause quadratic work (CWE-407). + let mut budget: u64 = (rows as u64).saturating_mul(cols as u64).saturating_mul(4).saturating_add(1024); for (r, row) in t.cells.iter().enumerate().take(rows) { for (c, cell) in row.iter().enumerate().take(cols) { if let Some((rs, cs)) = cell.merged { for rr in r..(r + rs.max(1) as usize).min(rows) { for cc in c..(c + cs.max(1) as usize).min(cols) { + budget = budget.saturating_sub(1); + if budget == 0 { + return Err(cov); + } if (rr, cc) != (r, c) && let Some(x) = cov.get_mut(rr).and_then(|v| v.get_mut(cc)) { @@ -74,7 +87,7 @@ pub fn table_covered(t: &Table) -> Vec> { } } } - cov + Ok(cov) } /// The table style a table uses (Standard defaults when missing). @@ -134,19 +147,25 @@ pub fn table_geometry(d: &Drawing, t: &Table) -> TableGeom { /// One part of a broken table as a table of its own: its rows (with their cell styles) at /// `origin`; merges are cut at the part's edge. fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table { - let cols = t.col_widths.len(); - let mut cells: Vec> = rows.iter().map(|r| t.cells.get(*r).cloned().unwrap_or_default()).collect(); - for (i, row) in cells.iter_mut().enumerate() { - row.resize_with(cols, Default::default); - for cell in row.iter_mut() { - if let Some((rs, cs)) = cell.merged { - // Rows of the merge that follow in this part. - let start = rows.get(i).copied().unwrap_or(0); - let mut n = 1u32; - while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) { - n += 1; + let cols = t.col_widths.len().min(10_000); + let mut cells: Vec> = + rows.iter().take(10_000).map(|r| t.cells.get(*r).map(|row| row.iter().take(cols).cloned().collect()).unwrap_or_default()).collect(); + // Only expand sparse rows to the full column count when detailed layout is actually permitted. + // Otherwise `layout` renders an outline, so materialising millions of empty cells here would be + // wasted work an attacker can trigger with a compact dimension array (CWE-789). + if rows.len().min(10_000).saturating_mul(cols) <= MAX_LAYOUT_CELLS { + for (i, row) in cells.iter_mut().enumerate() { + row.resize_with(cols, Default::default); + for cell in row.iter_mut() { + if let Some((rs, cs)) = cell.merged { + // Rows of the merge that follow in this part. + let start = rows.get(i).copied().unwrap_or(0); + let mut n = 1u32; + while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) { + n += 1; + } + cell.merged = (n > 1 || cs > 1).then_some((n, cs)); } - cell.merged = (n > 1 || cs > 1).then_some((n, cs)); } } } @@ -180,7 +199,8 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom { if rows == 0 || cols == 0 { return g; } - if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS { + let cov = coverage(t); + if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS || cov.is_err() { let (w, h) = (x_at(cols), y_at(rows)); let corners = [p(0.0, 0.0), p(w, 0.0), p(w, h), p(0.0, h), p(0.0, 0.0)]; for s in corners.windows(2) { @@ -209,7 +229,7 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom { i }; // Anchors and the anchor owning each grid cell. - let cov = table_covered(t); + let cov = cov.unwrap_or_default(); let mut anchors: Vec = Vec::new(); let mut owner = vec![usize::MAX; rows * cols]; for r in 0..rows { @@ -369,3 +389,37 @@ pub(crate) fn draw(b: &mut Builder, ctx: &Ctx, t: &Table, rgb: Ink, lw: f32) { } } } + +#[cfg(test)] +mod budget_tests { + use super::*; + use cadcraft_doc::TableCell; + + #[test] + fn overlapping_merges_use_outline_fallback() { + let t = Table { + row_heights: vec![1.0; 40], + col_widths: vec![1.0; 40], + cells: vec![vec![TableCell { merged: Some((40, 40)), ..Default::default() }; 40]; 40], + ..Default::default() + }; + assert!(coverage(&t).is_err()); + let g = layout(&Drawing::default(), &t); + assert_eq!(g.lines.len(), 4); + assert!(g.fills.is_empty() && g.texts.is_empty()); + } + + #[test] + fn part_clips_merges_using_displayed_column_count() { + let t = Table { + row_heights: vec![1.0; 102], + col_widths: vec![1.0; 10_001], + cells: vec![vec![TableCell { merged: Some((100, 1)), ..Default::default() }]], + ..Default::default() + }; + let mut rows: Vec<_> = (0..99).collect(); + rows.push(101); + let part = part_table(&t, &rows, Vec2::ZERO); + assert_eq!(part.cells[0][0].merged, Some((99, 1))); + } +} From 916be26ed30d1c3cf3d0d54dfa590949b6e7ce34 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 17:12:04 -0400 Subject: [PATCH 30/57] security: bound constraint-engine work and allocation (CWE-400/407) (#1851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security: bound constraint-engine work and allocation (CWE-400/407) The constraint engine let imported geometry or settings cause disproportionate CPU/memory work on the synchronous analysis thread: - Auto-constraint inference scanned the whole caller-supplied type list for every geometric candidate. Resolve the allowed type names into a set once and use O(1) membership per candidate (row 46). - Rigid-point sampling stopped after collecting points but kept traversing block members that contribute none (Text, Ray, …), repeated for every Insert. Count every examined member against a bounded budget (row 47). - Control-mode residual evaluation cloned the whole retained spline, including excess imported weights, for each free parameter. Drop weights that do not match the control-point count before retaining the template (row 45). - The DOF analysis budget estimated only matrix-rank work, ignoring the spline reconstruction each tangent residual performs during numerical differentiation. Add a residual-evaluation budget (rows × free × per-residual cost, including a spline-reconstruction term) enforced before building the Jacobian (row 43). All constraints tests pass; clippy adds no new warnings. Addresses findings rows 43, 45, 46, 47. * fix: budget constraint work without changing spline weights --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- crates/constraints/src/auto.rs | 6 +- crates/constraints/src/dof.rs | 49 +++++++++++++- crates/constraints/src/lib.rs | 12 +++- crates/constraints/src/model.rs | 100 ++++++++++++++++++++++++++++- crates/constraints/src/residual.rs | 9 +++ crates/constraints/tests/dof.rs | 18 ++++++ 6 files changed, 186 insertions(+), 8 deletions(-) diff --git a/crates/constraints/src/auto.rs b/crates/constraints/src/auto.rs index d33185c60..fe7764205 100644 --- a/crates/constraints/src/auto.rs +++ b/crates/constraints/src/auto.rs @@ -102,7 +102,11 @@ fn ang_between(d1: Vec2, d2: Vec2) -> f64 { pub fn infer(d: &Drawing, handles: &[Handle], o: &InferOptions) -> Vec { let tol = if o.distance_tolerance.is_finite() { o.distance_tolerance.clamp(0.0, 1e9) } else { 0.05 }; let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 1f64.to_radians() }; - let allow = |k: ConstraintKind| o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(k.name())); + // Resolve the allowed type names into a set once, so membership is O(1) per candidate instead + // of re-scanning the whole (attacker-controllable) `o.types` list for every geometric match + // (CWE-407). Empty `o.types` means "all types". + let allowed: std::collections::HashSet = o.types.iter().map(|t| t.to_ascii_lowercase()).collect(); + let allow = |k: ConstraintKind| allowed.is_empty() || allowed.contains(&k.name().to_ascii_lowercase()); let g = collect(d, handles); let mut out: Vec = Vec::new(); let mut push = |kind: ConstraintKind, refs: Vec| { diff --git a/crates/constraints/src/dof.rs b/crates/constraints/src/dof.rs index a3b549f96..5be5cd577 100644 --- a/crates/constraints/src/dof.rs +++ b/crates/constraints/src/dof.rs @@ -12,12 +12,15 @@ use cadcraft_doc::{Constraint, Drawing, Handle}; use serde::Serialize; use crate::model::Model; -use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build, lm}; +use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm}; /// Most free parameters for which per-object freedom is analysed (null-space basis size). const MAX_NULL_COLS: usize = 600; /// Largest Jacobian (rows × columns × min) whose rank is computed. const MAX_RANK_WORK: f64 = 4e8; +/// Largest residual-evaluation work (rows × free × per-residual cost, including spline +/// reconstruction) allowed before building the Jacobian during numerical differentiation. +const MAX_EVAL_WORK: f64 = 2e9; /// Most objects listed in one report. const MAX_OBJECTS: usize = 10_000; @@ -94,6 +97,14 @@ fn analyse(comp: &Comp) -> CompDof { if rows > lm::MAX_ROWS || (rows as f64) * (nf as f64) * (rows.min(nf) as f64) > MAX_RANK_WORK { return unknown; } + // Numerical differentiation evaluates every residual about `rows × free` times, and each + // spline-end residual reconstructs its spline through a dense solve. Account for that + // reconstruction cost before building the Jacobian so a component with few equations but large + // splines cannot pass the rank-work check yet still require billions of operations (row 43). + let eval_work = (rows as f64) * (nf as f64) * (1.0 + comp.built.iter().map(|b| b.spline_reconstruct_cost(&comp.model)).sum::()); + if eval_work > MAX_EVAL_WORK { + return unknown; + } let f = |x: &[f64], out: &mut Vec| comp.eval(x, out, None); let jac = lm::jacobian(&f, &comp.model.x0, &free, rows); if nf > MAX_NULL_COLS { @@ -126,7 +137,8 @@ fn plural(n: usize, one: &str, many: &str) -> String { /// (constrained or not) and the constraint components they belong to. pub fn dof(d: &Drawing, handles: &[Handle]) -> Result { let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect(); - let (comps, _) = build(d, &cons, &SolveOptions::default())?; + let mut members_left = crate::model::MAX_RIGID_WORK; + let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?; let want = |h: Handle| handles.is_empty() || handles.contains(&h); let mut objects = Vec::new(); let mut seen = HashSet::new(); @@ -165,7 +177,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result { if seen.contains(&h) { continue; } - let m = Model::build(d, &[h]); + let m = Model::build_budgeted(d, &[h], &mut members_left); let Some(s) = m.slots.first() else { continue }; seen.insert(h); free_objects = true; @@ -193,3 +205,34 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result { }; Ok(DofReport { dof: total, status, redundant, constraints, objects, message }) } + +#[cfg(test)] +mod budget_tests { + use super::*; + use crate::residual::{Built, Form}; + use cadcraft_doc::{Common, ConstraintKind, EntityKind, GeomRef, Space}; + use cadcraft_geom::{Spline, Vec2}; + + #[test] + fn endpoint_analysis_rejects_cubic_fit_work_before_differentiation() { + let mut d = Drawing::default(); + let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect(); + let mut spline = Spline::from_control(pts.clone(), 3); + spline.fit = pts; + let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(spline)).unwrap(); + let model = Model::build(&d, &[h]); + let endpoint = Built { + id: 1, + kind: ConstraintKind::Tangent, + refs: vec![GeomRef::whole(h)], + form: Form::EndOn { spline: 0, at_end: false }, + target: 0.0, + sign: 1.0, + rows: 2, + aux_init: Vec::new(), + aux: 0, + }; + let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], tol: 1e-9 }; + assert!(analyse(&comp).dof.is_none()); + } +} diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index 00ba351bb..338219469 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -324,12 +324,22 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec> { /// Build the component systems. Constraints that cannot be interpreted are returned as skipped. pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec, Vec<(u32, String)>), SolveError> { + let mut members_left = model::MAX_RIGID_WORK; + build_budgeted(d, cons, opts, &mut members_left) +} + +pub(crate) fn build_budgeted( + d: &Drawing, + cons: &[&Constraint], + opts: &SolveOptions, + members_left: &mut usize, +) -> Result<(Vec, Vec<(u32, String)>), SolveError> { let mut e = env(d); let mut skipped = Vec::new(); let mut comps = Vec::new(); for group in components(cons) { let handles: Vec = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect(); - let mut model = Model::build(d, &handles); + let mut model = Model::build_budgeted(d, &handles, members_left); let mut built = Vec::new(); for c in group { let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 }; diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs index d68f70670..53ace75bf 100644 --- a/crates/constraints/src/model.rs +++ b/crates/constraints/src/model.rs @@ -27,6 +27,11 @@ pub const MAX_PARAMS: usize = 4000; pub const MAX_SPLINE_POINTS: usize = 500; /// Most characteristic points of a block reference used as rigid constraint points. pub const MAX_RIGID_POINTS: usize = 256; +/// Block members examined when sampling rigid points, counting those that contribute none, so a +/// block padded with point-less entities cannot make sampling (repeated per Insert) unbounded. +const MAX_RIGID_MEMBERS: usize = 100_000; +/// Shared across every model built for one analysis request. +pub(crate) const MAX_RIGID_WORK: usize = 1_000_000; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Shape { @@ -74,6 +79,21 @@ pub(crate) struct Model { rigid: HashMap>, } +impl Model { + /// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation + /// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template. + pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 { + let Some(s) = self.splines.get(&handle) else { return 0.0 }; + let n = s.fit.len() as f64; + if fit_mode(s) { + n * n * n + } else { + let n = s.control.len() as f64; + n * n + } + } +} + /// Can this entity take part in constraints? pub fn supported(kind: &EntityKind) -> bool { matches!( @@ -93,13 +113,24 @@ pub fn supported(kind: &EntityKind) -> bool { /// centres, vertices, nested insertion points) of the block's own objects, at most /// [`MAX_RIGID_POINTS`]. Nested blocks are not entered. pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec { + let mut members_left = MAX_RIGID_MEMBERS; + rigid_points_budgeted(d, ins, &mut members_left) +} + +fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec { let Some(block) = d.block(&ins.block) else { return Vec::new() }; let m = ins.transform(block.base.xy()); let mut out = Vec::new(); + // Bound the members examined, not only the points collected: entities that contribute no + // points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for + // every Insert — would otherwise be unbounded (CWE-407). + let mut examined = 0usize; for e in block.entities.iter() { - if out.len() >= MAX_RIGID_POINTS { + examined += 1; + if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 { break; } + *members_left -= 1; let pts: Vec = match &e.kind { EntityKind::Line(l) => vec![l.a.xy(), l.b.xy()], EntityKind::Arc(a) => { @@ -152,6 +183,11 @@ fn fit_mode(s: &GSpline) -> bool { impl Model { /// Build a model for the given entities. Unsupported or missing entities are skipped. pub fn build(d: &Drawing, handles: &[Handle]) -> Model { + let mut members_left = MAX_RIGID_WORK; + Self::build_budgeted(d, handles, &mut members_left) + } + + pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model { let mut m = Model::default(); for &h in handles { if m.index.contains_key(&h) { @@ -208,12 +244,15 @@ impl Model { // fit point; a control polygon's ends are its first and last points when the // knots are clamped. let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true))); - m.splines.insert(h, sp.clone()); + // Missing weights default to 1 in eval; only excess weights are unused. + let mut kept = sp.clone(); + kept.weights.truncate(kept.control.len()); + m.splines.insert(h, kept); Shape::Spline { n, fit, ends } } EntityKind::Insert(ins) => { let base = Vec2::new(ins.insert.x, ins.insert.y); - let pts: Vec = rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect(); + let pts: Vec = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect(); m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]); let n = pts.len(); m.rigid.insert(h, pts); @@ -573,3 +612,58 @@ impl Model { moved } } + +#[cfg(test)] +mod budget_tests { + use super::*; + use cadcraft_doc::{Block, Common, Entity, Line, Space}; + use cadcraft_geom::Vec3; + use std::sync::Arc; + + #[test] + fn write_back_keeps_partial_rational_weights() { + let mut drawing = Drawing::default(); + let mut spline = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0)], 2); + spline.weights = vec![2.0, 0.5]; + let h = drawing.add(&Space::Model, Common::default(), EntityKind::Spline(spline.clone())).unwrap(); + let model = Model::build(&drawing, &[h]); + model.write_back(&mut drawing, &model.x0); + let EntityKind::Spline(after) = &drawing.entity(h).unwrap().kind else { panic!("spline") }; + assert_eq!(after.weights, spline.weights); + for i in 0..=10 { + assert!(after.eval(i as f64 / 10.0).near(spline.eval(i as f64 / 10.0), 1e-12)); + } + } + + #[test] + fn rigid_sampling_budget_is_shared_between_models() { + let mut d = Drawing::default(); + let mut block = Block::new("BAR"); + for _ in 0..3 { + block.entities.push(Entity { + handle: d.new_handle(), + common: Common::default(), + kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }), + }); + } + d.blocks.insert("BAR".into(), Arc::new(block)); + let ins = Insert { + block: "BAR".into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); + let mut budget = 2; + let first = Model::build_budgeted(&d, &[h], &mut budget); + assert_eq!(first.rigid[&h].len(), 4); + assert_eq!(budget, 0); + let next = Model::build_budgeted(&d, &[h], &mut budget); + assert!(next.rigid[&h].is_empty()); + } +} diff --git a/crates/constraints/src/residual.rs b/crates/constraints/src/residual.rs index 478d03aaf..164ada073 100644 --- a/crates/constraints/src/residual.rs +++ b/crates/constraints/src/residual.rs @@ -116,6 +116,15 @@ fn r_at(c: &Constraint, i: usize) -> Option<&GeomRef> { } impl Built { + pub(crate) fn spline_reconstruct_cost(&self, m: &Model) -> f64 { + let cost = |i: usize| self.refs.get(i).map_or(0.0, |r| m.spline_reconstruct_cost(r.handle)); + match self.form { + Form::EndOn { spline, .. } => cost(spline), + Form::Join { .. } => cost(0) + cost(1), + _ => 0.0, + } + } + /// Interpret a constraint against the model at `x`. `target` is the evaluated value for /// dimensional constraints (degrees for angular). Errors explain what references are needed. pub fn new(c: &Constraint, m: &Model, x: &[f64], target: f64) -> Result { diff --git a/crates/constraints/tests/dof.rs b/crates/constraints/tests/dof.rs index 074d8afe0..f863d9b8f 100644 --- a/crates/constraints/tests/dof.rs +++ b/crates/constraints/tests/dof.rs @@ -363,3 +363,21 @@ fn hostile_new_geometry_never_panics() { let _ = dof(&d, &hs); let _ = dof(&d, &[Handle(0), Handle(u64::MAX)]); } + +#[test] +fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() { + let mut d = Drawing::default(); + let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect(); + let mut sp = GSpline::from_control(pts.clone(), 3); + sp.fit = pts; + let h = put(&mut d, EntityKind::Spline(sp)); + for i in 0..5 { + let p = point(&mut d, (i as f64, (i % 3) as f64)); + let mut c = con(K::Coincident, &[s(h, Sub::Vertex(i)), w(p)]); + c.id = i + 1; + d.constraints.push(c); + } + let report = dof(&d, &[]).unwrap(); + assert_eq!(report.status, DofStatus::Under); + assert_eq!(report.dof, Some(1000)); +} From ca578a8fb7d065e8a06c18b2dba6effea47488c3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 17:35:18 -0400 Subject: [PATCH 31/57] security: authenticate the local control server with a per-session token (CWE-306) (#1844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security: authenticate the local control server with a per-session token (CWE-306) The optional `--control PORT` server listens on loopback TCP and dispatched any JSON request without authenticating the peer. Loopback TCP does not distinguish operating-system users, so another local account could connect and drive the desktop user's command and file authority (for example reading files through native Lisp, or create/truncate writes). Authenticate the peer with an unguessable per-session token: - New `cadcraft_mcp::control_token` module: `issue(port)` generates a 256-bit token and writes it to `cadcraft-control-.token` in the per-user runtime directory with owner-only permissions (0600 on Unix; per-user LOCALAPPDATA on Windows). `read(port)` reads it; `matches` compares in constant time. - The control server issues the token at startup and requires every request to carry a matching `token` field; requests without one get an `unauthorized` reply and the connection is closed before dispatch. - The MCP bridge (`Remote`) reads the token from the owner-only file — which needs the desktop user's own filesystem access — and sends it on every request, so the bridge keeps working unchanged for the legitimate user. A different local user cannot read the token and cannot authenticate. Updates `docs/control-protocol.md`. New tests cover token issue/read/compare, file permissions, and that the server refuses unauthenticated requests while accepting authenticated ones. Addresses finding row 48. * fix: recover control authentication across server restarts --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- Cargo.lock | 2 + apps/cadcraft/Cargo.toml | 2 + apps/cadcraft/src/control_server.rs | 86 +++++++++++-- crates/mcp/Cargo.toml | 2 + crates/mcp/src/backend.rs | 119 ++++++++++++++++-- crates/mcp/src/control_token.rs | 180 ++++++++++++++++++++++++++++ crates/mcp/src/lib.rs | 1 + docs/control-protocol.md | 10 +- 8 files changed, 376 insertions(+), 26 deletions(-) create mode 100644 crates/mcp/src/control_token.rs diff --git a/Cargo.lock b/Cargo.lock index 4fc46f3c2..0e7f50974 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -551,6 +551,7 @@ version = "0.11.0" dependencies = [ "cadcraft-engine", "cadcraft-io", + "cadcraft-mcp", "cadcraft-ui-egui", "chrono", "eframe", @@ -687,6 +688,7 @@ dependencies = [ "cadcraft-engine", "cadcraft-io", "cadcraft-render", + "getrandom 0.3.4", "log", "serde_json", ] diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index 886eac1ec..7a46fa9bc 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -14,6 +14,8 @@ workspace = true cadcraft-engine = { workspace = true } cadcraft-ui-egui = { workspace = true } cadcraft-io = { workspace = true } +# For the shared per-session control-token helper (control_server.rs authentication). +cadcraft-mcp = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] } egui = { workspace = true } serde_json = { workspace = true } diff --git a/apps/cadcraft/src/control_server.rs b/apps/cadcraft/src/control_server.rs index e678762cc..d11671e11 100644 --- a/apps/cadcraft/src/control_server.rs +++ b/apps/cadcraft/src/control_server.rs @@ -51,8 +51,21 @@ impl Default for Limits { pub fn start(port: u16, ctx: egui::Context) -> Receiver { match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => { - log::info!("control server listening on 127.0.0.1:{port}"); - serve_listener(l, Limits::default(), ctx) + // Loopback TCP cannot tell local users apart, so issue an unguessable per-session + // token in an owner-only file and require it on every request (CWE-306). A client + // (the MCP bridge) reads it from the file, which needs the desktop user's filesystem + // access; another local account cannot. + let token = match cadcraft_mcp::control_token::issue(port) { + Ok(t) => Arc::new(t), + Err(e) => { + log::error!("control server not started: could not write the control token: {e}"); + return sync_channel::(0).1; + } + }; + if let Ok(path) = cadcraft_mcp::control_token::token_path(port) { + log::info!("control server listening on 127.0.0.1:{port} (token in {})", path.display()); + } + serve_listener(l, Limits::default(), ctx, token) } Err(e) => { log::error!("control server failed to bind 127.0.0.1:{port}: {e}"); @@ -72,9 +85,9 @@ fn accepted(incoming: impl Iterator>, mut wait: imp } /// Accept connections on `listener` under `limits`; requests arrive on the returned receiver. -fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver { +fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context, token: Arc) -> Receiver { let (tx, rx) = sync_channel::(limits.queue.max(1)); - std::thread::spawn(move || accept_loop(listener, tx, ctx, limits)); + std::thread::spawn(move || accept_loop(listener, tx, ctx, limits, token)); rx } @@ -88,7 +101,7 @@ impl Drop for Slot { } } -fn accept_loop(listener: TcpListener, tx: SyncSender, ctx: egui::Context, limits: Limits) { +fn accept_loop(listener: TcpListener, tx: SyncSender, ctx: egui::Context, limits: Limits, token: Arc) { let open = Arc::new(AtomicUsize::new(0)); for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) { let _ = stream.set_write_timeout(Some(limits.write_timeout)); @@ -100,9 +113,10 @@ fn accept_loop(listener: TcpListener, tx: SyncSender, ctx: egui: let slot = Slot(Arc::clone(&open)); let tx = tx.clone(); let ctx = ctx.clone(); + let token = Arc::clone(&token); let _ = std::thread::Builder::new().spawn(move || { let _slot = slot; - serve(stream, &tx, &ctx, limits); + serve(stream, &tx, &ctx, limits, &token); }); } } @@ -141,6 +155,7 @@ enum Line { id: Value, method: String, params: Value, + token: Option, }, Blank, /// Not a request: reply with this error and close the connection. @@ -172,6 +187,7 @@ fn parse_line(bytes: &[u8]) -> Line { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})), + token: obj.get("token").and_then(Value::as_str).map(str::to_string), } } @@ -182,7 +198,7 @@ fn is_http_request_line(text: &str) -> bool { || text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/")) } -fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context, limits: Limits) { +fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context, limits: Limits, token: &str) { if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { return; } @@ -200,7 +216,18 @@ fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context Ok(true) => match parse_line(&buf) { Line::Blank => continue, Line::Reject(e) => e, - Line::Request { id, method, params } => { + Line::Request { id, method, params, token: given } => { + // Authenticate the peer before dispatching: loopback TCP does not identify the + // OS user, so only a client holding the owner-only session token may drive the + // desktop user's commands (CWE-306). + if !given.map(|g| cadcraft_mcp::control_token::matches(token, &g)).unwrap_or(false) { + let mut r = json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"}); + if let Some(o) = r.as_object_mut() { + o.insert("id".into(), id); + } + let _ = writeln!(out, "{r}"); + break; + } let (req, pending) = ControlRequest::new(method, params); let mut r = match tx.try_send(req) { Ok(()) => { @@ -277,11 +304,14 @@ mod tests { } } + /// A fixed token for tests; real tokens are random (see `cadcraft_mcp::control_token`). + const TEST_TOKEN: &str = "test-control-token"; + /// The control server on a loopback port under `limits`; requests arrive on the receiver. fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver) { let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let addr = listener.local_addr().unwrap(); - (addr, serve_listener(listener, limits, egui::Context::default())) + (addr, serve_listener(listener, limits, egui::Context::default(), Arc::new(TEST_TOKEN.to_string()))) } /// The control server on a loopback port, with a stand-in UI that echoes each method. @@ -295,6 +325,11 @@ mod tests { addr } + /// A request line carrying the test token, so it authenticates. + fn req_line(id: i64, method: &str) -> String { + json!({"id": id, "method": method, "token": TEST_TOKEN}).to_string() + "\n" + } + /// Send `input` on a new connection and read reply lines until the server closes it. fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec { let mut c = TcpStream::connect(addr).unwrap(); @@ -354,12 +389,12 @@ mod tests { fn a_full_queue_answers_busy() { let (addr, rx) = serve_with(short()); // No UI drains the one-place queue: the first request fills it, the second finds it full. - let first = std::thread::spawn(move || exchange(addr, b"{\"id\":1,\"method\":\"m\"}\n")); + let first = std::thread::spawn(move || exchange(addr, req_line(1, "m").as_bytes())); let deadline = std::time::Instant::now() + Duration::from_secs(4); let busy = loop { let mut c = TcpStream::connect(addr).unwrap(); c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); - c.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap(); + c.write_all(req_line(2, "m").as_bytes()).unwrap(); let mut line = String::new(); BufReader::new(c).read_line(&mut line).unwrap(); let v: Value = serde_json::from_str(&line).unwrap(); @@ -377,7 +412,8 @@ mod tests { let addr = serve_echo(); let mut c = TcpStream::connect(addr).unwrap(); c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); - c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap(); + let input = format!("{}\n{}", req_line(3, "ping").trim_end(), req_line(4, "pong")); + c.write_all(input.as_bytes()).unwrap(); let mut r = BufReader::new(c); for (id, method) in [(3, "ping"), (4, "pong")] { let mut line = String::new(); @@ -387,6 +423,32 @@ mod tests { } } + #[test] + fn a_request_without_the_token_is_unauthorized() { + // A local peer that does not hold the session token cannot drive the app (CWE-306). + let addr = serve_echo(); + let replies = exchange(addr, b"{\"id\":9,\"method\":\"ping\"}\n"); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert_eq!(replies[0]["ok"], false); + assert!(replies[0]["error"].as_str().unwrap().contains("unauthorized"), "{replies:?}"); + // A wrong token is also refused. + let wrong = exchange(addr, b"{\"id\":9,\"method\":\"ping\",\"token\":\"nope\"}\n"); + assert_eq!(wrong[0]["ok"], false); + assert!(wrong[0]["error"].as_str().unwrap().contains("unauthorized"), "{wrong:?}"); + } + + #[test] + fn a_request_with_the_token_is_dispatched() { + let addr = serve_echo(); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(req_line(10, "ping").as_bytes()).unwrap(); + let mut line = String::new(); + BufReader::new(c).read_line(&mut line).unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!((v["ok"].as_bool(), v["method"].as_str()), (Some(true), Some("ping")), "{line}"); + } + #[test] fn extra_connections_are_refused() { let addr = serve_echo(); diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 9bfc18e2c..c44529554 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -14,3 +14,5 @@ cadcraft-render = { workspace = true } cadcraft-io = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +# Secure randomness for the per-session control token (MIT OR Apache-2.0). +getrandom = "0.3" diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index cbde5c7b8..c758bac97 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -23,6 +23,14 @@ pub struct Remote { next_id: u64, /// Open the app when nothing answers (see [`Remote::lazy`]). launch: bool, + /// The per-session control token for this port, read from the owner-only token file on first + /// use so requests can authenticate (CWE-306). + token: Option, +} + +/// The port component of a `HOST:PORT` address, for locating the token file. +fn port_of(addr: &str) -> Option { + addr.rsplit(':').next().and_then(|p| p.parse::().ok()) } /// How long a launched app has to open its control port. @@ -30,7 +38,7 @@ const LAUNCH_WAIT: Duration = Duration::from_secs(30); impl Remote { pub fn connect(addr: &str) -> std::io::Result { - let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false }; + let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false, token: None }; r.reconnect()?; Ok(r) } @@ -38,7 +46,20 @@ impl Remote { /// the server before the app is open. With `launch`, a call that finds nothing at `addr` /// opens the app with that control port first (macOS) and waits for it. pub fn lazy(addr: &str, launch: bool) -> Self { - Self { addr: addr.to_string(), conn: None, next_id: 1, launch } + Self { addr: addr.to_string(), conn: None, next_id: 1, launch, token: None } + } + + /// The per-session token for this port, read (and cached) from the owner-only token file. A + /// missing file is not fatal here: the request is sent without a token and the server replies + /// with an authentication error the user can act on. + fn token(&mut self) -> Option<&str> { + if self.token.is_none() + && let Some(port) = port_of(&self.addr) + && let Ok(t) = crate::control_token::read(port) + { + self.token = Some(t); + } + self.token.as_deref() } /// Connect, opening the app first when asked to and nothing answers. fn reach(&mut self) -> std::io::Result<()> { @@ -91,7 +112,9 @@ impl Remote { let sent = writer.write_all(line.as_bytes()).and_then(|()| writer.write_all(b"\n")).and_then(|()| writer.flush()); if let Err(e) = sent { self.conn = None; - return Err((e, false)); + // write_all can fail after partial delivery. The server may have received the full + // JSON before the newline write failed, so a mutation must not be retried blindly. + return Err((e, true)); } let mut reply = String::new(); match reader.read_line(&mut reply) { @@ -162,17 +185,35 @@ impl Backend for Remote { fn call(&mut self, method: &str, params: Value) -> Result { let id = self.next_id; self.next_id += 1; - let line = json!({"id": id, "method": method, "params": params}).to_string(); - let reply = match self.roundtrip(&line) { + let token = self.token().map(str::to_string); + let mut req = json!({"id": id, "method": method, "params": params}); + if let (Some(obj), Some(token)) = (req.as_object_mut(), token) { + obj.insert("token".into(), Value::String(token)); + } + let reply = match self.roundtrip(&req.to_string()) { Ok(r) => r, - // Resend only when the request cannot have been applied. After a sent mutation with no reply the outcome is unknown, and - // resending could apply it twice (for example two circles from one call), so report that instead. - Err((_, sent)) if !sent || is_read_only(method) => self.roundtrip(&line).map_err(|(e, _)| format!("CADCraft at {}: {e}", self.addr))?, - Err((e, _)) => { - return Err(format!( - "CADCraft at {}: {e}; the reply was lost, so `{method}` may or may not have been applied. Inspect the drawing first", - self.addr - )); + Err((e, sent)) => { + self.token = None; + self.conn = None; + // A mutation whose reply was lost must never be sent twice. + if sent && !is_read_only(method) { + return Err(format!( + "CADCraft at {}: {e}; the reply was lost, so `{method}` may or may not have been applied. Inspect the drawing first", + self.addr + )); + } + // A restarted server may have rotated its token before dropping the old socket. + if let Some(obj) = req.as_object_mut() { + obj.remove("token"); + if let Some(token) = self.token() { + obj.insert("token".into(), Value::String(token.to_owned())); + } + } + self.roundtrip(&req.to_string()).map_err(|(e, _)| { + self.token = None; + self.conn = None; + format!("CADCraft at {}: {e}", self.addr) + })? } }; let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply: {e}"))?; @@ -180,6 +221,10 @@ impl Backend for Remote { Ok(v.get("result").cloned().unwrap_or(Value::Null)) } else { let e = v.get("error").and_then(Value::as_str).unwrap_or("error"); + if e.starts_with("unauthorized") { + self.token = None; + self.conn = None; + } // A timeout says whether the request can still have run (#345). Err(match v.get("state").and_then(Value::as_str) { Some("not-run") => format!("{e}: CADCraft did not start `{method}` and won't; it is safe to send again"), @@ -375,9 +420,12 @@ mod tests { requests }); let mut remote = Remote::connect(&addr).unwrap(); + remote.token = Some("stale-token".into()); let err = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err(); assert!(err.contains("may or may not have been applied"), "{err}"); assert_eq!(server.join().unwrap(), 1); + assert!(remote.token.is_none()); + assert!(remote.conn.is_none()); } /// The app's timeout reply says whether the request can still run; the error passes that on (#345). @@ -399,4 +447,49 @@ mod tests { server.join().unwrap(); } } + #[test] + fn safe_retry_rereads_a_rotated_token_after_a_lost_reply() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let old = crate::control_token::issue(addr.port()).unwrap(); + let server = std::thread::spawn(move || { + let (conn, _) = listener.accept().unwrap(); + let mut line = String::new(); + let mut reader = BufReader::new(conn); + reader.read_line(&mut line).unwrap(); + let first: Value = serde_json::from_str(&line).unwrap(); + assert!(first["token"].as_str() == Some(old.as_str())); + let fresh = crate::control_token::issue(addr.port()).unwrap(); + drop(reader); + let (mut conn, _) = listener.accept().unwrap(); + line.clear(); + BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap(); + let retry: Value = serde_json::from_str(&line).unwrap(); + assert!(retry["token"].as_str() == Some(fresh.as_str())); + writeln!(conn, "{}", json!({"ok": true, "result": "pong"})).unwrap(); + crate::control_token::revoke(addr.port()); + }); + let mut remote = Remote::connect(&addr.to_string()).unwrap(); + assert_eq!(remote.call("ping", json!({})).unwrap(), json!("pong")); + server.join().unwrap(); + } + #[test] + fn failed_mutation_write_is_not_resent() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let mut remote = Remote::connect(&addr).unwrap(); + remote.conn.as_ref().unwrap().1.shutdown(std::net::Shutdown::Write).unwrap(); + let server = std::thread::spawn(move || { + let (conn, _) = listener.accept().unwrap(); + let mut line = String::new(); + assert_eq!(BufReader::new(conn).read_line(&mut line).unwrap(), 0); + listener.set_nonblocking(true).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + listener.accept().is_ok() + }); + let error = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err(); + assert!(error.contains("may or may not have been applied")); + assert!(!server.join().unwrap(), "a failed mutation write was retried"); + assert!(remote.token.is_none()); + } } diff --git a/crates/mcp/src/control_token.rs b/crates/mcp/src/control_token.rs new file mode 100644 index 000000000..84677ae4a --- /dev/null +++ b/crates/mcp/src/control_token.rs @@ -0,0 +1,180 @@ +//! A per-session credential for the loopback control channel. +//! +//! The control server listens on `127.0.0.1`, but loopback TCP does not distinguish operating +//! system users: any local account can connect and drive the desktop user's command and file +//! authority (CWE-306). To authenticate the peer without changing the transport, the app issues an +//! unguessable token at startup and writes it to a file only the desktop user can read. A client +//! (the MCP bridge) reads the token from that file — which requires the same user's filesystem +//! access — and sends it with every request. A different local user cannot read the token, so +//! cannot authenticate. + +use std::io; +use std::path::PathBuf; + +/// Length of the token in bytes (256 bits), rendered as lowercase hex. +const TOKEN_BYTES: usize = 32; + +/// The file holding the control token for `port`, inside a per-user private directory. +/// +/// On Unix the runtime dir (`XDG_RUNTIME_DIR`, already `0700` and per-user) is preferred, else the +/// user's home; on Windows the local app-data directory. The file itself is created with +/// owner-only permissions regardless. +pub fn token_path(port: u16) -> io::Result { + Ok(private_dir()?.join(format!("cadcraft-control-{port}.token"))) +} + +#[cfg(unix)] +fn private_dir() -> io::Result { + if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) { + return Ok(PathBuf::from(d)); + } + if let Some(h) = std::env::var_os("HOME").filter(|v| !v.is_empty()) { + return Ok(PathBuf::from(h).join(".cache")); + } + Ok(std::env::temp_dir()) +} + +#[cfg(windows)] +fn private_dir() -> io::Result { + windows_private_dir(std::env::var_os("LOCALAPPDATA")) +} + +#[cfg(any(windows, test))] +fn windows_private_dir(local: Option) -> io::Result { + local + .filter(|p| !p.is_empty()) + .map(PathBuf::from) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "LOCALAPPDATA is required for a private control token")) +} + +#[cfg(not(any(unix, windows)))] +fn private_dir() -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "no private control token directory on this platform")) +} + +/// Generate a fresh token and write it to [`token_path`] with owner-only permissions, returning +/// the token. The file is replaced if it already exists. +pub fn issue(port: u16) -> io::Result { + let token = random_hex()?; + let path = token_path(port)?; + issue_at(&path, token.as_bytes())?; + Ok(token) +} + +fn issue_at(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + write_private(path, bytes) +} + +/// Read the token for `port`, trimming trailing whitespace. Used by clients (the MCP bridge). +pub fn read(port: u16) -> io::Result { + let raw = std::fs::read_to_string(token_path(port)?)?; + let token = raw.trim().to_string(); + if token.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "empty control token")); + } + Ok(token) +} + +/// Remove the token file (best effort), called when the server stops. +pub fn revoke(port: u16) { + if let Ok(path) = token_path(port) { + let _ = std::fs::remove_file(path); + } +} + +/// Constant-time comparison of two tokens, so a wrong token cannot be guessed byte by byte. +pub fn matches(expected: &str, given: &str) -> bool { + let (a, b) = (expected.as_bytes(), given.as_bytes()); + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +fn random_hex() -> io::Result { + let mut bytes = [0u8; TOKEN_BYTES]; + getrandom::fill(&mut bytes).map_err(|e| io::Error::other(format!("no secure randomness: {e}")))?; + let mut s = String::with_capacity(TOKEN_BYTES * 2); + for b in bytes { + use std::fmt::Write as _; + let _ = write!(s, "{b:02x}"); + } + Ok(s) +} + +#[cfg(unix)] +fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + // Replace any stale file, then create fresh with mode 0600 so no other user can read the token. + let _ = std::fs::remove_file(path); + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?; + f.write_all(bytes)?; + f.flush() +} + +#[cfg(not(unix))] +fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> { + use std::io::Write; + // On Windows the per-user LOCALAPPDATA directory is already ACL-protected to the user. + let _ = std::fs::remove_file(path); + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?; + f.write_all(bytes)?; + f.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn issue_then_read_round_trips() { + let port = 50000 + (std::process::id() % 10000) as u16; + let token = issue(port).unwrap(); + assert_eq!(token.len(), TOKEN_BYTES * 2); + assert_eq!(read(port).unwrap(), token); + revoke(port); + assert!(read(port).is_err()); + } + + #[test] + fn matches_is_exact() { + assert!(matches("abc", "abc")); + assert!(!matches("abc", "abd")); + assert!(!matches("abc", "ab")); + assert!(!matches("", "x")); + } + + #[cfg(unix)] + #[test] + fn token_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let port = 40000 + (std::process::id() % 10000) as u16; + issue(port).unwrap(); + let mode = std::fs::metadata(token_path(port).unwrap()).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "token file is readable by group/other: {:o}", mode); + revoke(port); + } + #[test] + fn windows_requires_local_app_data() { + assert!(windows_private_dir(None).is_err()); + assert!(windows_private_dir(Some("".into())).is_err()); + assert_eq!(windows_private_dir(Some("private-app-data".into())).unwrap(), PathBuf::from("private-app-data")); + } + + #[test] + fn issuance_creates_missing_parent() { + let dir = std::env::temp_dir().join(format!("cadcraft-token-parent-{}", std::process::id())); + let path = dir.join("missing/cache/session.token"); + issue_at(&path, b"secret").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"secret"); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 1d4d4162d..7fcf4849d 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] mod backend; +pub mod control_token; mod server; mod tools; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 56cf4d990..b1e91a52c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -5,10 +5,18 @@ Start the desktop app with `--control PORT` (or `CADCRAFT_CONTROL_PORT=PORT`). I `--control` wins over the variable; a bare `--control` keeps the variable's port. ```json -{"id": 1, "method": "cmdline.input", "params": {"text": "circle 0,0 5"}} +{"id": 1, "method": "cmdline.input", "params": {"text": "circle 0,0 5"}, "token": ""} {"id": 1, "ok": true, "result": {"prompt": "Command:", "output": ["Command: CIRCLE"], ...}} ``` +Every request must carry the session `token`. Because loopback TCP cannot tell local operating +system users apart, the app writes an unguessable per-session token to an owner-only file when the +control server starts, and refuses (`unauthorized`) any request without a matching token. The file +is `cadcraft-control-.token` in the per-user runtime directory (`$XDG_RUNTIME_DIR`, else +`~/.cache`; `%LOCALAPPDATA%` on Windows); its path is logged at startup. A client reads the token +from that file — which requires the desktop user's own filesystem access — and includes it on each +line. The MCP bridge (`cadcraft-cli mcp --connect`) does this automatically. + Errors come back as `{"id": …, "ok": false, "error": "message"}`. An unknown method's error points to `control.methods`, which lists the methods the server answers. From 8085745a224c92a3c867da6b6cc1a9f02682923b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 17:35:43 -0400 Subject: [PATCH 32/57] security: bound native DXF/SVG/PDF import allocation and quadratic work (CWE-400/407/789) (#1846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security: bound native DXF/SVG/PDF import allocation and quadratic work (CWE-400/407/789) Several native import paths let a crafted drawing cause quadratic CPU work or unbounded allocation: - DXF layer decoding scanned the whole layer list for every LAYER record (`d.layer_mut`), so many distinct layer names made import quadratic. Keep a case-normalised name→index map and update/append in O(1) (row 29). - ACAD_TABLE cells were materialised per table under a per-table cap, but many small tables could still add up. Add a document-wide table-cell budget in parse_entities; a table that would overflow it is reduced to empty (row 8). - DXF image name allocation restarted its `_2, _3…` suffix probe for every definition sharing a base name (quadratic). Keep a per-base next-suffix counter so probing resumes where it left off (row 13). - Image resolution cloned each definition's path/name into every referring image. Add an aggregate byte budget; once spent, string copies stop while geometry fields are still applied (row 42). - PDF link annotation scanned the whole selected-link vector for every primitive. Build a HashSet of linked handles once for O(1) membership (row 15). - SVG arc/curve flattening retained each flushed run with only a per-run cap, so repeated arcs amplified a compact path into unbounded geometry. Add a document-wide generated-vertex budget charged in `add` (row 31). New test `svg_arc_budget` covers the SVG budget. All `cadcraft-io` tests pass; clippy adds no new warnings. Addresses findings rows 8, 13, 15, 29, 31, 42. * fix: share DXF table budgets before allocation --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- crates/io/src/dxf_image.rs | 29 ++++++++--- crates/io/src/dxf_read.rs | 82 +++++++++++++++++++++++++++---- crates/io/src/pdf.rs | 5 +- crates/io/src/svg_read.rs | 16 ++++++ crates/io/tests/svg_arc_budget.rs | 32 ++++++++++++ 5 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 crates/io/tests/svg_arc_budget.rs diff --git a/crates/io/src/dxf_image.rs b/crates/io/src/dxf_image.rs index 1b16fe8d4..a4f57a5de 100644 --- a/crates/io/src/dxf_image.rs +++ b/crates/io/src/dxf_image.rs @@ -141,26 +141,36 @@ pub(crate) fn resolve(secs: &[Section], d: &mut Drawing) { if by_image.is_empty() { return; } - fill(&mut d.model, &by_image); + // Aggregate budget on the image-definition strings copied into referring images, so an + // attacker cannot multiply a long path/name across many references into gigabytes (CWE-400). + const MAX_RESOLVED_STRING_BYTES: usize = 64 << 20; + let mut budget = MAX_RESOLVED_STRING_BYTES; + fill(&mut d.model, &by_image, &mut budget); for l in &mut d.layouts { - fill(&mut l.entities, &by_image); + fill(&mut l.entities, &by_image, &mut budget); } for b in d.blocks.values_mut() { if b.entities.iter().any(|e| matches!(e.kind, EntityKind::Image(_)) && by_image.contains_key(&e.handle)) { - fill(&mut Arc::make_mut(b).entities, &by_image); + fill(&mut Arc::make_mut(b).entities, &by_image, &mut budget); } } } -fn fill(store: &mut EntityStore, by_image: &HashMap) { +fn fill(store: &mut EntityStore, by_image: &HashMap, budget: &mut usize) { let hs: Vec = store.iter().filter(|e| matches!(e.kind, EntityKind::Image(_)) && by_image.contains_key(&e.handle)).map(|e| e.handle).collect(); for h in hs { let Some(def) = by_image.get(&h) else { continue }; + // Charge the bytes this copy adds; once the shared budget is spent, stop copying strings + // (geometry fields are still applied) so retained memory stays bounded. + let cost = def.path.len() + def.name.len(); + let copy_strings = budget.checked_sub(cost).map(|left| *budget = left).is_some(); store.modify(h, |e| { if let EntityKind::Image(i) = &mut e.kind { - i.path = def.path.clone(); - i.name = def.name.clone(); + if copy_strings { + i.path = def.path.clone(); + i.name = def.name.clone(); + } if let Some(p) = def.pixel_size { i.pixel_size = p; } @@ -215,6 +225,10 @@ pub(crate) fn plan<'a>(entities: impl Iterator, let mut p = Plan::default(); let mut index: HashMap<(String, String), usize> = HashMap::new(); let mut used: HashSet = HashSet::new(); + // Next suffix to try for each normalised base name, so allocating a unique name resumes where + // the previous collision left off instead of re-probing `_2, _3, …` from the start — otherwise + // many images sharing a base name make name allocation quadratic (CWE-407). + let mut next_suffix: HashMap = HashMap::new(); for e in entities { let EntityKind::Image(i) = &e.kind else { continue }; if p.by_entity.contains_key(&e.handle) { @@ -227,11 +241,12 @@ pub(crate) fn plan<'a>(entities: impl Iterator, // Dictionary keys are unique regardless of case. let base = def_name(i); let mut name = base.clone(); - let mut n = 2usize; + let mut n = next_suffix.get(&key(&base)).copied().unwrap_or(2); while !used.insert(key(&name)) { name = format!("{base}_{n}"); n = n.saturating_add(1); } + next_suffix.insert(key(&base), n); p.defs.push(PlannedDef { handle: next(), name, diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 86867a368..e8839dc57 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -245,6 +245,10 @@ fn mirror_text(t: &mut Text, align: Option, insert: Vec3) { /// Parse one entity record. POLYLINE/INSERT followers are handled by the caller. fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { + entity_with_table_budget(kind, tags, &mut 4_000_000) +} + +fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usize) -> Option<(Common, EntityKind)> { // The paper-space viewport our writer adds (dxf_write::paper_view) stays implicit. if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) { return None; @@ -480,7 +484,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { } k } - "ACAD_TABLE" => EntityKind::Table(acad_table(tags)), + "ACAD_TABLE" => EntityKind::Table(acad_table(tags, table_cells_left)), "VIEWPORT" => EntityKind::Viewport(Viewport { center: t.p(10), width: t.fd(40, 1.0), @@ -525,7 +529,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { /// An ACAD_TABLE entity: sizes, cell texts and merges (DXF Reference), plus CADCraft's /// title/header flags, text height and style name from xdata when present. -fn acad_table(tags: &[Tag]) -> Table { +fn acad_table(tags: &[Tag], table_cells_left: &mut usize) -> Table { const MAX_CELLS: usize = 1_000_000; let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbTable").unwrap_or(0); let tail = tags.get(start..).unwrap_or(&[]); @@ -540,6 +544,12 @@ fn acad_table(tags: &[Tag]) -> Table { rows = 0; cols = 0; } + // Charge before any cell vector is materialized, including discarded tables. + let count = rows.saturating_mul(cols); + match table_cells_left.checked_sub(count) { + Some(left) => *table_cells_left = left, + None => return Table { insert: T(tags).p(10), ..Table::default() }, + } let size = |v: f64| if v.is_finite() && v > 0.0 { v } else { 0.27 }; let mut row_heights: Vec = head.all_f(141).into_iter().take(rows).map(size).collect(); row_heights.resize(rows, 0.27); @@ -1098,7 +1108,13 @@ fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &m /// Parse a run of entity records (ENTITIES section or a block's body) into entities. Records that /// can't be placed are counted in `skipped`. -fn parse_entities(recs: &[(String, Vec)], d: &mut Drawing, rx: &mut Rx, skipped: &mut usize) -> Vec<(Option, bool, Entity)> { +fn parse_entities( + recs: &[(String, Vec)], + d: &mut Drawing, + rx: &mut Rx, + skipped: &mut usize, + table_cells_left: &mut usize, +) -> Vec<(Option, bool, Entity)> { let mut out = Vec::new(); let mut i = 0; while let Some((kind, tags)) = recs.get(i) { @@ -1106,7 +1122,7 @@ fn parse_entities(recs: &[(String, Vec)], d: &mut Drawing, rx: &mut Rx, ski let owner = crate::dxf_ext::owner(tags); let paper = t.i(67) == Some(1); let handle = t.s(5).and_then(|h| Handle::parse_hex(&h)); - let mut kindent = entity(kind, tags); + let mut kindent = entity_with_table_budget(kind, tags, table_cells_left); if let Some((c, _)) = kindent.as_mut() && let Some(n) = entity_plot_style(tags, rx) { @@ -1420,6 +1436,10 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { } } let mut active_vport = false; + // Index of layers already appended, by lower-cased name, so each LAYER record updates or + // appends in O(1) instead of scanning the growing layer list — otherwise many distinct layer + // names make import quadratic (CWE-407). + let mut layer_index: HashMap = d.layers.iter().enumerate().map(|(i, l)| (l.name.to_ascii_lowercase(), i)).collect(); for (kind, tg) in recs { let t = T(&tg); match kind.as_str() { @@ -1455,9 +1475,16 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { if let Some(desc) = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_DESCRIPTION_APP).iter().filter(|x| x.code == 1000).nth(1) { l.description = desc.str(); } - match d.layer_mut(&name) { - Some(x) => *x = l, - None => d.layers.push(l), + match layer_index.get(&name.to_ascii_lowercase()) { + Some(&i) => { + if let Some(x) = d.layers.get_mut(i) { + *x = l; + } + } + None => { + layer_index.insert(name.to_ascii_lowercase(), d.layers.len()); + d.layers.push(l); + } } } "LTYPE" => { @@ -1651,6 +1678,10 @@ fn ltype_pattern(tags: &[Tag], styles: &HashMap) -> Vec Result { + read_budgeted(bytes, 4_000_000) +} + +fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { let tags = cadcraft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?; let secs = sections(&tags); if secs.is_empty() { @@ -1705,7 +1736,7 @@ pub fn read(bytes: &[u8]) -> Result { j += 1; } let body = recs.get(i + 1..j).unwrap_or(&[]); - let ents = parse_entities(body, &mut d, &mut rx, &mut n); + let ents = parse_entities(body, &mut d, &mut rx, &mut n, &mut table_cells_left); blocks.push((name, base, description, ents, xref)); i = j + 1; } else { @@ -1721,7 +1752,7 @@ pub fn read(bytes: &[u8]) -> Result { // A damaged file may split its entities over several ENTITIES sections (#865). "ENTITIES" => { let mut n = 0; - entities.extend(parse_entities(&records(&s.tags), &mut d, &mut rx, &mut n)); + entities.extend(parse_entities(&records(&s.tags), &mut d, &mut rx, &mut n, &mut table_cells_left)); count_skipped(&mut skipped, "ENTITIES", n); } "OBJECTS" => { @@ -2310,3 +2341,36 @@ fn helix(tags: &[Tag]) -> Helix { pub fn entity_from_groups(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { entity(&kind.to_ascii_uppercase(), tags) } + +#[cfg(test)] +mod table_budget_tests { + use super::*; + fn table_tags(rows: i64, cols: i64) -> Vec { + vec![Tag::s(100, "AcDbTable"), Tag::i(91, rows), Tag::i(92, cols), Tag::i(171, 1), Tag::s(1, "cell")] + } + #[test] + fn exhausted_budget_rejects_before_materializing_cells() { + let mut left = 3; + let table = acad_table(&table_tags(2, 2), &mut left); + assert!(table.cells.is_empty() && table.row_heights.is_empty() && table.col_widths.is_empty()); + assert_eq!(left, 3); + let table = acad_table(&table_tags(1, 2), &mut left); + assert_eq!(table.cells[0].len(), 2); + assert_eq!(left, 1); + } + #[test] + fn blocks_and_entity_sections_share_one_budget() { + let table = "0\nACAD_TABLE\n100\nAcDbTable\n91\n1\n92\n2\n171\n1\n1\ncell\n"; + let dxf = format!( + "0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nTEST\n10\n0\n20\n0\n{table}0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n{table}0\nENDSEC\n0\nSECTION\n2\nENTITIES\n{table}0\nENDSEC\n0\nEOF\n" + ); + let d = read_budgeted(dxf.as_bytes(), 2).unwrap(); + let block = d.block("TEST").unwrap(); + let count = |e: &Entity| match &e.kind { + EntityKind::Table(t) => t.cells.iter().map(Vec::len).sum::(), + _ => 0, + }; + assert_eq!(block.entities.iter().map(|e| count(e)).sum::(), 2); + assert_eq!(d.model.iter().map(|e| count(e)).sum::(), 0); + } +} diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 2a2295089..fd2742c17 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -511,9 +511,12 @@ fn links(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2, link if links.is_empty() { return Vec::new(); } + // Membership by handle, built once, so scanning primitives is O(primitives) rather than + // O(primitives × links): otherwise the two counts multiply into excessive work (CWE-407). + let linked: std::collections::HashSet = links.iter().map(|(h, _)| *h).collect(); let mut areas: std::collections::HashMap = std::collections::HashMap::new(); for p in &list.prims { - if matches!(p.kind, Kind::Infinite { .. }) || !links.iter().any(|(h, _)| *h == p.handle) { + if matches!(p.kind, Kind::Infinite { .. }) || !linked.contains(&p.handle) { continue; } let b = areas.entry(p.handle).or_insert(Bounds2::EMPTY); diff --git a/crates/io/src/svg_read.rs b/crates/io/src/svg_read.rs index f5abc84d6..8e5552168 100644 --- a/crates/io/src/svg_read.rs +++ b/crates/io/src/svg_read.rs @@ -38,6 +38,9 @@ const MAX_LAYERS: usize = 256; const MAX_TRANSFORM_DEPTH: usize = 64; /// Flattened chords per elliptical arc under a non-uniform transform. const MAX_ARC_CHORDS: usize = 10_000; +/// Generated vertices retained across the whole document (all paths and shapes), so repeated +/// arc/curve flattening cannot amplify a compact input into unbounded geometry. +const MAX_TOTAL_VERTS: usize = 8_000_000; /// Elements whose whole subtree is skipped (paint servers, text, raster images, metadata). const SKIP: &[&[u8]] = &[ @@ -126,6 +129,10 @@ struct Parser { layers: HashSet, layer_color: u8, elements: usize, + /// Generated vertices still allowed across the whole document. Arc/curve flattening can turn + /// a compact path into many samples, and each flushed run is retained, so without a shared + /// budget repeated arcs amplify a small input into gigabytes of geometry (CWE-400). + verts_left: usize, /// > 0 while inside a skipped subtree; counts the starts seen there. skip: usize, /// What was left out, by kind ([`LeftOut`]). @@ -173,6 +180,7 @@ impl Parser { layers: HashSet::from(["0".to_string()]), layer_color: 1, elements: 0, + verts_left: MAX_TOTAL_VERTS, skip: 0, left_out: BTreeMap::new(), } @@ -331,6 +339,14 @@ impl Parser { if self.elements > MAX_ELEMENTS { return Err(IoError::Format(format!("SVG: more than {MAX_ELEMENTS} elements"))); } + // Charge the vertices this entity retains against the shared document budget, so repeated + // flattened arcs/curves cannot amplify a compact path into unbounded geometry (CWE-400). + let verts = match &kind { + EntityKind::LwPolyline(p) => p.vertices.len(), + EntityKind::Spline(s) => s.control.len(), + _ => 1, + }; + self.verts_left = self.verts_left.checked_sub(verts).ok_or_else(|| IoError::Format("SVG: too much generated geometry".into()))?; let common = Common { layer: self.layer.clone(), ..Common::default() }; self.drawing.add(&Space::Model, common, kind).map(|_| ()).map_err(parse_err) } diff --git a/crates/io/tests/svg_arc_budget.rs b/crates/io/tests/svg_arc_budget.rs new file mode 100644 index 000000000..b3b096e33 --- /dev/null +++ b/crates/io/tests/svg_arc_budget.rs @@ -0,0 +1,32 @@ +//! Reading an SVG whose paths flatten into a huge number of retained vertices must be rejected by +//! the document-wide generated-geometry budget, not amplified into gigabytes (CWE-400, row 31). + +use cadcraft_io::read; + +/// A path with many elliptical-arc subpaths under a non-uniform group transform; each subpath +/// flushes to its own retained polyline, so enough of them must exceed the document vertex budget. +fn arc_bomb(arcs: usize) -> Vec { + // `scale(1 2)` makes the transform non-uniform, forcing per-sample flattening rather than a + // single bulge arc. Each `M … a …` is a separate subpath that is flushed and retained. + let mut d = String::new(); + for i in 0..arcs { + let y = i as i32; + d.push_str(&format!("M 0 {y} a 1000 500 0 1 1 1 1 ")); + } + let svg = format!(r#""#); + svg.into_bytes() +} + +#[test] +fn a_modest_arc_svg_still_reads() { + // A handful of arcs is well within budget and parses fine. + let d = read(&arc_bomb(4), "a.svg").expect("a small arc SVG should read"); + assert!(d.model.iter().count() >= 1, "expected some geometry"); +} + +#[test] +fn an_arc_heavy_svg_is_rejected_by_the_vertex_budget() { + // ~1000 arcs x up to 10_000 chords each would far exceed the 8M vertex budget. + let err = read(&arc_bomb(30000), "a.svg"); + assert!(err.unwrap_err().to_string().contains("SVG: too much generated geometry")); +} From 953a6b3b01d3ea2e7490ed8ea9cfb4c803d32ac8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 17:57:09 -0400 Subject: [PATCH 33/57] security: bound Lisp callbacks, native comparisons, and local file reads (CWE-400, CWE-407) (#1842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security: bound Lisp callbacks, native comparisons, and local file reads (CWE-400, CWE-407) The command and scripting engine had several unbounded paths a supplied LISP routine or a selected path could exploit to monopolise the thread or exhaust memory: - `call_sync` ran synchronous callbacks (vl-sort comparators, vl-remove-if predicates, …) with `u64::MAX` fuel, so a constant-memory infinite loop never yielded and bypassed the blocking execution cap. It now drives the callback in bounded slices and aborts with an error past a hard step cap (row 2). - `wcmatch`/`glob` explored `*` placements with only a recursion-depth guard, and `equal` re-traversed shared cons subtrees. Both now charge every step against a shared work budget and stop when it is exhausted (row 33). - `setvar` converted a LISP value to JSON unboundedly, so a shared cons DAG expanded into gigabytes of owned arrays. Conversion is now depth- and element-bounded and rejects over-budget values with a LISP error (row 39). - OPEN/LOAD/APPLOAD (`read_text_file`), SCRIPT, and native open (`read_file`, `run_open`) buffered a path with `std::fs::read`, so an endless device exhausted memory before any size check. Reads now go through a new `capped_read` helper that rejects non-regular files and caps bytes (rows 21, 34). New tests cover the callback loop, the oversized setvar structure, and the capped reader. Addresses findings rows 2, 21, 33, 34, 39. * fix: bound native Lisp scans and nonblocking file reads * fix: enforce callback fuel before each execution slice --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- Cargo.lock | 1 + crates/engine/Cargo.toml | 3 + crates/engine/src/capped_read.rs | 81 ++++++++++++++++ crates/engine/src/cmd/file.rs | 18 +++- crates/engine/src/lib.rs | 2 + crates/engine/src/lisp/builtins.rs | 146 ++++++++++++++++++++++++----- crates/engine/src/lisp/drawing.rs | 32 +++++-- crates/engine/src/lisp/eval.rs | 30 +++++- crates/engine/src/lisp/mod.rs | 4 +- crates/engine/src/lisp/tests.rs | 58 ++++++++++++ crates/engine/src/lisp/value.rs | 19 +++- 11 files changed, 350 insertions(+), 44 deletions(-) create mode 100644 crates/engine/src/capped_read.rs diff --git a/Cargo.lock b/Cargo.lock index 0e7f50974..6ed706014 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -637,6 +637,7 @@ dependencies = [ "cadcraft-io", "cadcraft-render", "craft-docpath", + "libc", "log", "rstar", "serde", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index a19da90f9..04be29199 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -24,6 +24,9 @@ log = { workspace = true } # R-tree spatial index for picking, window selection and object snaps (MIT OR Apache-2.0). rstar = { version = "0.12", default-features = false } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] # Real DXF files for the external reference tests (io is a lower layer). cadcraft-io = { workspace = true } diff --git a/crates/engine/src/capped_read.rs b/crates/engine/src/capped_read.rs new file mode 100644 index 000000000..d82344bb6 --- /dev/null +++ b/crates/engine/src/capped_read.rs @@ -0,0 +1,81 @@ +//! Bounded reads of local files. +//! +//! Reading a path with [`std::fs::read`] buffers until EOF, so a path naming an endless readable +//! device (for example `/dev/urandom` or a FIFO) exhausts process memory before any size check +//! runs (CWE-400). [`read_capped`] opens the path once, refuses anything that is not a regular +//! file, and reads through a reader limited to `max + 1` bytes so oversized input is rejected +//! before it is fully buffered. + +#![cfg(not(target_arch = "wasm32"))] + +use std::fs::OpenOptions; +use std::io::{self, Read}; +use std::path::Path; + +/// Read at most `max` bytes from the regular file at `path`. +/// +/// Returns an error if the path is not a regular file, cannot be opened, or holds more than `max` +/// bytes. At most `max + 1` bytes are ever buffered, so an endless device cannot exhaust memory. +pub fn read_capped(path: &Path, max: usize) -> io::Result> { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK); + } + let file = options.open(path)?; + let meta = file.metadata()?; + if !meta.is_file() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "not a regular file")); + } + // Read one byte past the limit so we can tell "exactly max" from "too big" without trusting + // the metadata length (which a device may misreport). + let mut buf = Vec::new(); + let cap = (max as u64).saturating_add(1); + let read = file.take(cap).read_to_end(&mut buf)?; + if read as u64 > max as u64 { + return Err(io::Error::new(io::ErrorKind::InvalidData, format!("file is larger than the {max}-byte limit"))); + } + Ok(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_within_limit_and_rejects_oversize() { + let dir = std::env::temp_dir().join(format!("cadcraft-cr-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("f.bin"); + std::fs::write(&p, vec![7u8; 100]).unwrap(); + assert_eq!(read_capped(&p, 100).unwrap().len(), 100); + assert_eq!(read_capped(&p, 200).unwrap().len(), 100); + assert!(read_capped(&p, 99).is_err()); + assert_eq!(read_capped(&p, usize::MAX).unwrap().len(), 100); + std::fs::remove_dir_all(&dir).ok(); + } + + #[cfg(unix)] + #[test] + fn rejects_non_regular_file() { + // /dev/zero is an endless readable device; it must be rejected, not buffered. + let p = Path::new("/dev/zero"); + if p.exists() { + assert!(read_capped(p, 8 << 20).is_err()); + } + } + #[cfg(unix)] + #[test] + fn rejects_fifo_without_a_writer() { + let dir = std::env::temp_dir().join(format!("cadcraft-cr-fifo-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let fifo = dir.join("pipe"); + assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success()); + let started = std::time::Instant::now(); + assert_eq!(read_capped(&fifo, 1024).unwrap_err().kind(), io::ErrorKind::InvalidInput); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 062840690..96b86f4eb 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -19,6 +19,15 @@ pub use pgp::{MAX_ALIASES, alias_name, command_id}; pub(crate) use pgp::{specs as alias_specs, user_alias}; pub use template::{qnew_template, set_qnew_template}; +/// Largest drawing accepted from a local path, matching the per-file xref limit. A path naming an +/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read +/// through [`crate::capped_read`] rather than buffering to EOF. +#[cfg(not(target_arch = "wasm32"))] +const MAX_DRAWING_BYTES: usize = 512 << 20; +/// Largest `.scr` script accepted from a local path. +#[cfg(not(target_arch = "wasm32"))] +const MAX_SCRIPT_BYTES: usize = 64 << 20; + /// Plot a space to PDF bytes with JSON options (`{paper?, landscape?, fit?, lineweights?, …}`). pub type PlotHook = fn(&Drawing, &cadcraft_doc::Space, &Value) -> std::result::Result, String>; @@ -192,7 +201,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result { let path = _path; #[cfg(not(target_arch = "wasm32"))] { - let bytes = std::fs::read(path).map_err(|e| bad("script", format!("{path}: {e}")))?; + let bytes = + crate::capped_read::read_capped(std::path::Path::new(path), MAX_SCRIPT_BYTES).map_err(|e| bad("script", format!("{path}: {e}")))?; String::from_utf8_lossy(&bytes).into_owned() } #[cfg(target_arch = "wasm32")] @@ -265,7 +275,8 @@ pub fn read_file(path: &str) -> std::result::Result { #[cfg(not(target_arch = "wasm32"))] { let r = std::panic::catch_unwind(|| { - let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?; + let bytes = + crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?; read_bytes(&bytes, path, Some(path.to_string())) }); match r { @@ -332,7 +343,8 @@ fn run_open(s: &mut Session, p: &Value) -> Result { let path = _path; #[cfg(not(target_arch = "wasm32"))] { - let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?; + let bytes = + crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?; let i = open_bytes(s, &bytes, path, Some(path.to_string()))?; super::blocks::xref::after_open(s, i); return Ok(opened(s, i, true)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a4ac9aafd..1bf8e0059 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,8 @@ pub mod atomic_write; pub mod audit; pub mod autosave; pub mod blockedit; +#[cfg(not(target_arch = "wasm32"))] +pub mod capped_read; pub mod cmd; pub mod doc_path; mod finite; diff --git a/crates/engine/src/lisp/builtins.rs b/crates/engine/src/lisp/builtins.rs index 73aea3635..9ef90f3b6 100644 --- a/crates/engine/src/lisp/builtins.rs +++ b/crates/engine/src/lisp/builtins.rs @@ -679,19 +679,44 @@ fn wcmatch(a: &[Value]) -> R { arity(a, 2, 2)?; let (s, pat) = (string(&a[0])?, string(&a[1])?); let text: Vec = s.chars().collect(); - Ok(Value::bool(split_patterns(&pat).iter().any(|p| match_pattern(&text, p)))) + // Native wildcard matching runs as a single interpreter step, so its internal `*` backtracking + // must not explore exponentially many placements unchecked (CWE-400). Charge every glob step + // against a shared budget and fail the call if it is exhausted. + let mut budget: u64 = WCMATCH_BUDGET; + for p in split_patterns(&pat, &mut budget).map_err(|()| LispError::new("wcmatch: pattern too complex"))? { + match match_pattern(&text, &p, &mut budget) { + Ok(true) => return Ok(Value::bool(true)), + Ok(false) => {} + Err(()) => return Err(LispError::new("wcmatch: pattern too complex")), + } + } + Ok(Value::bool(false)) } -fn split_patterns(pat: &str) -> Vec> { +/// Shared step budget for one `wcmatch` call's wildcard matching. Large enough for the patterns +/// AutoLISP routines legitimately use (including the pathological-but-bounded cases the tests +/// cover), but finite so truly exponential `*`-heavy patterns are rejected rather than hanging. +const WCMATCH_BUDGET: u64 = 200_000_000; + +fn charge_match(budget: &mut u64) -> Result<(), ()> { + *budget = budget.checked_sub(1).ok_or(())?; + Ok(()) +} + +fn split_patterns(pat: &str, budget: &mut u64) -> Result>, ()> { let mut out = vec![Vec::new()]; let mut chars = pat.chars().peekable(); let mut in_set = false; while let Some(c) = chars.next() { + charge_match(budget)?; match c { '`' => { if let Some(cur) = out.last_mut() { cur.push('`'); - cur.extend(chars.next()); + if let Some(escaped) = chars.next() { + charge_match(budget)?; + cur.push(escaped); + } } } '[' => { @@ -714,41 +739,93 @@ fn split_patterns(pat: &str) -> Vec> { } } } - out + Ok(out) } -fn match_pattern(text: &[char], pat: &[char]) -> bool { +fn match_pattern(text: &[char], pat: &[char], budget: &mut u64) -> Result { if let Some(('~', rest)) = pat.split_first().map(|(c, r)| (*c, r)) && !rest.is_empty() { - return !glob(text, rest, 0); + return glob(text, rest, 0, budget).map(|m| !m); } - glob(text, pat, 0) + glob(text, pat, 0, budget) } -/// Iterative-enough glob with `*` backtracking; `depth` bounds the recursion on `*`. -fn glob(text: &[char], pat: &[char], depth: usize) -> bool { +/// Iterative-enough glob with `*` backtracking; `depth` bounds the recursion on `*`. Every step is +/// charged against `budget`; `Err(())` means the shared budget ran out (treated as too complex). +fn glob(text: &[char], pat: &[char], depth: usize, budget: &mut u64) -> Result { + glob_cached(text, pat, depth, budget, &mut std::collections::HashSet::new()) +} + +fn glob_cached( + text: &[char], + pat: &[char], + depth: usize, + budget: &mut u64, + failed: &mut std::collections::HashSet<(usize, usize, usize)>, +) -> Result { + charge_match(budget)?; + let key = (text.len(), pat.len(), depth); + if failed.contains(&key) { + return Ok(false); + } + let matched = glob_inner(text, pat, depth, budget, failed)?; + // Avoid revisiting the same failed suffix exponentially. Bound the memo table independently + // of the step limit so a very large pattern cannot turn the optimization into an allocation bomb. + if !matched && failed.len() < 100_000 { + failed.insert(key); + } + Ok(matched) +} + +fn glob_inner( + text: &[char], + pat: &[char], + depth: usize, + budget: &mut u64, + failed: &mut std::collections::HashSet<(usize, usize, usize)>, +) -> Result { + if *budget == 0 { + return Err(()); + } + *budget -= 1; if depth > 64 { - return false; + return Ok(false); } let (mut t, mut p) = (0usize, 0usize); while p < pat.len() { + charge_match(budget)?; match pat[p] { '*' => { let rest = &pat[p + 1..]; - return (t..=text.len()).any(|i| glob(&text[i..], rest, depth + 1)); + for i in t..=text.len() { + if glob_cached(&text[i..], rest, depth + 1, budget, failed)? { + return Ok(true); + } + } + return Ok(false); } '`' => { - let Some(&lit) = pat.get(p + 1) else { return false }; + let Some(&lit) = pat.get(p + 1) else { return Ok(false) }; if text.get(t) != Some(&lit) { - return false; + return Ok(false); } t += 1; p += 2; } '[' => { - let Some(close) = pat[p..].iter().position(|c| *c == ']').map(|i| p + i) else { return false }; - let Some(&c) = text.get(t) else { return false }; + let mut close = p; + while let Some(c) = pat.get(close) { + charge_match(budget)?; + if *c == ']' { + break; + } + close += 1; + } + if close == pat.len() { + return Ok(false); + } + let Some(&c) = text.get(t) else { return Ok(false) }; let set = &pat[p + 1..close]; let (neg, set) = match set.split_first() { Some(('~', r)) if !r.is_empty() => (true, r), @@ -757,6 +834,7 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool { let mut hit = false; let mut i = 0; while i < set.len() { + charge_match(budget)?; if set.get(i + 1) == Some(&'-') && i + 2 < set.len() { if set[i] <= c && c <= set[i + 2] { hit = true; @@ -770,13 +848,13 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool { } } if hit == neg { - return false; + return Ok(false); } t += 1; p = close + 1; } pc => { - let Some(&c) = text.get(t) else { return false }; + let Some(&c) = text.get(t) else { return Ok(false) }; let ok = match pc { '#' => c.is_ascii_digit(), '@' => c.is_alphabetic(), @@ -785,14 +863,14 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool { other => other == c, }; if !ok { - return false; + return Ok(false); } t += 1; p += 1; } } } - t == text.len() + Ok(t == text.len()) } // ---------------- geometry ---------------- @@ -984,11 +1062,10 @@ fn findfile(a: &[Value]) -> R { #[cfg(not(target_arch = "wasm32"))] pub fn read_text_file(path: &str) -> Option { - let bytes = std::fs::read(path).ok()?; - // Cap what a routine can pull into memory. - if bytes.len() > 64 * 1024 * 1024 { - return None; - } + // Read through a byte-limited, regular-file-only reader so a path naming an endless device + // cannot exhaust memory before the 64 MiB cap is enforced (CWE-400). + const MAX: usize = 64 * 1024 * 1024; + let bytes = crate::capped_read::read_capped(std::path::Path::new(path), MAX).ok()?; Some(String::from_utf8_lossy(&bytes).into_owned()) } #[cfg(target_arch = "wasm32")] @@ -1503,5 +1580,24 @@ pub fn real(r: f64) -> String { /// `wcmatch` for other callers (`ssget` filters). pub fn wild_match(text: &str, pattern: &str) -> bool { let t: Vec = text.chars().collect(); - split_patterns(pattern).iter().any(|p| match_pattern(&t, p)) + let mut budget: u64 = WCMATCH_BUDGET; + split_patterns(pattern, &mut budget).is_ok_and(|patterns| patterns.iter().any(|p| match_pattern(&t, p, &mut budget) == Ok(true))) +} + +#[cfg(test)] +mod wildcard_budget_tests { + use super::*; + + #[test] + fn all_native_scans_consume_fuel() { + assert!(split_patterns("abcdefgh", &mut 4).is_err()); + assert!(split_patterns("`a`b`c", &mut 4).is_err()); + let text: Vec<_> = "abcdefgh".chars().collect(); + assert!(glob(&text, &text, 0, &mut 4).is_err()); + let pattern: Vec<_> = "[abcdefgh]".chars().collect(); + assert!(glob(&['!'], &pattern, 0, &mut 6).is_err()); + // Enough for finding the closing bracket, but not scanning the entire set again. + assert!(glob(&['!'], &pattern, 0, &mut 14).is_err()); + assert_eq!(glob(&['a'], &pattern, 0, &mut 100), Ok(true)); + } } diff --git a/crates/engine/src/lisp/drawing.rs b/crates/engine/src/lisp/drawing.rs index 573eaa24a..043b611a3 100644 --- a/crates/engine/src/lisp/drawing.rs +++ b/crates/engine/src/lisp/drawing.rs @@ -151,16 +151,32 @@ pub fn from_json(v: &serde_json::Value) -> Value { } } -/// A LISP value as JSON (for `setvar`). -pub fn to_json(v: &Value) -> serde_json::Value { - match v { +/// A LISP value as JSON (for `setvar`), bounded so a shared cons structure cannot expand into +/// exponentially many owned array elements before the setter can reject it (CWE-400). +/// +/// Largest number of elements it may expand, and the deepest nesting it follows. +const TO_JSON_BUDGET: u64 = 2_000_000; +const TO_JSON_MAX_DEPTH: usize = 256; + +fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option { + if depth > TO_JSON_MAX_DEPTH || *budget == 0 { + return None; + } + *budget -= 1; + Some(match v { Value::Nil => serde_json::Value::Null, Value::Int(i) => serde_json::json!(i), Value::Real(r) => serde_json::json!(r), Value::Str(s) => serde_json::json!(&**s), - Value::Cons(_) => serde_json::Value::Array(v.iter().map(|x| to_json(&x)).collect()), + Value::Cons(_) => { + let mut arr = Vec::new(); + for x in v.iter() { + arr.push(to_json_bounded(&x, depth + 1, budget)?); + } + serde_json::Value::Array(arr) + } other => serde_json::json!(to_text(other, false)), - } + }) } pub fn getvar(ctx: &mut Ctx<'_>, a: Vec) -> Result { @@ -172,7 +188,11 @@ pub fn getvar(ctx: &mut Ctx<'_>, a: Vec) -> Result { pub fn setvar(ctx: &mut Ctx<'_>, a: Vec) -> Result { arity(&a, 2, 2)?; let name = string(&a[0])?; - match crate::sysvars::set(ctx.s, &name, &to_json(&a[1])) { + let mut budget: u64 = TO_JSON_BUDGET; + let Some(json) = to_json_bounded(&a[1], 0, &mut budget) else { + return Err(LispError::new(format!("AutoCAD variable setting rejected: {} value too large", to_text(&a[0], true)))); + }; + match crate::sysvars::set(ctx.s, &name, &json) { Ok(()) => Ok(Flow::Value(a[1].clone())), Err(_) => Err(LispError::new(format!("AutoCAD variable setting rejected: {} {}", to_text(&a[0], true), to_text(&a[1], true)))), } diff --git a/crates/engine/src/lisp/eval.rs b/crates/engine/src/lisp/eval.rs index e25798f63..3101b3933 100644 --- a/crates/engine/src/lisp/eval.rs +++ b/crates/engine/src/lisp/eval.rs @@ -163,7 +163,8 @@ pub struct Ctx<'a> { pub const MAX_STACK: usize = 100_000; /// Nesting depth of synchronous callbacks (`vl-sort` comparing with a LISP function, …). const MAX_SYNC_DEPTH: usize = 32; - +/// Steps per slice while driving a synchronous callback ([`Ctx::call_sync`]). +const SYNC_SLICE: u64 = 400_000; enum State { Eval(Value), Ret(Value), @@ -645,12 +646,31 @@ impl Ctx<'_> { super::value::charge_result(&v)?; return Ok(v); } + let cap = self.s.lisp.limits.callback_steps; let mut m = Machine::calling(f.clone(), args); let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 }; - match m.run(&mut sub, u64::MAX) { - Outcome::Done(v) => Ok(v), - Outcome::Error(e) => Err(e), - Outcome::Wait(_) => Err(LispError::new("this function can't ask for input here")), + // Drive the callback in bounded slices instead of granting it `u64::MAX` fuel. A `Yield` + // (the step budget ran out) is retried until a hard cap, so a constant-memory infinite + // loop inside a comparator or predicate terminates with an error rather than hanging the + // thread and bypassing the blocking execution cap (CWE-400). + let mut spent: u64 = 0; + loop { + let remaining = cap.saturating_sub(spent); + if remaining == 0 { + return Err(LispError::new(format!("callback stopped after {cap} steps"))); + } + let slice = remaining.min(SYNC_SLICE); + match m.run(&mut sub, slice) { + Outcome::Done(v) => return Ok(v), + Outcome::Error(e) => return Err(e), + Outcome::Wait(Need::Yield) => { + spent = spent.saturating_add(slice); + if spent >= cap { + return Err(LispError::new(format!("callback stopped after {cap} steps"))); + } + } + Outcome::Wait(_) => return Err(LispError::new("this function can't ask for input here")), + } } } } diff --git a/crates/engine/src/lisp/mod.rs b/crates/engine/src/lisp/mod.rs index d9fdc9a10..14ac32c6c 100644 --- a/crates/engine/src/lisp/mod.rs +++ b/crates/engine/src/lisp/mod.rs @@ -126,11 +126,13 @@ pub(crate) struct Limits { pub cells: usize, /// Bytes of strings, printed text and files read per run. pub string_bytes: u64, + /// Maximum interpreter steps per synchronous callback. + pub callback_steps: u64, } impl Default for Limits { fn default() -> Self { - Limits { cells: value::MAX_CELLS, string_bytes: value::STRING_BUDGET } + Limits { cells: value::MAX_CELLS, string_bytes: value::STRING_BUDGET, callback_steps: 500_000_000 } } } diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index 04d78f893..206690560 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -221,6 +221,64 @@ fn runaway_routines_are_errors_not_crashes() { assert_eq!(r, Ok("3000000".into())); } +#[test] +fn synchronous_callback_loop_is_an_error_not_a_hang() { + // A comparator with an infinite loop used to run with u64::MAX fuel and hang the thread; it + // now terminates at the synchronous-callback step cap (CWE-400, finding row 2). + let mut s = session(); + s.lisp.limits.callback_steps = 1_000; + assert!(eval(&mut s, "(vl-sort '(1 2) (function (lambda (a b) (while t))))").is_err()); + // A well-behaved comparator still works afterwards. + assert_eq!(ok(&mut s, "(vl-sort '(3 1 2) '<)"), "(1 2 3)"); +} + +#[test] +fn a_zero_callback_budget_does_not_run_the_callback() { + let mut s = session(); + s.lisp.limits.callback_steps = 0; + ok(&mut s, "(setq callback-ran nil)"); + let result = eval(&mut s, "(vl-remove-if '(lambda (x) (setq callback-ran t)) '(1))"); + assert_eq!(result, Err("callback stopped after 0 steps".into())); + assert_eq!(ok(&mut s, "callback-ran"), "nil"); +} + +#[test] +fn a_callback_must_fit_within_a_partial_slice() { + let mut s = session(); + s.lisp.limits.callback_steps = 1_000; + ok(&mut s, "(setq callback-count 0)"); + let result = eval(&mut s, "(vl-remove-if '(lambda (x) (repeat 1000 (setq callback-count (1+ callback-count)))) '(1))"); + assert_eq!(result, Err("callback stopped after 1000 steps".into())); + assert_eq!(ok(&mut s, "(and (> callback-count 0) (< callback-count 1000))"), "T"); +} + +#[test] +fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() { + let counts: Vec = [399_999, 400_000] + .into_iter() + .map(|cap| { + let mut s = session(); + s.lisp.limits.callback_steps = cap; + ok(&mut s, "(setq callback-count 0)"); + let result = eval(&mut s, "(vl-remove-if '(lambda (x) (while t (setq callback-count (1+ callback-count)))) '(1))"); + assert_eq!(result, Err(format!("callback stopped after {cap} steps"))); + ok(&mut s, "callback-count").parse().unwrap() + }) + .collect(); + // One additional VM step can perform at most one assignment, even at SYNC_SLICE's boundary. + assert!(counts[0] > 0); + assert!(counts[1] >= counts[0] && counts[1] - counts[0] <= 1, "{counts:?}"); +} + +#[test] +fn setvar_rejects_an_oversized_shared_structure() { + // A list repeatedly sharing its own contents expands exponentially; setvar's bounded JSON + // conversion rejects it instead of allocating gigabytes (CWE-400, finding row 39). + let mut s = session(); + let build = "(setq x '(1)) (repeat 40 (setq x (list x x))) (setvar \"GRIDUNIT\" x)"; + assert!(eval(&mut s, build).is_err()); +} + #[test] fn printing() { let mut s = session(); diff --git a/crates/engine/src/lisp/value.rs b/crates/engine/src/lisp/value.rs index f05d1716a..99f066d7e 100644 --- a/crates/engine/src/lisp/value.rs +++ b/crates/engine/src/lisp/value.rs @@ -343,18 +343,29 @@ pub fn eq(a: &Value, b: &Value) -> bool { /// `equal`: the same structure, numbers within `fuzz`. pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool { - equal_depth(a, b, fuzz, 0) + // A shared cons DAG can expand into exponentially many node visits when compared as a tree. + // Bound the total comparisons so `equal` on adversarial shared structure cannot monopolise the + // thread (CWE-407); exhausting the budget reports "not equal" rather than looping. + let mut budget: u64 = EQUAL_BUDGET; + equal_depth(a, b, fuzz, 0, &mut budget) } -fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize) -> bool { - if depth > MAX_DEPTH { +/// Largest number of node comparisons one `equal` call may perform. +const EQUAL_BUDGET: u64 = 50_000_000; + +fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize, budget: &mut u64) -> bool { + if depth > MAX_DEPTH || *budget == 0 { return false; } + *budget -= 1; let (mut a, mut b) = (a.clone(), b.clone()); loop { + if *budget == 0 { + return false; + } match (&a, &b) { (Value::Cons(x), Value::Cons(y)) => { - if !equal_depth(&x.car, &y.car, fuzz, depth + 1) { + if !equal_depth(&x.car, &y.car, fuzz, depth + 1, budget) { return false; } let (na, nb) = (x.cdr.clone(), y.cdr.clone()); From dc4bbe86f64a3126173b2f7af930132f287e9571 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 17:57:39 -0400 Subject: [PATCH 34/57] security: bound font outline geometry and MTEXT line scanning (CWE-400/407) (#1852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security: bound font outline geometry and MTEXT line scanning (CWE-400/407) Two font/text paths let crafted input monopolise the rendering thread: - Glyph shaping copied a glyph's full contour set for every placed character with no cumulative budget, so a compact CFF/OTF font with a large expanded outline, repeated across a short string, could grow the shaped result without bound. `shape` now charges retained outline points against a cumulative per-line budget and stops placing geometry once it is exhausted (advances still accumulate, so the width stays sensible) (row 9). - Default MTEXT layout recomputed `line.text.chars().count()` before appending each segment or space, rescanning the whole accumulated line every time — multiplicative on a long line. The line now keeps a running character count, updated only from newly appended text (row 20). New tests cover the MTEXT layout; all font tests pass and render still builds, clippy clean. Addresses findings rows 9, 20. * fix: budget glyph geometry before outline placement * fix: skip empty outlines without text metadata --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- crates/fonts/src/complex.rs | 10 +-- crates/fonts/src/lib.rs | 4 + crates/fonts/src/mtext.rs | 21 +++++- crates/fonts/src/ttf.rs | 143 +++++++++++++++++++++++++++++++----- 4 files changed, 151 insertions(+), 27 deletions(-) diff --git a/crates/fonts/src/complex.rs b/crates/fonts/src/complex.rs index b479590e1..fdfef1515 100644 --- a/crates/fonts/src/complex.rs +++ b/crates/fonts/src/complex.rs @@ -68,6 +68,7 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64 let cs: Vec = chars.iter().map(|c| c.0).collect(); let flags = |i: usize| chars.get(i).map(|c| (c.1, c.2)).unwrap_or((false, false)); let mut out = Shaped::default(); + let mut points_left = ttf::MAX_SHAPED_POINTS; let mut spans: Vec<(f64, f64, bool, bool)> = Vec::new(); let mut x = 0.0; // Whether the rightmost glyph so far is a stroke-font glyph. @@ -102,11 +103,10 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64 let outline = ttf::glyph_by_id(face, g.gid); let k = h / outline.cap * wf; let dy = g.dy * h / outline.cap; - let mut contours = ttf::place_glyph(&outline, x + g.dx * k, h, wf, shear); - for p in contours.iter_mut().flatten() { - *p += Vec2::new(dy * shear, dy); - } - if !contours.is_empty() { + if let Some(mut contours) = ttf::place_glyph_budgeted(&outline, x + g.dx * k, h, wf, shear, &mut points_left) { + for p in contours.iter_mut().flatten() { + *p += Vec2::new(dy * shear, dy); + } out.glyphs.push(contours); } let adv = g.advance * k; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 3f86f817c..13af05646 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -334,7 +334,11 @@ pub struct TextGlyph { impl Shaped { /// Add a glyph outline and, when it is a font glyph, its [`TextGlyph`]. + /// An empty outline is retained only when text metadata needs its slot. pub(crate) fn push_glyph(&mut self, contours: Vec>, text: Option) { + if contours.is_empty() && text.is_none() { + return; + } if let Some(t) = text { self.text.push(TextGlyph { outline: self.glyphs.len(), ..t }); } diff --git a/crates/fonts/src/mtext.rs b/crates/fonts/src/mtext.rs index 1d53dc915..916bdf334 100644 --- a/crates/fonts/src/mtext.rs +++ b/crates/fonts/src/mtext.rs @@ -501,6 +501,9 @@ struct Line { width: f64, max_h: f64, text: String, + /// Running character count of `text`, kept incrementally so appending a segment need not + /// rescan the whole accumulated line (which is multiplicative on a long line) (CWE-407). + chars: usize, /// Index of the paragraph the line belongs to. para: usize, } @@ -580,9 +583,10 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout { finish(&mut lines, &mut line, fmt(last_fmt), para); } else if !line.items.is_empty() || pending_space.is_some() { if let Some(f) = pending_space { - let at = line.text.chars().count(); + let at = line.chars; line.items.push(Item { seg: None, fmt: f, x: line.width, w: sw, at }); line.text.push(' '); + line.chars += 1; } line.width += sw; } @@ -590,8 +594,10 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout { for (sg, f) in segs { let fw = seg_width(sg, fmt(*f)); line.max_h = line.max_h.max(fmt(*f).height); - let at = line.text.chars().count(); - line.text.push_str(&seg_plain(sg)); + let at = line.chars; + let plain = seg_plain(sg); + line.chars += plain.chars().count(); + line.text.push_str(&plain); line.items.push(Item { seg: Some(sg.clone()), fmt: *f, x: line.width, w: fw, at }); line.width += fw; last_fmt = *f; @@ -679,6 +685,15 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout { mod tests { use super::*; + #[test] + fn line_char_offsets_track_incrementally() { + // A multi-word line lays out correctly even though the per-line character count is now + // maintained incrementally rather than rescanned per segment (CWE-407, row 20). + let l = layout_mtext("alpha beta gamma", Vec2::ZERO, 1.0, 0.0, 1, 0.0, 1.0); + assert_eq!(l.lines.len(), 1, "no wrap width: one line"); + assert_eq!(l.lines[0], "alpha beta gamma"); + } + #[test] fn strips_formatting() { assert_eq!(plain_mtext("{\\fArial|b1;Bold}\\Pline 2"), "Bold\nline 2"); diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index 62759c1b0..aedcbb07d 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -107,6 +107,9 @@ const MAX_FIRST_WORD_FILES: usize = 24; const MAX_LOADED_BYTES: usize = 256 << 20; const MAX_CACHED_NAMES: usize = 4096; const MAX_CACHED_CHARS: usize = 50_000; +/// Cumulative outline points one shaped line may retain, so an oversized expanded glyph repeated +/// across a string cannot grow the result without bound (CWE-400). +pub(crate) const MAX_SHAPED_POINTS: usize = 8_000_000; /// Fonts known to cover many scripts (CJK first), tried first for missing glyphs. Matched as /// substrings of the normalised file stem. @@ -595,6 +598,7 @@ pub fn fallback_for(c: char) -> Option { #[derive(Default)] pub(crate) struct GlyphOutline { pub(crate) contours: Arc<[Vec]>, + points: usize, pub(crate) advance: f64, pub(crate) cap: f64, /// The font maps the character (false: the missing-glyph shape). @@ -614,22 +618,46 @@ enum GlyphKey { /// Cached glyph outlines keyed by (font bytes address, length, face, glyph). Fonts live for the /// whole process in the font table, so the address identifies them. A read-write lock: display /// lists are built on all cores and nearly every lookup is a hit, which a mutex serialized. -type GlyphCache = HashMap<(usize, usize, u32, GlyphKey), Arc>; +#[derive(Default)] +struct GlyphCache { + glyphs: HashMap<(usize, usize, u32, GlyphKey), Arc>, + points: usize, +} fn glyph_cache() -> &'static RwLock { static C: OnceLock> = OnceLock::new(); - C.get_or_init(|| RwLock::new(HashMap::new())) + C.get_or_init(|| RwLock::new(GlyphCache::default())) } const MAX_CACHED_GLYPHS: usize = 40_000; const UPEM: f32 = 1000.0; +#[derive(Default)] struct Pen { contours: Vec>, cur: Vec, + points: usize, + truncated: bool, } +const MAX_OUTLINE_POINTS: usize = 65_536; + impl Pen { + fn push(&mut self, point: Vec2) { + if self.truncated { + return; + } + if self.points == MAX_OUTLINE_POINTS { + // An incomplete contour is not safe to fill; retain only the advance for this glyph. + self.truncated = true; + self.cur.clear(); + self.contours.clear(); + return; + } + self.points += 1; + self.cur.push(point); + } + fn pt(x: f32, y: f32) -> Vec2 { Vec2::new(f64::from(x), f64::from(y)) } @@ -638,9 +666,11 @@ impl Pen { if let (Some(f), Some(l)) = (self.cur.first().copied(), self.cur.last().copied()) && f != l { - self.cur.push(f); + self.push(f); + } + if !self.truncated { + self.contours.push(std::mem::take(&mut self.cur)); } - self.contours.push(std::mem::take(&mut self.cur)); } else { self.cur.clear(); } @@ -650,10 +680,10 @@ impl Pen { impl OutlinePen for Pen { fn move_to(&mut self, x: f32, y: f32) { self.flush(); - self.cur.push(Self::pt(x, y)); + self.push(Self::pt(x, y)); } fn line_to(&mut self, x: f32, y: f32) { - self.cur.push(Self::pt(x, y)); + self.push(Self::pt(x, y)); } fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { let a = self.cur.last().copied().unwrap_or_default(); @@ -662,7 +692,7 @@ impl OutlinePen for Pen { for i in 1..=6 { let t = f64::from(i) / 6.0; let u = 1.0 - t; - self.cur.push(a * (u * u) + c * (2.0 * u * t) + b * (t * t)); + self.push(a * (u * u) + c * (2.0 * u * t) + b * (t * t)); } } fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { @@ -673,7 +703,7 @@ impl OutlinePen for Pen { for i in 1..=8 { let t = f64::from(i) / 8.0; let u = 1.0 - t; - self.cur.push(a * (u * u * u) + c0 * (3.0 * u * u * t) + c1 * (3.0 * u * t * t) + b * (t * t * t)); + self.push(a * (u * u * u) + c0 * (3.0 * u * u * t) + c1 * (3.0 * u * t * t) + b * (t * t * t)); } } fn close(&mut self) { @@ -714,7 +744,7 @@ pub fn glyph_contours(face: &Face, gid: u16) -> Arc<[Vec]> { fn outline(face: &Face, k: GlyphKey) -> Arc { let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, k); - if let Some(g) = glyph_cache().read().unwrap_or_else(PoisonError::into_inner).get(&key) { + if let Some(g) = glyph_cache().read().unwrap_or_else(PoisonError::into_inner).glyphs.get(&key) { return g.clone(); } let Some(f) = face.font() else { return Arc::new(GlyphOutline { cap: 1.0, ..Default::default() }) }; @@ -723,19 +753,24 @@ fn outline(face: &Face, k: GlyphKey) -> Arc { GlyphKey::Id(id) => Some(GlyphId::new(id)), }; let gid = mapped.unwrap_or_default(); - let mut pen = Pen { contours: Vec::new(), cur: Vec::new() }; + let mut pen = Pen::default(); if let Some(g) = f.outline_glyphs().get(gid) { let _ = g.draw(DrawSettings::unhinted(Size::new(UPEM), LocationRef::default()), &mut pen); pen.flush(); } let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5)); let (cap, mapped, gid) = (cap_height(&f), mapped.is_some(), gid.to_u32()); - let g = Arc::new(GlyphOutline { contours: pen.contours.into(), advance, cap, mapped, gid, face: Some(face.clone()) }); + let points = pen.contours.iter().map(Vec::len).sum(); + let g = Arc::new(GlyphOutline { points, contours: pen.contours.into(), advance, cap, mapped, gid, face: Some(face.clone()) }); let mut cache = glyph_cache().write().unwrap_or_else(PoisonError::into_inner); - if cache.len() > MAX_CACHED_GLYPHS { - cache.clear(); + if cache.glyphs.len() >= MAX_CACHED_GLYPHS || cache.points.saturating_add(points) > MAX_SHAPED_POINTS { + cache.glyphs.clear(); + cache.points = 0; } - cache.insert(key, g.clone()); + if let Some(old) = cache.glyphs.insert(key, g.clone()) { + cache.points -= old.points; + } + cache.points += points; g } @@ -777,6 +812,11 @@ pub(crate) fn text_glyph_at(g: &GlyphOutline, c: char, origin: Vec2, scale: f64, }) } +pub(crate) fn place_glyph_budgeted(g: &GlyphOutline, x: f64, h: f64, wf: f64, shear: f64, points_left: &mut usize) -> Option>> { + *points_left = points_left.checked_sub(g.points)?; + Some(place_glyph(g, x, h, wf, shear)) +} + /// Contours of a glyph scaled so its font's cap height is `h`, at pen position `x`. pub(crate) fn place_glyph(g: &GlyphOutline, x: f64, h: f64, wf: f64, shear: f64) -> Vec> { place_glyph_at(g, Vec2::new(x, 0.0), h / g.cap, wf, shear) @@ -814,16 +854,20 @@ pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) let mut out = Shaped::default(); let mut spans = Vec::new(); let mut x = 0.0; + // Cumulative retained-point budget: a compact CFF/OTF font can expand one glyph into a huge + // outline, and a short repeated string copies it per occurrence, so cap the total geometry + // kept in the Shaped result (CWE-400). Advances still accumulate so the width stays sensible. + let mut points_left: usize = MAX_SHAPED_POINTS; for (c, under, over) in crate::decode_controls(s) { if crate::bidi::is_format_control(c) { continue; } let g = glyph_or_fallback(face, c); - if !c.is_whitespace() { - let contours = place_glyph(&g, x, h, wf, shear); - if !contours.is_empty() { - out.push_glyph(contours, text_glyph(&g, c, x, h, wf, shear)); - } + if !c.is_whitespace() + && let Some(contours) = place_glyph_budgeted(&g, x, h, wf, shear, &mut points_left) + { + // Even an empty mapped glyph needs a valid outline slot for text export. + out.push_glyph(contours, text_glyph(&g, c, x, h, wf, shear)); } let adv = g.advance * h / g.cap * wf; spans.push((x, x + adv, under, over)); @@ -878,3 +922,64 @@ mod family_tests { assert!(family_candidates(&many, "gosttypea", "gost").len() <= MAX_FAMILY_FILES + MAX_FIRST_WORD_FILES); } } + +#[cfg(test)] +mod geometry_budget_tests { + use super::*; + #[test] + fn placement_checks_source_size_and_accepts_empty_glyphs() { + let glyph = GlyphOutline { contours: vec![vec![Vec2::ZERO; 4]].into(), points: 4, cap: 1.0, ..Default::default() }; + let mut budget = 3; + assert!(place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut budget).is_none()); + assert_eq!(budget, 3); + let empty = GlyphOutline { cap: 1.0, ..Default::default() }; + assert_eq!(place_glyph_budgeted(&empty, 0.0, 1.0, 1.0, 0.0, &mut 0), Some(Vec::new())); + let mut budget = 4; + assert!(place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut budget).is_some()); + assert_eq!(budget, 0); + assert!(place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut budget).is_none()); + } + #[test] + fn flattened_outlines_are_bounded_before_caching() { + let mut pen = Pen::default(); + for _ in 0..MAX_OUTLINE_POINTS { + pen.line_to(0.0, 0.0); + } + pen.line_to(1.0, 1.0); + pen.close(); + assert!(pen.truncated); + assert!(pen.contours.is_empty() && pen.cur.is_empty()); + assert_eq!(pen.points, MAX_OUTLINE_POINTS); + } + #[test] + fn mapped_empty_glyph_keeps_text_and_a_valid_outline_slot() { + // Metadata placement does not parse the face; an empty face suffices to check the index contract. + let glyph = GlyphOutline { cap: 1.0, mapped: true, gid: 1, face: Some(Face { bytes: Arc::new(Vec::new()), index: 0 }), ..Default::default() }; + let contours = place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut 0).unwrap(); + let mut shaped = Shaped::default(); + shaped.push_glyph(contours, text_glyph(&glyph, 'x', 0.0, 1.0, 1.0, 0.0)); + assert_eq!(shaped.text.len(), 1); + assert_eq!(shaped.text[0].outline, 0); + assert!(shaped.glyphs[shaped.text[0].outline].is_empty()); + } + + #[test] + fn unmapped_empty_glyph_does_not_make_the_shape_nonempty() { + let glyph = GlyphOutline { cap: 1.0, ..Default::default() }; + let contours = place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut 0).unwrap(); + let mut shaped = Shaped::default(); + shaped.push_glyph(contours, text_glyph(&glyph, 'x', 0.0, 1.0, 1.0, 0.0)); + assert!(shaped.is_empty()); + assert!(shaped.text.is_empty()); + } + + #[test] + fn an_unmapped_glyph_with_contours_still_draws() { + let mut shaped = Shaped::default(); + let contours = vec![vec![Vec2::ZERO, Vec2::new(1.0, 1.0)]]; + shaped.push_glyph(contours.clone(), None); + assert!(!shaped.is_empty()); + assert_eq!(shaped.glyphs, vec![contours]); + assert!(shaped.text.is_empty()); + } +} From 07bb6042145142b2f8a0f8ef44ec01274ce8d7dc Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 18:18:43 -0400 Subject: [PATCH 35/57] security: guard the DXF->DWG conversion input and document decoder limits (CWE-789) (#1847) The native DWG bridge wraps the third-party `acadrust` decoder, which allocates tables and section buffers from sizes a file declares and cannot be interrupted once it starts. The DWG->DXF read path already rejects oversized input before decoding, caps the DXF output while writing, and runs inside `catch_unwind`. The DXF->DWG `convert` path had no input-size guard, so a very large DXF went straight into the dependency's reader. - `convert` now rejects DXF input larger than `Limits::max_dxf_bytes` before calling `DxfReader::read`, symmetric with the read path, and keeps the existing `catch_unwind` around the decoder and writer (rows 23, 3, 27, large-input variant). - Documented the residual risk: a small file that declares a huge internal table or section can still make the dependency allocate before any size is known. Fully fixing that needs an upstream fix in `acadrust` (validate declared sizes against a decoded-memory budget) or a separate worker with an OS memory limit; the in-crate limits keep the blast radius bounded until then. Addresses findings rows 3, 23, 27 (bounds the reachable input/output size; the deeper allocation fix belongs in the unmodified dependency). --- crates/dwg/src/lib.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index 7cbd40fc7..835830c94 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -4,6 +4,25 @@ //! converted to DXF bytes, which CADCraft's own DXF reader maps to its document model; saving //! goes the other way. Keeping the dependency behind this byte-level API isolates it: nothing //! else in CADCraft depends on its types. Native targets only (it memory-maps files). +//! +//! # Guardrails around the third-party decoder +//! +//! The decoder allocates tables and section buffers from sizes a (possibly damaged or hostile) +//! file declares, and once it starts it cannot be interrupted (an allocation failure aborts the +//! process, which `catch_unwind` cannot recover). Because the dependency is used unmodified, this +//! crate guards it from the outside: +//! +//! - Both directions reject oversized input *before* the decoder runs ([`Limits::max_dwg_bytes`] +//! for DWG→DXF, [`Limits::max_dxf_bytes`] for DXF→DWG), and the DWG→DXF output is capped while +//! it is written (`CappedWriter`). +//! - The reader and writer run inside `catch_unwind`, so a decode failure is reported instead of +//! crashing. +//! +//! These bound the input- and output-size variants. A small file that declares a huge internal +//! table or section can still make the dependency allocate before any size is known; fully fixing +//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory +//! budget) or running the decode in a separate worker with an OS memory limit. The limits here +//! keep the blast radius bounded in the meantime. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -505,6 +524,13 @@ mod native { } fn convert(dxf: &[u8], version: Option) -> Result, String> { + // Reject a pathologically large DXF before handing it to the third-party decoder, which + // allocates tables and section buffers from the input's declared sizes and cannot be + // interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder + // runs inside `catch_unwind` so a decode failure is reported rather than crashing. + if dxf.len() > Limits::DEFAULT.max_dxf_bytes { + return Err(format!("DXF: the input is {}, larger than the {} CADCraft converts to DWG", mb(dxf.len() as u64), mb(Limits::DEFAULT.max_dxf_bytes as u64))); + } let data = dxf.to_vec(); let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) .map_err(|_| "the DXF→DWG conversion failed".to_string())? @@ -618,6 +644,15 @@ mod tests { assert!(dwg_to_dxf(b"").is_err()); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn dxf_to_dwg_rejects_garbage_without_crashing() { + // The DXF→DWG path runs the third-party reader inside catch_unwind and reports failures + // rather than crashing (and rejects oversized input before decoding). + assert!(dxf_to_dwg(b"not a dxf file").is_err()); + assert!(dxf_to_dwg(b"").is_err()); + } + /// A DXF with one DIMENSION per (text, group 70, subclass, points) row. #[cfg(not(target_arch = "wasm32"))] fn dims_dxf(dims: &[(&str, i64, &str, &[(i32, [f64; 2])])]) -> String { From 15ba3dc5ccc2fb03796c5e477d09eb4717d13d3f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 18:19:13 -0400 Subject: [PATCH 36/57] security: submit XATTACH picked path as literal text, not a command line (CWE-74) (#1843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The External References palette's XATTACH handler passed the file picker's result to `app.cmdline(&path)`. `cmdline` interprets command-line syntax, so a selected filename beginning with an apostrophe (for example `'closeall`) was run as an engine command rather than naming the file — closing every drawing without the UI's unsaved-change confirmation. The picked path is attacker-controlled (the picker returns whatever the user selected, and `relative_to_host` can reduce it to a bare basename). After starting XATTACH, submit it through `Session::input(Input::Text(path))` so it is delivered to the command's prompt as literal text and never interpreted as a command, matching how other picker/dialog bridges already behave. Addresses finding row 26. --- crates/ui-egui/src/xrefs.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/xrefs.rs b/crates/ui-egui/src/xrefs.rs index 5828169d7..fb9cb9ef1 100644 --- a/crates/ui-egui/src/xrefs.rs +++ b/crates/ui-egui/src/xrefs.rs @@ -24,7 +24,13 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option None, From 01bbdb24bc27b087893afc37dff07d79862a5b97 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 18:42:51 -0400 Subject: [PATCH 37/57] security: confine craft-fonts manifest paths and reject newline directives (CWE-22, CWE-74) (#1841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The optional `CRAFT_FONTS_DIR` build input lets a font manifest select the file each face is embedded from. `build.rs` canonicalised that file with no containment check, so an absolute path, a `..` traversal, or a symlink inside the checkout could make the generated `include_bytes!` read an arbitrary file on the build host and expose its bytes through `CRAFT_FONTS` (CWE-22). It also printed the resolved path straight into Cargo's newline-delimited build-script protocol (`cargo::rerun-if-changed=…`). A path containing an embedded newline — reachable through a crafted symlink target — could inject a second directive such as `cargo::rustc-env=OUT_DIR=…`, redirecting the generated-source include to attacker-supplied Rust (CWE-74). - `resolve_font` rejects absolute and `..` manifest paths and requires the canonicalised result to stay inside the canonicalised checkout root. - `rerun_if_changed` refuses any path containing CR/LF before emitting the directive; `one_line` collapses CR/LF in checkout-derived diagnostics. Addresses findings rows 5 (CWE-22) and 14 (CWE-74). --- crates/fonts/build.rs | 47 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 7eb2a784b..658f5b7b3 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -18,9 +18,9 @@ fn main() { match craft_fonts(&dir) { Ok(entries) => src.push_str(&entries), Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); + println!("cargo::error=CRAFT_FONTS_DIR={}: {}", one_line(&dir.display().to_string()), one_line(&e)); } - Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), + Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {}", one_line(&dir.display().to_string()), one_line(&e)), } } src.push_str("];\n"); @@ -37,7 +37,9 @@ const BARRED_FAMILIES: [&str; 5] = ["Source Han", "Source Serif", "Source Sans", /// One `CraftFont { .. }` initialiser per manifest line, in manifest order. fn craft_fonts(dir: &std::path::Path) -> Result { let manifest = dir.join("fonts/manifest.txt"); - println!("cargo::rerun-if-changed={}", manifest.display()); + rerun_if_changed(&manifest)?; + // Resolve the checkout root once so every manifest file can be confined inside it. + let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?; let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); let mut out = String::new(); @@ -55,8 +57,8 @@ fn craft_fonts(dir: &std::path::Path) -> Result { if wasm && !web_face { continue; } - let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; - println!("cargo::rerun-if-changed={}", path.display()); + let path = resolve_font(&root, file)?; + rerun_if_changed(&path)?; let scripts: Vec = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); let _ = writeln!( out, @@ -67,3 +69,38 @@ fn craft_fonts(dir: &std::path::Path) -> Result { } Ok(out) } + +/// Resolve a manifest `file` field to a real path *confined to the checkout* `root` (CWE-22). +/// +/// The field is rejected if it is absolute or contains a parent (`..`) component, and the +/// canonicalised result must still live inside `root`, so a crafted manifest or a symlink in the +/// checkout cannot make the generated `include_bytes!` read an arbitrary file on the build host. +fn resolve_font(root: &std::path::Path, file: &str) -> Result { + let rel = std::path::Path::new(file); + if rel.is_absolute() || rel.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + return Err(format!("{file}: manifest paths must be relative and stay inside the checkout")); + } + let path = root.join(rel).canonicalize().map_err(|e| format!("{file}: {e}"))?; + if !path.starts_with(root) { + return Err(format!("{file}: resolves outside the craft-fonts checkout")); + } + Ok(path) +} + +/// Collapse CR/LF in a diagnostic derived from checkout contents to a single space, so it cannot +/// introduce an additional Cargo directive on the newline-delimited build-script stream (CWE-74). +fn one_line(s: &str) -> String { + s.replace(['\n', '\r'], " ") +} + +/// Emit a `cargo::rerun-if-changed` directive for `path`, refusing paths that contain a line +/// separator (CWE-74): Cargo's build-script protocol is newline-delimited, so a path with an +/// embedded CR/LF could smuggle an extra directive such as `cargo::rustc-env=OUT_DIR=…`. +fn rerun_if_changed(path: &std::path::Path) -> Result<(), String> { + let shown = path.display().to_string(); + if shown.contains(['\n', '\r']) { + return Err(format!("{shown:?}: path contains a line separator")); + } + println!("cargo::rerun-if-changed={shown}"); + Ok(()) +} From 48b4165591360b4e7d513a5276c2d08dead09a38 Mon Sep 17 00:00:00 2001 From: skylartaylor <26588250+skylartaylor@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:59:55 +0200 Subject: [PATCH 38/57] docs: document security limits and regression guarantees --- apps/cadcraft/src/control_server.rs | 10 ++++++++++ crates/constraints/src/dof.rs | 3 +++ crates/constraints/src/lib.rs | 2 ++ crates/constraints/src/model.rs | 5 +++++ crates/constraints/src/residual.rs | 1 + crates/constraints/tests/dof.rs | 1 + crates/dwg/src/lib.rs | 6 +++++- crates/engine/src/capped_read.rs | 3 +++ crates/engine/src/cmd/file.rs | 1 + crates/engine/src/lisp/builtins.rs | 9 +++++++++ crates/engine/src/lisp/drawing.rs | 3 +++ crates/engine/src/lisp/mod.rs | 1 + crates/engine/src/lisp/tests.rs | 5 +++++ crates/engine/src/lisp/value.rs | 1 + crates/fonts/build.rs | 4 +++- crates/fonts/src/complex.rs | 1 + crates/fonts/src/mtext.rs | 1 + crates/fonts/src/ttf.rs | 16 ++++++++++++++++ crates/io/src/dxf_image.rs | 2 ++ crates/io/src/dxf_read.rs | 6 ++++++ crates/io/src/svg_read.rs | 2 ++ crates/io/tests/svg_arc_budget.rs | 2 ++ crates/mcp/src/backend.rs | 7 +++++++ crates/mcp/src/control_token.rs | 13 +++++++++++++ crates/render/src/dim.rs | 6 ++++++ crates/render/src/hatch.rs | 4 ++++ crates/render/src/linetype.rs | 1 + crates/render/src/table.rs | 4 ++++ xtask/src/stats.rs | 6 ++++++ 29 files changed, 124 insertions(+), 2 deletions(-) diff --git a/apps/cadcraft/src/control_server.rs b/apps/cadcraft/src/control_server.rs index d11671e11..14283e6fd 100644 --- a/apps/cadcraft/src/control_server.rs +++ b/apps/cadcraft/src/control_server.rs @@ -48,6 +48,8 @@ impl Default for Limits { } } +/// Start the loopback server with a fresh session token and a bounded request queue. +/// Binding or token-issuance failure returns a disconnected receiver. pub fn start(port: u16, ctx: egui::Context) -> Receiver { match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => { @@ -101,6 +103,7 @@ impl Drop for Slot { } } +/// Accept clients within the connection limit and give each worker the same session token. fn accept_loop(listener: TcpListener, tx: SyncSender, ctx: egui::Context, limits: Limits, token: Arc) { let open = Arc::new(AtomicUsize::new(0)); for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) { @@ -162,6 +165,7 @@ enum Line { Reject(String), } +/// Parse a UTF-8 JSON request and its optional token, distinguishing blank lines from rejected input. fn parse_line(bytes: &[u8]) -> Line { let Ok(text) = std::str::from_utf8(bytes) else { return Line::Reject("not a control request (invalid UTF-8); closing the connection".into()); @@ -198,6 +202,8 @@ fn is_http_request_line(text: &str) -> bool { || text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/")) } +/// Serve bounded JSON lines, authenticating each request before enqueueing it for the UI. +/// Invalid credentials close the connection; queue and reply limits report whether a request ran. fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context, limits: Limits, token: &str) { if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { return; @@ -385,6 +391,7 @@ mod tests { assert!(t.elapsed() < Duration::from_secs(4)); } + /// Authenticated requests rejected by a full queue retain their IDs and report that they did not run. #[test] fn a_full_queue_answers_busy() { let (addr, rx) = serve_with(short()); @@ -407,6 +414,7 @@ mod tests { drop(rx); } + /// Replies on one authenticated connection preserve the ID of each request. #[test] fn requests_are_answered_with_their_ids() { let addr = serve_echo(); @@ -423,6 +431,7 @@ mod tests { } } + /// Missing and incorrect session tokens are rejected before dispatch. #[test] fn a_request_without_the_token_is_unauthorized() { // A local peer that does not hold the session token cannot drive the app (CWE-306). @@ -437,6 +446,7 @@ mod tests { assert!(wrong[0]["error"].as_str().unwrap().contains("unauthorized"), "{wrong:?}"); } + /// A request carrying the session token reaches the control dispatcher. #[test] fn a_request_with_the_token_is_dispatched() { let addr = serve_echo(); diff --git a/crates/constraints/src/dof.rs b/crates/constraints/src/dof.rs index 5be5cd577..f0a34b9f8 100644 --- a/crates/constraints/src/dof.rs +++ b/crates/constraints/src/dof.rs @@ -89,6 +89,8 @@ struct CompDof { objects: Vec>, } +/// Compute component and object freedom only when rank and residual-evaluation work fit their budgets. +/// Return unknown freedom before building the Jacobian when either work limit is exceeded. fn analyse(comp: &Comp) -> CompDof { let free = comp.free(None); let rows = comp.rows(None); @@ -213,6 +215,7 @@ mod budget_tests { use cadcraft_doc::{Common, ConstraintKind, EntityKind, GeomRef, Space}; use cadcraft_geom::{Spline, Vec2}; + /// An expensive fit-spline endpoint residual produces unknown freedom before numerical differentiation. #[test] fn endpoint_analysis_rejects_cubic_fit_work_before_differentiation() { let mut d = Drawing::default(); diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index 338219469..81e81fc25 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -328,6 +328,8 @@ pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> R build_budgeted(d, cons, opts, &mut members_left) } +/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model. +/// Return constraints that cannot be interpreted as skipped entries. pub(crate) fn build_budgeted( d: &Drawing, cons: &[&Constraint], diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs index 53ace75bf..b7fe14322 100644 --- a/crates/constraints/src/model.rs +++ b/crates/constraints/src/model.rs @@ -117,6 +117,8 @@ pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec { rigid_points_budgeted(d, ins, &mut members_left) } +/// Sample finite block points within per-insert and caller-shared member limits. +/// Members that contribute no points still consume traversal work; nested blocks are not entered. fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec { let Some(block) = d.block(&ins.block) else { return Vec::new() }; let m = ins.transform(block.base.xy()); @@ -187,6 +189,7 @@ impl Model { Self::build_budgeted(d, handles, &mut members_left) } + /// Build solver parameters for supported entities while sharing rigid-member sampling work with other models. pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model { let mut m = Model::default(); for &h in handles { @@ -620,6 +623,7 @@ mod budget_tests { use cadcraft_geom::Vec3; use std::sync::Arc; + /// Writing a model back preserves partial rational weights and the spline they describe. #[test] fn write_back_keeps_partial_rational_weights() { let mut drawing = Drawing::default(); @@ -635,6 +639,7 @@ mod budget_tests { } } + /// A second model receives no sampled block points after the first model spends the shared member budget. #[test] fn rigid_sampling_budget_is_shared_between_models() { let mut d = Drawing::default(); diff --git a/crates/constraints/src/residual.rs b/crates/constraints/src/residual.rs index 164ada073..be5723fec 100644 --- a/crates/constraints/src/residual.rs +++ b/crates/constraints/src/residual.rs @@ -116,6 +116,7 @@ fn r_at(c: &Constraint, i: usize) -> Option<&GeomRef> { } impl Built { + /// Estimate spline reconstruction work for endpoint and join residuals; other residual forms add no such cost. pub(crate) fn spline_reconstruct_cost(&self, m: &Model) -> f64 { let cost = |i: usize| self.refs.get(i).map_or(0.0, |r| m.spline_reconstruct_cost(r.handle)); match self.form { diff --git a/crates/constraints/tests/dof.rs b/crates/constraints/tests/dof.rs index f863d9b8f..ea78ca932 100644 --- a/crates/constraints/tests/dof.rs +++ b/crates/constraints/tests/dof.rs @@ -364,6 +364,7 @@ fn hostile_new_geometry_never_panics() { let _ = dof(&d, &[Handle(0), Handle(u64::MAX)]); } +/// Fit-point vertex constraints retain a known freedom count because they do not reconstruct the spline. #[test] fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() { let mut d = Drawing::default(); diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index 835830c94..273229247 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -529,7 +529,11 @@ mod native { // interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder // runs inside `catch_unwind` so a decode failure is reported rather than crashing. if dxf.len() > Limits::DEFAULT.max_dxf_bytes { - return Err(format!("DXF: the input is {}, larger than the {} CADCraft converts to DWG", mb(dxf.len() as u64), mb(Limits::DEFAULT.max_dxf_bytes as u64))); + return Err(format!( + "DXF: the input is {}, larger than the {} CADCraft converts to DWG", + mb(dxf.len() as u64), + mb(Limits::DEFAULT.max_dxf_bytes as u64) + )); } let data = dxf.to_vec(); let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) diff --git a/crates/engine/src/capped_read.rs b/crates/engine/src/capped_read.rs index d82344bb6..05c7267c9 100644 --- a/crates/engine/src/capped_read.rs +++ b/crates/engine/src/capped_read.rs @@ -44,6 +44,7 @@ pub fn read_capped(path: &Path, max: usize) -> io::Result> { mod tests { use super::*; + /// Regular-file reads accept the byte boundary and reject any larger payload. #[test] fn reads_within_limit_and_rejects_oversize() { let dir = std::env::temp_dir().join(format!("cadcraft-cr-{}", std::process::id())); @@ -57,6 +58,7 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + /// An endless device is rejected before its contents can be buffered. #[cfg(unix)] #[test] fn rejects_non_regular_file() { @@ -66,6 +68,7 @@ mod tests { assert!(read_capped(p, 8 << 20).is_err()); } } + /// Opening a FIFO without a writer returns an error without waiting for a peer. #[cfg(unix)] #[test] fn rejects_fifo_without_a_writer() { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 96b86f4eb..b6998f424 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -337,6 +337,7 @@ fn opened(s: &Session, i: usize, entities: bool) -> Value { } } +/// Open a drawing from bounded regular-file bytes or base64 data and resolve its external references. fn run_open(s: &mut Session, p: &Value) -> Result { if let Some(_path) = str_param(p, "path") { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/lisp/builtins.rs b/crates/engine/src/lisp/builtins.rs index 9ef90f3b6..e421686c0 100644 --- a/crates/engine/src/lisp/builtins.rs +++ b/crates/engine/src/lisp/builtins.rs @@ -698,11 +698,14 @@ fn wcmatch(a: &[Value]) -> R { /// cover), but finite so truly exponential `*`-heavy patterns are rejected rather than hanging. const WCMATCH_BUDGET: u64 = 200_000_000; +/// Spend one wildcard work unit, returning an error when the shared budget is exhausted. fn charge_match(budget: &mut u64) -> Result<(), ()> { *budget = budget.checked_sub(1).ok_or(())?; Ok(()) } +/// Split comma-separated wildcard alternatives while preserving escapes and bracket sets. +/// Every consumed character spends from the same budget used for matching. fn split_patterns(pat: &str, budget: &mut u64) -> Result>, ()> { let mut out = vec![Vec::new()]; let mut chars = pat.chars().peekable(); @@ -742,6 +745,7 @@ fn split_patterns(pat: &str, budget: &mut u64) -> Result>, ()> { Ok(out) } +/// Match one wildcard alternative, including leading `~` negation, within the shared budget. fn match_pattern(text: &[char], pat: &[char], budget: &mut u64) -> Result { if let Some(('~', rest)) = pat.split_first().map(|(c, r)| (*c, r)) && !rest.is_empty() @@ -757,6 +761,7 @@ fn glob(text: &[char], pat: &[char], depth: usize, budget: &mut u64) -> Result R { Ok(if file_exists(&p) { Value::Str(p) } else { Value::Nil }) } +/// Read a regular text file up to 64 MiB, replacing invalid UTF-8 and returning `None` on read failure. #[cfg(not(target_arch = "wasm32"))] pub fn read_text_file(path: &str) -> Option { // Read through a byte-limited, regular-file-only reader so a path naming an endless device @@ -1068,6 +1075,7 @@ pub fn read_text_file(path: &str) -> Option { let bytes = crate::capped_read::read_capped(std::path::Path::new(path), MAX).ok()?; Some(String::from_utf8_lossy(&bytes).into_owned()) } +/// Return `None` on the web, where native filesystem paths are unavailable. #[cfg(target_arch = "wasm32")] pub fn read_text_file(_path: &str) -> Option { None @@ -1588,6 +1596,7 @@ pub fn wild_match(text: &str, pattern: &str) -> bool { mod wildcard_budget_tests { use super::*; + /// Pattern splitting, literal matching, and bracket scans all stop when their shared fuel runs out. #[test] fn all_native_scans_consume_fuel() { assert!(split_patterns("abcdefgh", &mut 4).is_err()); diff --git a/crates/engine/src/lisp/drawing.rs b/crates/engine/src/lisp/drawing.rs index 043b611a3..53c7af868 100644 --- a/crates/engine/src/lisp/drawing.rs +++ b/crates/engine/src/lisp/drawing.rs @@ -158,6 +158,8 @@ pub fn from_json(v: &serde_json::Value) -> Value { const TO_JSON_BUDGET: u64 = 2_000_000; const TO_JSON_MAX_DEPTH: usize = 256; +/// Convert a LISP value to owned JSON within element and nesting limits. +/// Return `None` before a shared cons structure can expand beyond the remaining budget. fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option { if depth > TO_JSON_MAX_DEPTH || *budget == 0 { return None; @@ -185,6 +187,7 @@ pub fn getvar(ctx: &mut Ctx<'_>, a: Vec) -> Result { Ok(Flow::Value(crate::sysvars::get(ctx.s, &name).map(|v| from_json(&v)).unwrap_or_default())) } +/// Set a system variable after bounded JSON conversion, reporting invalid or oversized values as LISP errors. pub fn setvar(ctx: &mut Ctx<'_>, a: Vec) -> Result { arity(&a, 2, 2)?; let name = string(&a[0])?; diff --git a/crates/engine/src/lisp/mod.rs b/crates/engine/src/lisp/mod.rs index 14ac32c6c..47dea46df 100644 --- a/crates/engine/src/lisp/mod.rs +++ b/crates/engine/src/lisp/mod.rs @@ -131,6 +131,7 @@ pub(crate) struct Limits { } impl Default for Limits { + /// Provide finite cons-cell, string-byte, and synchronous-callback limits for a LISP run. fn default() -> Self { Limits { cells: value::MAX_CELLS, string_bytes: value::STRING_BUDGET, callback_steps: 500_000_000 } } diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index 206690560..9104e2525 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -221,6 +221,7 @@ fn runaway_routines_are_errors_not_crashes() { assert_eq!(r, Ok("3000000".into())); } +/// An infinite comparator stops at its callback cap, and a later finite comparator still succeeds. #[test] fn synchronous_callback_loop_is_an_error_not_a_hang() { // A comparator with an infinite loop used to run with u64::MAX fuel and hang the thread; it @@ -232,6 +233,7 @@ fn synchronous_callback_loop_is_an_error_not_a_hang() { assert_eq!(ok(&mut s, "(vl-sort '(3 1 2) '<)"), "(1 2 3)"); } +/// A zero-step callback limit prevents even the first callback side effect. #[test] fn a_zero_callback_budget_does_not_run_the_callback() { let mut s = session(); @@ -242,6 +244,7 @@ fn a_zero_callback_budget_does_not_run_the_callback() { assert_eq!(ok(&mut s, "callback-ran"), "nil"); } +/// A callback stops at a cap smaller than one scheduling slice without completing all its assignments. #[test] fn a_callback_must_fit_within_a_partial_slice() { let mut s = session(); @@ -252,6 +255,7 @@ fn a_callback_must_fit_within_a_partial_slice() { assert_eq!(ok(&mut s, "(and (> callback-count 0) (< callback-count 1000))"), "T"); } +/// Crossing a scheduling boundary grants only the additional configured step, rather than another slice. #[test] fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() { let counts: Vec = [399_999, 400_000] @@ -270,6 +274,7 @@ fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() { assert!(counts[1] >= counts[0] && counts[1] - counts[0] <= 1, "{counts:?}"); } +/// System-variable conversion rejects a shared list whose owned JSON expansion would exceed its budget. #[test] fn setvar_rejects_an_oversized_shared_structure() { // A list repeatedly sharing its own contents expands exponentially; setvar's bounded JSON diff --git a/crates/engine/src/lisp/value.rs b/crates/engine/src/lisp/value.rs index 99f066d7e..09da1d27a 100644 --- a/crates/engine/src/lisp/value.rs +++ b/crates/engine/src/lisp/value.rs @@ -353,6 +353,7 @@ pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool { /// Largest number of node comparisons one `equal` call may perform. const EQUAL_BUDGET: u64 = 50_000_000; +/// Compare nested values within depth and node-work limits, returning false when either limit is reached. fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize, budget: &mut u64) -> bool { if depth > MAX_DEPTH || *budget == 0 { return false; diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 658f5b7b3..6df86f7eb 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -20,7 +20,9 @@ fn main() { Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { println!("cargo::error=CRAFT_FONTS_DIR={}: {}", one_line(&dir.display().to_string()), one_line(&e)); } - Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {}", one_line(&dir.display().to_string()), one_line(&e)), + Err(e) => { + println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {}", one_line(&dir.display().to_string()), one_line(&e)) + } } } src.push_str("];\n"); diff --git a/crates/fonts/src/complex.rs b/crates/fonts/src/complex.rs index fdfef1515..6193f3f80 100644 --- a/crates/fonts/src/complex.rs +++ b/crates/fonts/src/complex.rs @@ -60,6 +60,7 @@ pub(crate) fn shape(primary: Option<&Face>, s: &str, height: f64, width_factor: catch_unwind(AssertUnwindSafe(|| shape_chars(primary, &chars, height, width_factor, oblique))).ok() } +/// Lay out decoded directional runs, keeping advances and decoration spans for empty glyphs. fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64, width_factor: f64, oblique: f64) -> Shaped { let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; diff --git a/crates/fonts/src/mtext.rs b/crates/fonts/src/mtext.rs index 916bdf334..0c6cb074c 100644 --- a/crates/fonts/src/mtext.rs +++ b/crates/fonts/src/mtext.rs @@ -685,6 +685,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout { mod tests { use super::*; + /// Incremental character accounting preserves the text of a multi-word line without introducing wrapping. #[test] fn line_char_offsets_track_incrementally() { // A multi-word line lays out correctly even though the per-line character count is now diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index aedcbb07d..52998900c 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -624,6 +624,7 @@ struct GlyphCache { points: usize, } +/// Return the process-wide outline cache whose retained glyph and point counts are bounded during insertion. fn glyph_cache() -> &'static RwLock { static C: OnceLock> = OnceLock::new(); C.get_or_init(|| RwLock::new(GlyphCache::default())) @@ -643,6 +644,7 @@ struct Pen { const MAX_OUTLINE_POINTS: usize = 65_536; impl Pen { + /// Append one flattened outline point, discarding all contours if the per-glyph point limit is exceeded. fn push(&mut self, point: Vec2) { if self.truncated { return; @@ -661,6 +663,7 @@ impl Pen { fn pt(x: f32, y: f32) -> Vec2 { Vec2::new(f64::from(x), f64::from(y)) } + /// Close and retain a nondegenerate contour only if its closing point also fits the outline budget. fn flush(&mut self) { if self.cur.len() > 2 { if let (Some(f), Some(l)) = (self.cur.first().copied(), self.cur.last().copied()) @@ -678,13 +681,16 @@ impl Pen { } impl OutlinePen for Pen { + /// Finish the preceding contour and begin a new one through the budgeted point writer. fn move_to(&mut self, x: f32, y: f32) { self.flush(); self.push(Self::pt(x, y)); } + /// Append a line endpoint through the budgeted point writer. fn line_to(&mut self, x: f32, y: f32) { self.push(Self::pt(x, y)); } + /// Flatten a quadratic segment into six samples, charging each retained point to the glyph budget. fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { let a = self.cur.last().copied().unwrap_or_default(); let c = Self::pt(cx0, cy0); @@ -695,6 +701,7 @@ impl OutlinePen for Pen { self.push(a * (u * u) + c * (2.0 * u * t) + b * (t * t)); } } + /// Flatten a cubic segment into eight samples, charging each retained point to the glyph budget. fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { let a = self.cur.last().copied().unwrap_or_default(); let c0 = Self::pt(cx0, cy0); @@ -742,6 +749,8 @@ pub fn glyph_contours(face: &Face, gid: u16) -> Arc<[Vec]> { glyph_by_id(face, u32::from(gid)).contours.clone() } +/// Load or build a glyph outline with bounded flattening, retaining its advance even when contours are discarded. +/// Cache insertion clears existing entries when the glyph-count or aggregate point allowance would be exceeded. fn outline(face: &Face, k: GlyphKey) -> Arc { let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, k); if let Some(g) = glyph_cache().read().unwrap_or_else(PoisonError::into_inner).glyphs.get(&key) { @@ -812,6 +821,8 @@ pub(crate) fn text_glyph_at(g: &GlyphOutline, c: char, origin: Vec2, scale: f64, }) } +/// Charge source outline points before copying and transforming a glyph. +/// Return `None` without spending fuel when it cannot fit; empty outlines remain valid at zero budget. pub(crate) fn place_glyph_budgeted(g: &GlyphOutline, x: f64, h: f64, wf: f64, shear: f64, points_left: &mut usize) -> Option>> { *points_left = points_left.checked_sub(g.points)?; Some(place_glyph(g, x, h, wf, shear)) @@ -926,6 +937,7 @@ mod family_tests { #[cfg(test)] mod geometry_budget_tests { use super::*; + /// Placement checks the source point cost before copying and accepts empty outlines without spending points. #[test] fn placement_checks_source_size_and_accepts_empty_glyphs() { let glyph = GlyphOutline { contours: vec![vec![Vec2::ZERO; 4]].into(), points: 4, cap: 1.0, ..Default::default() }; @@ -939,6 +951,7 @@ mod geometry_budget_tests { assert_eq!(budget, 0); assert!(place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut budget).is_none()); } + /// Exceeding the per-glyph flattening limit discards all contour geometry before it reaches the cache. #[test] fn flattened_outlines_are_bounded_before_caching() { let mut pen = Pen::default(); @@ -951,6 +964,7 @@ mod geometry_budget_tests { assert!(pen.contours.is_empty() && pen.cur.is_empty()); assert_eq!(pen.points, MAX_OUTLINE_POINTS); } + /// A mapped empty glyph retains text metadata referring to an existing empty outline slot. #[test] fn mapped_empty_glyph_keeps_text_and_a_valid_outline_slot() { // Metadata placement does not parse the face; an empty face suffices to check the index contract. @@ -963,6 +977,7 @@ mod geometry_budget_tests { assert!(shaped.glyphs[shaped.text[0].outline].is_empty()); } + /// An empty glyph with no text mapping leaves the shaped result empty. #[test] fn unmapped_empty_glyph_does_not_make_the_shape_nonempty() { let glyph = GlyphOutline { cap: 1.0, ..Default::default() }; @@ -973,6 +988,7 @@ mod geometry_budget_tests { assert!(shaped.text.is_empty()); } + /// Missing text metadata does not discard a glyph that has drawable contours. #[test] fn an_unmapped_glyph_with_contours_still_draws() { let mut shaped = Shaped::default(); diff --git a/crates/io/src/dxf_image.rs b/crates/io/src/dxf_image.rs index a4f57a5de..8f4685a21 100644 --- a/crates/io/src/dxf_image.rs +++ b/crates/io/src/dxf_image.rs @@ -156,6 +156,8 @@ pub(crate) fn resolve(secs: &[Section], d: &mut Drawing) { } } +/// Resolve image definitions under a shared copied-string budget. +/// When strings no longer fit, keep applying pixel-size and resolution metadata. fn fill(store: &mut EntityStore, by_image: &HashMap, budget: &mut usize) { let hs: Vec = store.iter().filter(|e| matches!(e.kind, EntityKind::Image(_)) && by_image.contains_key(&e.handle)).map(|e| e.handle).collect(); diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index e8839dc57..bfd1ec0ad 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -248,6 +248,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { entity_with_table_budget(kind, tags, &mut 4_000_000) } +/// Parse one entity while charging materialized table cells to the caller's shared import budget. fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usize) -> Option<(Common, EntityKind)> { // The paper-space viewport our writer adds (dxf_write::paper_view) stays implicit. if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) { @@ -1398,6 +1399,7 @@ fn style_font(tags: &[Tag], file: String) -> String { } } +/// Load symbol tables and resolve handle references, indexing layer names for case-insensitive updates. fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { let recs = records(tags); // STYLE handle → name, or the shape file of an unnamed shape style (linetype elements). @@ -1681,6 +1683,7 @@ pub fn read(bytes: &[u8]) -> Result { read_budgeted(bytes, 4_000_000) } +/// Import DXF sections while sharing one materialized-table-cell budget across blocks and entity sections. fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { let tags = cadcraft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?; let secs = sections(&tags); @@ -2345,9 +2348,11 @@ pub fn entity_from_groups(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKin #[cfg(test)] mod table_budget_tests { use super::*; + /// Build synthetic table tags with a requested row and column count for allocation-budget checks. fn table_tags(rows: i64, cols: i64) -> Vec { vec![Tag::s(100, "AcDbTable"), Tag::i(91, rows), Tag::i(92, cols), Tag::i(171, 1), Tag::s(1, "cell")] } + /// A table that cannot fit allocates no cell grid, while a smaller table spends only its cell count. #[test] fn exhausted_budget_rejects_before_materializing_cells() { let mut left = 3; @@ -2358,6 +2363,7 @@ mod table_budget_tests { assert_eq!(table.cells[0].len(), 2); assert_eq!(left, 1); } + /// Tables in blocks and repeated entity sections consume one document-wide cell allowance. #[test] fn blocks_and_entity_sections_share_one_budget() { let table = "0\nACAD_TABLE\n100\nAcDbTable\n91\n1\n92\n2\n171\n1\n1\ncell\n"; diff --git a/crates/io/src/svg_read.rs b/crates/io/src/svg_read.rs index 8e5552168..1d150fc8b 100644 --- a/crates/io/src/svg_read.rs +++ b/crates/io/src/svg_read.rs @@ -172,6 +172,7 @@ impl LeftOut { } impl Parser { + /// Initialize a metric SVG drawing with one document-wide generated-vertex allowance. fn new() -> Self { Parser { drawing: Drawing::new_metric(), @@ -334,6 +335,7 @@ impl Parser { self.cur().apply_vec(v) } + /// Charge an entity against element and retained-vertex limits before adding it to model space. fn add(&mut self, kind: EntityKind) -> Result<()> { self.elements += 1; if self.elements > MAX_ELEMENTS { diff --git a/crates/io/tests/svg_arc_budget.rs b/crates/io/tests/svg_arc_budget.rs index b3b096e33..dbcc97150 100644 --- a/crates/io/tests/svg_arc_budget.rs +++ b/crates/io/tests/svg_arc_budget.rs @@ -17,6 +17,7 @@ fn arc_bomb(arcs: usize) -> Vec { svg.into_bytes() } +/// A small transformed-arc input remains accepted within the generated-geometry budget. #[test] fn a_modest_arc_svg_still_reads() { // A handful of arcs is well within budget and parses fine. @@ -24,6 +25,7 @@ fn a_modest_arc_svg_still_reads() { assert!(d.model.iter().count() >= 1, "expected some geometry"); } +/// Repeated flattened arcs fail with a geometry-budget error before retaining excessive vertices. #[test] fn an_arc_heavy_svg_is_rejected_by_the_vertex_budget() { // ~1000 arcs x up to 10_000 chords each would far exceed the 8M vertex budget. diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index c758bac97..b843ebbd5 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -10,9 +10,11 @@ use serde_json::{Value, json}; /// Something that answers control-channel methods (`engine.execute`, `cmdline.input`, /// `drawing.inspect`, `ui.render`…). See `cadcraft_ui_egui::control` for the full list. pub trait Backend { + /// Invoke a control method and return its result or a descriptive error. fn call(&mut self, method: &str, params: Value) -> Result; /// True when a real UI is attached (`ui.screenshot` works). fn has_ui(&self) -> bool; + /// Describe the backend connection for client-facing status. fn describe(&self) -> String; } @@ -37,6 +39,7 @@ fn port_of(addr: &str) -> Option { const LAUNCH_WAIT: Duration = Duration::from_secs(30); impl Remote { + /// Connect to a running control server; its session token is read lazily when the first request is sent. pub fn connect(addr: &str) -> std::io::Result { let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false, token: None }; r.reconnect()?; @@ -182,6 +185,8 @@ fn is_read_only(method: &str) -> bool { } impl Backend for Remote { + /// Send an authenticated control request, refreshing the token for a safe retry. + /// A mutation that may have reached the server is never resent automatically. fn call(&mut self, method: &str, params: Value) -> Result { let id = self.next_id; self.next_id += 1; @@ -447,6 +452,7 @@ mod tests { server.join().unwrap(); } } + /// Retrying a read after a lost reply reloads the replacement server session token. #[test] fn safe_retry_rereads_a_rotated_token_after_a_lost_reply() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -473,6 +479,7 @@ mod tests { assert_eq!(remote.call("ping", json!({})).unwrap(), json!("pong")); server.join().unwrap(); } + /// A failed mutation write reports uncertain delivery and does not open a retry connection. #[test] fn failed_mutation_write_is_not_resent() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/mcp/src/control_token.rs b/crates/mcp/src/control_token.rs index 84677ae4a..084f8b455 100644 --- a/crates/mcp/src/control_token.rs +++ b/crates/mcp/src/control_token.rs @@ -23,6 +23,7 @@ pub fn token_path(port: u16) -> io::Result { Ok(private_dir()?.join(format!("cadcraft-control-{port}.token"))) } +/// Choose the Unix runtime directory, home cache, or temporary directory for an owner-only token file. #[cfg(unix)] fn private_dir() -> io::Result { if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) { @@ -34,11 +35,13 @@ fn private_dir() -> io::Result { Ok(std::env::temp_dir()) } +/// Require the Windows per-user local app-data directory for session token storage. #[cfg(windows)] fn private_dir() -> io::Result { windows_private_dir(std::env::var_os("LOCALAPPDATA")) } +/// Validate that Windows supplied a nonempty local app-data path instead of using a shared fallback. #[cfg(any(windows, test))] fn windows_private_dir(local: Option) -> io::Result { local @@ -47,6 +50,7 @@ fn windows_private_dir(local: Option) -> io::Result .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "LOCALAPPDATA is required for a private control token")) } +/// Reject token storage on platforms without a supported private-directory convention. #[cfg(not(any(unix, windows)))] fn private_dir() -> io::Result { Err(io::Error::new(io::ErrorKind::Unsupported, "no private control token directory on this platform")) @@ -61,6 +65,7 @@ pub fn issue(port: u16) -> io::Result { Ok(token) } +/// Create missing parent directories, then write the session token through the platform privacy helper. fn issue_at(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; @@ -98,6 +103,7 @@ pub fn matches(expected: &str, given: &str) -> bool { diff == 0 } +/// Encode 256 bits of operating-system randomness as lowercase hexadecimal, propagating entropy failures. fn random_hex() -> io::Result { let mut bytes = [0u8; TOKEN_BYTES]; getrandom::fill(&mut bytes).map_err(|e| io::Error::other(format!("no secure randomness: {e}")))?; @@ -109,6 +115,7 @@ fn random_hex() -> io::Result { Ok(s) } +/// Replace a stale token with an exclusively created file using Unix mode `0600`, then flush its bytes. #[cfg(unix)] fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> { use std::io::Write; @@ -120,6 +127,7 @@ fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> { f.flush() } +/// Exclusively create and flush a replacement token within the per-user directory selected by the caller. #[cfg(not(unix))] fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> { use std::io::Write; @@ -134,6 +142,7 @@ fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> { mod tests { use super::*; + /// An issued token has the expected size, can be read back, and disappears after revocation. #[test] fn issue_then_read_round_trips() { let port = 50000 + (std::process::id() % 10000) as u16; @@ -144,6 +153,7 @@ mod tests { assert!(read(port).is_err()); } + /// Token comparison rejects different bytes and lengths while accepting identical tokens. #[test] fn matches_is_exact() { assert!(matches("abc", "abc")); @@ -152,6 +162,7 @@ mod tests { assert!(!matches("", "x")); } + /// A token issued on Unix grants no file permission bits to group or other users. #[cfg(unix)] #[test] fn token_file_is_owner_only() { @@ -162,6 +173,7 @@ mod tests { assert_eq!(mode & 0o077, 0, "token file is readable by group/other: {:o}", mode); revoke(port); } + /// Windows token storage rejects absent or empty app-data paths and preserves a supplied path. #[test] fn windows_requires_local_app_data() { assert!(windows_private_dir(None).is_err()); @@ -169,6 +181,7 @@ mod tests { assert_eq!(windows_private_dir(Some("private-app-data".into())).unwrap(), PathBuf::from("private-app-data")); } + /// Token issuance creates its missing directory hierarchy before writing the credential. #[test] fn issuance_creates_missing_parent() { let dir = std::env::temp_dir().join(format!("cadcraft-token-parent-{}", std::process::id())); diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index e79b7b4e6..3a14aabc8 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -413,6 +413,7 @@ fn tol_height_code(st: &DimStyle) -> String { format!("{}x", format_decimal(f / 0.7, 4, 8, ".")) } +/// Apply measurement placeholders or append a dimension suffix, bounding the resulting UTF-8 text. fn post(template: &str, meas: &str) -> String { if template.contains("<>") { bounded_replace(template, meas) @@ -424,6 +425,7 @@ fn post(template: &str, meas: &str) -> String { } } +/// Apply dimension text overrides: empty uses the base, whitespace suppresses text, and other text substitutes it. fn apply_override(base: &str, d: &Dimension) -> String { if d.text.is_empty() { base.to_string() @@ -455,6 +457,8 @@ fn bounded_replace(template: &str, value: &str) -> String { } // Returns false when truncation consumed the remaining UTF-8-safe space. +/// Append within the remaining dimension-text byte allowance without splitting a UTF-8 character. +/// Return false if any input text had to be truncated. fn append_dim_text(out: &mut String, text: &str) -> bool { let mut end = text.len().min(MAX_DIM_TEXT.saturating_sub(out.len())); while !text.is_char_boundary(end) { @@ -1363,12 +1367,14 @@ mod tests { #[cfg(test)] mod text_budget_tests { use super::*; + /// A dimension suffix without placeholders still obeys the total text limit. #[test] fn bounds_post_without_markers() { let text = post(&"x".repeat(MAX_DIM_TEXT * 2), "12"); assert_eq!(text.len(), MAX_DIM_TEXT); assert!(text.starts_with("12x")); } + /// Oversized substituted text retains its prefix and ends at a valid UTF-8 boundary. #[test] fn oversized_value_is_substituted_and_truncated_at_a_character_boundary() { let text = bounded_replace("prefix <> mm", &"é".repeat(MAX_DIM_TEXT)); diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index b297618ab..dd83a71a2 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -79,6 +79,8 @@ fn same_polygon(a: &[Vec2], b: &[Vec2]) -> bool { (0..n).any(|k| (0..n).all(|i| near(a[i], b[(i + k) % n]))) || (0..n).any(|k| (0..n).all(|i| near(a[i], b[(k + n - (i % n)) % n]))) } +/// Remove degenerate and duplicate boundary loops within a shared comparison budget. +/// Retain remaining loops without further comparisons once the budget is exhausted. fn dedup_polygons(loops: &mut Vec>) { // `same_polygon` is O(n²) in a polygon's vertex count (every cyclic alignment, both windings), // and each loop is compared against every unique one kept so far, so adversarial boundary @@ -463,6 +465,7 @@ impl Family<'_> { self.origin + p.rotate(self.ang) } + /// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits. fn sweep(&self, loops: &[Vec], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec>, work: &mut u64) { let local: Vec> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect(); let bb = Bounds2::from_points(local.iter().flatten().copied()); @@ -606,6 +609,7 @@ mod tests { #[cfg(test)] mod dedup_budget_tests { use super::*; + /// Loops with different vertex counts leave enough comparison fuel to remove a later duplicate. #[test] fn unequal_loops_do_not_spend_the_comparison_budget() { // The old estimate exhausted the budget on this unequal pair before seeing the triangle. diff --git a/crates/render/src/linetype.rs b/crates/render/src/linetype.rs index 8a8cab347..0c999caa1 100644 --- a/crates/render/src/linetype.rs +++ b/crates/render/src/linetype.rs @@ -118,6 +118,7 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option Vec> { } // No partial coverage may reach owner assignment: it would revisit unbounded merge rectangles. +/// Mark cells covered by merges under grid-size and marking-work limits. +/// Return partial coverage as an error so layout can use its outline fallback instead of assigning owners. fn coverage(t: &Table) -> Result>, Vec>> { let rows = t.row_heights.len().min(10_000); let cols = t.col_widths.len().min(10_000); @@ -395,6 +397,7 @@ mod budget_tests { use super::*; use cadcraft_doc::TableCell; + /// Excessive overlapping merges draw only the table outline after coverage work is exhausted. #[test] fn overlapping_merges_use_outline_fallback() { let t = Table { @@ -409,6 +412,7 @@ mod budget_tests { assert!(g.fills.is_empty() && g.texts.is_empty()); } + /// A split table clips merge spans using the rendered column count when source columns exceed the limit. #[test] fn part_clips_merges_using_displayed_column_count() { let t = Table { diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs index a5b6e7dfe..c2c6de3d2 100644 --- a/xtask/src/stats.rs +++ b/xtask/src/stats.rs @@ -14,11 +14,14 @@ struct Counts { /// never make the stats command run unbounded work. const MAX_ENTRIES: usize = 2_000_000; +/// Accumulate Rust source counts under one directory with a fresh traversal-entry budget. fn walk(dir: &Path, c: &mut Counts) -> Result<(), String> { let mut budget = MAX_ENTRIES; walk_inner(dir, c, &mut budget) } +/// Traverse entries without following symlinks, sharing one budget across descendant directories. +/// Skip unreadable entries and excluded directories; report budget exhaustion to the caller. fn walk_inner(dir: &Path, c: &mut Counts, budget: &mut usize) -> Result<(), String> { let Ok(rd) = std::fs::read_dir(dir) else { return Ok(()); @@ -84,6 +87,7 @@ fn exact_tests(pkg: &str) -> Option { Some(String::from_utf8_lossy(&out.stdout).lines().filter(|l| l.ends_with(": test")).count()) } +/// Print per-package source statistics, optionally querying exact test counts, and propagate traversal errors. pub fn run(_root: &Path, exact: bool) -> Result<(), String> { let meta = crate::metadata()?; let mut rows = Vec::new(); @@ -138,6 +142,7 @@ mod tests { assert_eq!(count_tests(src), (2, 1)); } + /// A directory alias does not cause its Rust source to be counted twice. #[cfg(unix)] #[test] fn walk_does_not_follow_directory_symlinks() { @@ -154,6 +159,7 @@ mod tests { assert_eq!(c.files, 1, "the aliased directory was followed and counted twice"); std::fs::remove_dir_all(&base).ok(); } + /// Traversal reports an exhausted entry budget and accepts a tree that exactly fits it. #[test] fn walk_reports_budget_exhaustion() { let base = std::env::temp_dir().join(format!("cadcraft-stats-budget-{}", std::process::id())); From bad1073fdf10a521949f6b542837b564719d0023 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 20:06:04 -0400 Subject: [PATCH 39/57] Release 0.12.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ed706014..2c24364ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -547,7 +547,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadcraft" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -571,7 +571,7 @@ dependencies = [ [[package]] name = "cadcraft-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -582,14 +582,14 @@ dependencies = [ [[package]] name = "cadcraft-color" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-constraints" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cadcraft-doc", "cadcraft-geom", @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "cadcraft-doc" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cadcraft-color", "cadcraft-geom", @@ -610,14 +610,14 @@ dependencies = [ [[package]] name = "cadcraft-dwg" -version = "0.11.0" +version = "0.12.0" dependencies = [ "acadrust", ] [[package]] name = "cadcraft-dxf" -version = "0.11.0" +version = "0.12.0" dependencies = [ "encoding_rs", "log", @@ -626,7 +626,7 @@ dependencies = [ [[package]] name = "cadcraft-engine" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cadcraft-color", "cadcraft-constraints", @@ -647,7 +647,7 @@ dependencies = [ [[package]] name = "cadcraft-fonts" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cadcraft-geom", "harfrust", @@ -657,14 +657,14 @@ dependencies = [ [[package]] name = "cadcraft-geom" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-io" -version = "0.11.0" +version = "0.12.0" dependencies = [ "acadrust", "cadcraft-color", @@ -684,7 +684,7 @@ dependencies = [ [[package]] name = "cadcraft-mcp" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -696,7 +696,7 @@ dependencies = [ [[package]] name = "cadcraft-render" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -710,7 +710,7 @@ dependencies = [ [[package]] name = "cadcraft-ui-egui" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -733,7 +733,7 @@ dependencies = [ [[package]] name = "cadcraft-web" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -4825,7 +4825,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index c87f38ffe..7ac093d06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] # default-members [workspace.package] -version = "0.11.0" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From fa6bb0351bfa93716504a5b37aa9b539bd575d01 Mon Sep 17 00:00:00 2001 From: skylartaylor <26588250+skylartaylor@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:14:11 +0200 Subject: [PATCH 40/57] docs: clarify security regression guarantees --- crates/constraints/src/model.rs | 2 +- crates/mcp/src/backend.rs | 2 +- crates/render/src/linetype.rs | 2 +- crates/render/src/table.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs index b7fe14322..ba7e58548 100644 --- a/crates/constraints/src/model.rs +++ b/crates/constraints/src/model.rs @@ -623,7 +623,7 @@ mod budget_tests { use cadcraft_geom::Vec3; use std::sync::Arc; - /// Writing a model back preserves partial rational weights and the spline they describe. + /// Writing an unchanged model back leaves partial rational weights and the original spline geometry intact. #[test] fn write_back_keeps_partial_rational_weights() { let mut drawing = Drawing::default(); diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index b843ebbd5..693839335 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -185,7 +185,7 @@ fn is_read_only(method: &str) -> bool { } impl Backend for Remote { - /// Send an authenticated control request, refreshing the token for a safe retry. + /// Send a control request with the token when available, refreshing it before a safe retry. /// A mutation that may have reached the server is never resent automatically. fn call(&mut self, method: &str, params: Value) -> Result { let id = self.next_id; diff --git a/crates/render/src/linetype.rs b/crates/render/src/linetype.rs index 0c999caa1..1385a4d3d 100644 --- a/crates/render/src/linetype.rs +++ b/crates/render/src/linetype.rs @@ -118,7 +118,7 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option Date: Thu, 22 Oct 2026 23:54:32 -0400 Subject: [PATCH 41/57] chore(deps): bump the cargo-dependencies group across 1 directory with 17 updates (#1957) Bumps the cargo-dependencies group with 17 updates in the / directory: | Package | From | To | | --- | --- | --- | | [serde_json](https://github.com/serde-rs/json) | `1.0.151` | `1.0.154` | | [tiny-skia](https://github.com/linebender/tiny-skia) | `0.11.4` | `0.12.0` | | [skrifa](https://github.com/googlefonts/fontations) | `0.47.0` | `0.48.0` | | [harfrust](https://github.com/harfbuzz/harfrust) | `0.12.0` | `0.14.0` | | [quick-xml](https://github.com/tafia/quick-xml) | `0.41.0` | `0.42.0` | | [egui](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` | | [egui_kittest](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` | | [eframe](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` | | [egui-wgpu](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` | | [arboard](https://github.com/1Password/arboard) | `3.6.1` | `3.7.0` | | [acadrust](https://github.com/hakanaktt/acadrust) | `0.6.4` | `0.6.5` | | [rstar](https://github.com/georust/rstar) | `0.12.2` | `0.13.0` | | [libc](https://github.com/rust-lang/libc) | `0.2.190` | `0.2.191` | | [miniz_oxide](https://github.com/Frommi/miniz_oxide) | `0.8.9` | `0.9.1` | | [getrandom](https://github.com/rust-random/getrandom) | `0.3.4` | `0.4.3` | | [muda](https://github.com/tauri-apps/muda) | `0.21.0` | `0.21.2` | | [winresource](https://github.com/BenjaminRi/winresource) | `0.1.31` | `0.1.34` | Updates `serde_json` from 1.0.151 to 1.0.154 - [Release notes](https://github.com/serde-rs/json/releases) - [Commits](https://github.com/serde-rs/json/compare/v1.0.151...v1.0.154) Updates `tiny-skia` from 0.11.4 to 0.12.0 - [Changelog](https://github.com/linebender/tiny-skia/blob/main/CHANGELOG.md) - [Commits](https://github.com/linebender/tiny-skia/compare/v0.11.4...v0.12.0) Updates `skrifa` from 0.47.0 to 0.48.0 - [Release notes](https://github.com/googlefonts/fontations/releases) - [Changelog](https://github.com/googlefonts/fontations/blob/main/docs/RELEASE.md) - [Commits](https://github.com/googlefonts/fontations/compare/skrifa-v0.47.0...skrifa-v0.48.0) Updates `harfrust` from 0.12.0 to 0.14.0 - [Release notes](https://github.com/harfbuzz/harfrust/releases) - [Changelog](https://github.com/harfbuzz/harfrust/blob/main/CHANGELOG.md) - [Commits](https://github.com/harfbuzz/harfrust/compare/0.12.0...0.14.0) Updates `quick-xml` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/tafia/quick-xml/releases) - [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md) - [Commits](https://github.com/tafia/quick-xml/compare/v0.41.0...v0.42.0) Updates `egui` from 0.36.2 to 0.37.0 - [Release notes](https://github.com/emilk/egui/releases) - [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md) - [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0) Updates `egui_kittest` from 0.36.2 to 0.37.0 - [Release notes](https://github.com/emilk/egui/releases) - [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md) - [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0) Updates `eframe` from 0.36.2 to 0.37.0 - [Release notes](https://github.com/emilk/egui/releases) - [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md) - [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0) Updates `egui-wgpu` from 0.36.2 to 0.37.0 - [Release notes](https://github.com/emilk/egui/releases) - [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md) - [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0) Updates `arboard` from 3.6.1 to 3.7.0 - [Release notes](https://github.com/1Password/arboard/releases) - [Commits](https://github.com/1Password/arboard/compare/v3.6.1...v3.7.0) Updates `acadrust` from 0.6.4 to 0.6.5 - [Release notes](https://github.com/hakanaktt/acadrust/releases) - [Changelog](https://github.com/hakanaktt/acadrust/blob/main/CHANGELOG.md) - [Commits](https://github.com/hakanaktt/acadrust/compare/v0.6.4...v0.6.5) Updates `rstar` from 0.12.2 to 0.13.0 - [Commits](https://github.com/georust/rstar/compare/v0.12.2...v0.13.0) Updates `libc` from 0.2.190 to 0.2.191 - [Release notes](https://github.com/rust-lang/libc/releases) - [Changelog](https://github.com/rust-lang/libc/blob/0.2.191/CHANGELOG.md) - [Commits](https://github.com/rust-lang/libc/compare/0.2.190...0.2.191) Updates `miniz_oxide` from 0.8.9 to 0.9.1 - [Changelog](https://github.com/Frommi/miniz_oxide/blob/master/CHANGELOG.md) - [Commits](https://github.com/Frommi/miniz_oxide/commits) Updates `getrandom` from 0.3.4 to 0.4.3 - [Changelog](https://github.com/rust-random/getrandom/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-random/getrandom/compare/v0.3.4...v0.4.3) Updates `muda` from 0.21.0 to 0.21.2 - [Release notes](https://github.com/tauri-apps/muda/releases) - [Changelog](https://github.com/tauri-apps/muda/blob/dev/CHANGELOG.md) - [Commits](https://github.com/tauri-apps/muda/compare/muda-v0.21...muda-v0.21.2) Updates `winresource` from 0.1.31 to 0.1.34 - [Commits](https://github.com/BenjaminRi/winresource/commits) --- updated-dependencies: - dependency-name: acadrust dependency-version: 0.6.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-dependencies - dependency-name: arboard dependency-version: 3.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: eframe dependency-version: 0.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: egui dependency-version: 0.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: egui-wgpu dependency-version: 0.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: egui_kittest dependency-version: 0.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: getrandom dependency-version: 0.4.3 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: harfrust dependency-version: 0.14.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: libc dependency-version: 0.2.191 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-dependencies - dependency-name: miniz_oxide dependency-version: 0.9.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: muda dependency-version: 0.21.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-dependencies - dependency-name: quick-xml dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: rstar dependency-version: 0.13.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: serde_json dependency-version: 1.0.154 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-dependencies - dependency-name: skrifa dependency-version: 0.48.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: tiny-skia dependency-version: 0.12.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: winresource dependency-version: 0.1.34 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 690 +++++++++++++++++++++------------------ Cargo.toml | 16 +- crates/engine/Cargo.toml | 2 +- crates/io/Cargo.toml | 2 +- crates/mcp/Cargo.toml | 2 +- 5 files changed, 380 insertions(+), 332 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c24364ab..f1bf96a72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,20 +4,20 @@ version = 4 [[package]] name = "acadrust" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94862189cab6e282ed0433a801a216cd1a712953813284a48fa81c67223e38f6" +checksum = "5eaa72520f70eb6f7f09b06f44e1ed0d8fad5fa743364f2c3715c30df962ec7c" dependencies = [ "anyhow", "bitflags 2.13.2", "byteorder", "encoding_rs", "flate2", - "foldhash", + "foldhash 0.2.0", "indexmap", "itoa", "memmap2", - "nom", + "nom 7.1.3", "oem_cp", "once_cell", "rayon", @@ -31,6 +31,15 @@ name = "accesskit" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "enumn", "serde", @@ -39,57 +48,51 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ - "accesskit", - "accesskit_consumer 0.36.0", + "accesskit 0.25.1", + "accesskit_consumer", "atspi-common", - "phf 0.13.1", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit 0.25.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ - "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit 0.25.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ - "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit 0.25.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -97,11 +100,11 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ - "accesskit", + "accesskit 0.25.1", "accesskit_atspi_common", "async-channel", "async-executor", @@ -115,13 +118,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ - "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit 0.25.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -129,11 +132,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ - "accesskit", + "accesskit 0.25.1", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -209,9 +213,9 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", @@ -223,7 +227,8 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.61.2", + "wl-clipboard-rs", "x11rb", ] @@ -555,7 +560,7 @@ dependencies = [ "cadcraft-ui-egui", "chrono", "eframe", - "egui", + "egui 0.37.0", "hidapi", "log", "muda", @@ -650,8 +655,8 @@ name = "cadcraft-fonts" version = "0.12.0" dependencies = [ "cadcraft-geom", - "harfrust", - "skrifa 0.47.0", + "harfrust 0.14.0", + "skrifa 0.48.0", "unicode-bidi", ] @@ -674,8 +679,8 @@ dependencies = [ "cadcraft-fonts", "cadcraft-geom", "cadcraft-render", - "miniz_oxide 0.8.9", - "quick-xml", + "miniz_oxide 0.9.1", + "quick-xml 0.42.0", "serde", "serde_json", "thiserror 2.0.21", @@ -689,7 +694,7 @@ dependencies = [ "cadcraft-engine", "cadcraft-io", "cadcraft-render", - "getrandom 0.3.4", + "getrandom 0.4.3", "log", "serde_json", ] @@ -720,7 +725,7 @@ dependencies = [ "cadcraft-render", "chrono", "craft-ui", - "egui", + "egui 0.37.0", "egui-wgpu", "egui_kittest", "image", @@ -739,7 +744,7 @@ dependencies = [ "cadcraft-io", "cadcraft-ui-egui", "eframe", - "egui", + "egui 0.37.0", "log", "serde_json", "wasm-bindgen", @@ -761,42 +766,18 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "calloop" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" -dependencies = [ - "bitflags 2.13.2", - "polling", - "rustix 1.1.5", - "tracing", -] - [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop 0.13.0", + "calloop", "rustix 0.38.44", "wayland-backend", "wayland-client", ] -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop 0.14.5", - "rustix 1.1.5", - "wayland-backend", - "wayland-client", -] - [[package]] name = "cc" version = "1.6.0" @@ -953,7 +934,7 @@ name = "craft-ui" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" dependencies = [ - "egui", + "egui 0.36.2", "serde", ] @@ -1089,22 +1070,31 @@ name = "ecolor" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "emath 0.36.2", +] + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2" dependencies = [ "bytemuck", - "emath", + "emath 0.37.0", "serde", ] [[package]] name = "eframe" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84" dependencies = [ "ahash", "bytemuck", "document-features", - "egui", + "egui 0.37.0", "egui-wgpu", "egui-winit", "egui_glow", @@ -1122,6 +1112,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1138,11 +1129,31 @@ version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" dependencies = [ - "accesskit", + "accesskit 0.24.1", "ahash", "bitflags 2.13.2", - "emath", - "epaint", + "emath 0.36.2", + "epaint 0.36.2", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f" +dependencies = [ + "accesskit 0.25.1", + "ahash", + "bitflags 2.13.2", + "emath 0.37.0", + "epaint 0.37.0", "itertools", "log", "nohash-hasher", @@ -1156,15 +1167,15 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +checksum = "0309cbe1febed48af5b974e4bcc424b35e9664863a4d402697147b57a488c864" dependencies = [ "ahash", "bytemuck", "document-features", - "egui", - "epaint", + "egui 0.37.0", + "epaint 0.37.0", "log", "profiling", "thiserror 2.0.21", @@ -1176,35 +1187,37 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", - "egui", + "egui 0.37.0", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", "serde", - "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +checksum = "9275ca5cc35a6b7c94e0e8cedf22542f66cb0cf45ed8ab2657c78ff49fec7268" dependencies = [ "bytemuck", - "egui", + "egui 0.37.0", "glow", "log", "profiling", @@ -1213,13 +1226,13 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +checksum = "0979c2e37860fcf13a2bea77508607f067a9a6a84ba9bd47b5e298582fb59b86" dependencies = [ "dify", "eframe", - "egui", + "egui 0.37.0", "egui-wgpu", "image", "kittest", @@ -1243,6 +1256,12 @@ name = "emath" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" + +[[package]] +name = "emath" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5" dependencies = [ "bytemuck", "serde", @@ -1305,14 +1324,37 @@ name = "epaint" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "ecolor 0.36.2", + "emath 0.36.2", + "font-types", + "harfrust 0.12.0", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa 0.44.0", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu 0.1.0", +] + +[[package]] +name = "epaint" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd" dependencies = [ "ahash", "bytemuck", - "ecolor", - "emath", + "ecolor 0.37.0", + "emath 0.37.0", "epaint_default_fonts", "font-types", - "harfrust", + "harfrust 0.12.0", "log", "nohash-hasher", "parking_lot", @@ -1322,15 +1364,16 @@ dependencies = [ "skrifa 0.44.0", "smallvec", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu", + "vello_cpu 0.2.0", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19" [[package]] name = "equivalent" @@ -1416,6 +1459,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1433,6 +1482,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1441,9 +1496,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "font-types" -version = "0.12.6" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", "serde", @@ -1600,13 +1655,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", "skrifa 0.44.0", "smallvec", - "vello_common", + "vello_common 0.1.0", +] + +[[package]] +name = "glifo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa 0.44.0", + "smallvec", + "vello_common 0.2.0", ] [[package]] @@ -1734,6 +1805,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "harfrust" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86aa776d6d32b072119e1f1650f4db843ff4382a4aaa6a2c9c4e35703cd377cb" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts 0.45.1", + "smallvec", +] + [[package]] name = "hash32" version = "0.3.1" @@ -1743,6 +1826,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1751,7 +1843,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1760,7 +1852,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1914,7 +2006,7 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", - "png 0.18.1", + "png", "tiff", "zune-core", "zune-jpeg", @@ -2072,12 +2164,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ - "accesskit", - "accesskit_consumer 0.35.0", + "accesskit 0.25.1", + "accesskit_consumer", ] [[package]] @@ -2100,9 +2192,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.190" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -2271,9 +2363,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", @@ -2282,8 +2374,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "once_cell", - "png 0.18.1", + "png", "thiserror 2.0.21", "windows-sys 0.61.2", ] @@ -2390,6 +2481,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2787,6 +2887,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2835,6 +2945,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.11.3" @@ -2846,33 +2967,33 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", - "phf_shared 0.13.1", + "phf_shared 0.14.0", "serde", ] [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared 0.13.1", + "phf_shared 0.14.0", ] [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", - "phf_shared 0.13.1", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -2889,9 +3010,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -2945,19 +3066,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide 0.8.9", -] - [[package]] name = "png" version = "0.18.1" @@ -3081,6 +3189,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -3159,13 +3276,15 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.44.0" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" +checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef" dependencies = [ "bytemuck", + "foldhash 0.2.0", "font-types", "once_cell", + "yoke", ] [[package]] @@ -3244,9 +3363,9 @@ dependencies = [ [[package]] name = "rstar" -version = "0.12.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe" dependencies = [ "heapless", "num-traits", @@ -3345,6 +3464,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -3377,9 +3502,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -3464,12 +3589,12 @@ dependencies = [ [[package]] name = "skrifa" -version = "0.47.0" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f" +checksum = "a5213b4825bac3587046330052d61bfac7bd3bd0d203c758d7c2ef726fcea034" dependencies = [ "bytemuck", - "read-fonts 0.44.0", + "read-fonts 0.45.1", ] [[package]] @@ -3503,8 +3628,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.2", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", + "calloop", + "calloop-wayland-source", "cursor-icon", "libc", "log", @@ -3521,44 +3646,6 @@ dependencies = [ "xkeysym", ] -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.13.2", - "calloop 0.14.5", - "calloop-wayland-source 0.4.1", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.5", - "thiserror 2.0.21", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit 0.20.0", - "wayland-backend", -] - [[package]] name = "smol_str" version = "0.2.2" @@ -3715,24 +3802,24 @@ dependencies = [ [[package]] name = "tiny-skia" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" dependencies = [ "arrayref", "arrayvec", "bytemuck", "cfg-if", "log", - "png 0.17.16", + "png", "tiny-skia-path", ] [[package]] name = "tiny-skia-path" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" dependencies = [ "arrayref", "bytemuck", @@ -3815,7 +3902,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3841,6 +3927,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -3885,6 +3982,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -3941,6 +4044,21 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "vello_common" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + [[package]] name = "vello_cpu" version = "0.1.0" @@ -3948,9 +4066,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" dependencies = [ "bytemuck", - "glifo", + "glifo 0.2.0", "hashbrown 0.17.1", - "vello_common", + "vello_common 0.1.0", +] + +[[package]] +name = "vello_cpu" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" +dependencies = [ + "bytemuck", + "glifo 0.3.0", + "hashbrown 0.17.1", + "vello_common 0.2.0", ] [[package]] @@ -4094,32 +4224,6 @@ dependencies = [ "wayland-scanner", ] -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -4153,7 +4257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -4510,7 +4614,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4519,16 +4623,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -4546,31 +4641,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -4588,96 +4666,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -4690,7 +4720,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "bytemuck", - "calloop 0.13.0", + "calloop", "cfg_aliases", "concurrent-queue", "core-foundation", @@ -4711,7 +4741,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", - "smithay-client-toolkit 0.19.2", + "smithay-client-toolkit", "smol_str", "tracing", "unicode-segmentation", @@ -4740,9 +4770,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -4754,6 +4784,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index 7ac093d06..958318b38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,24 +37,24 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" # rendering / formats -tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] } +tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd", "png-format"] } lyon_tessellation = "1.0.22" -skrifa = "0.47" +skrifa = "0.48" # Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; the version egui's epaint uses) # and the Unicode Bidirectional Algorithm for right-to-left drawing text. -harfrust = "0.12" +harfrust = "0.14" unicode-bidi = "0.3.18" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } encoding_rs = "0.8.35" # XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones) -quick-xml = "0.41" +quick-xml = "0.42" # ui craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -egui = "0.36" -egui_kittest = "0.36" -eframe = { version = "0.36", default-features = false } -egui-wgpu = "0.36" +egui = "0.37" +egui_kittest = "0.37" +eframe = { version = "0.37", default-features = false } +egui-wgpu = "0.37" rfd = "0.17" arboard = { version = "3.6", default-features = false } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 04be29199..8be55a2af 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -22,7 +22,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } # R-tree spatial index for picking, window selection and object snaps (MIT OR Apache-2.0). -rstar = { version = "0.12", default-features = false } +rstar = { version = "0.13", default-features = false } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 84adfec4f..b056603c1 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -19,7 +19,7 @@ cadcraft-fonts = { workspace = true } thiserror = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } -miniz_oxide = "0.8" +miniz_oxide = "0.9" quick-xml = { workspace = true } [dev-dependencies] diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index c44529554..955a6b6d2 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -15,4 +15,4 @@ cadcraft-io = { workspace = true } serde_json = { workspace = true } log = { workspace = true } # Secure randomness for the per-session control token (MIT OR Apache-2.0). -getrandom = "0.3" +getrandom = "0.4" From 611eb9f5a92ea70c3a19ea964d521232184f8649 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 08:44:31 +0200 Subject: [PATCH 42/57] fonts: port complex-text shaping to harfrust 0.14 --- crates/fonts/src/complex.rs | 47 ++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/crates/fonts/src/complex.rs b/crates/fonts/src/complex.rs index 6193f3f80..1d76f813e 100644 --- a/crates/fonts/src/complex.rs +++ b/crates/fonts/src/complex.rs @@ -8,6 +8,7 @@ use std::ops::Range; use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::Arc; use cadcraft_geom::Vec2; @@ -182,28 +183,31 @@ struct Glyph { /// Shape characters `r` of `cs` (one direction, one face) into glyphs in display order. fn shape_run(face: &Face, cs: &[char], r: Range, rtl: bool) -> Vec { let run = cs.get(r.clone()).unwrap_or_default(); - let Ok(font) = harfrust::FontRef::from_index(&face.bytes, face.index) else { return Vec::new() }; - let data = harfrust::ShaperData::new(&font); - let shaper = data.shaper(&font).build(); - let mut buf = harfrust::UnicodeBuffer::new(); + // Shares the face's bytes instead of copying them. + let bytes: Arc + Send + Sync> = face.bytes.clone(); + let Some(font) = harfrust::Font::new(bytes, face.index) else { return Vec::new() }; + let shaper = harfrust::ShaperFont::new(&font); + let mut buf = harfrust::Buffer::new(); let arabic = run.iter().any(|c| matches!(*c, '\u{0600}'..='\u{08FF}')); if arabic && !face.has_joining_features() { for (c, i) in bidi::presentation_forms(run, |p| face.has_glyph(p)) { - buf.add(c, u32::try_from(r.start + i).unwrap_or(u32::MAX)); + buf.push(u32::from(c), u32::try_from(r.start + i).unwrap_or(u32::MAX)); } } else { for (i, c) in run.iter().enumerate() { - buf.add(*c, u32::try_from(r.start + i).unwrap_or(u32::MAX)); + buf.push(u32::from(*c), u32::try_from(r.start + i).unwrap_or(u32::MAX)); } } buf.set_direction(if rtl { harfrust::Direction::RightToLeft } else { harfrust::Direction::LeftToRight }); buf.guess_segment_properties(); - let shaped = shaper.shape(buf, harfrust::ShapeOptions::new()); - let k = 1000.0 / f64::from(shaper.units_per_em().max(1)); - shaped - .glyph_infos() + // Without a plan, shaping fails only on an already shaped buffer or an unset direction. + if harfrust::shape(&shaper, &mut buf, harfrust::ShapeOptions::new()).is_err() { + return Vec::new(); + } + let k = 1000.0 / f64::from(font.units_per_em().max(1)); + buf.glyph_infos() .iter() - .zip(shaped.glyph_positions()) + .zip(buf.glyph_positions()) .map(|(gi, gp)| Glyph { gid: gi.glyph_id, cluster: usize::try_from(gi.cluster).unwrap_or(usize::MAX), @@ -291,6 +295,27 @@ mod tests { assert!((crate::line_width("قیمت 25 تومان", 1.0, 1.0) - sh.width).abs() < 1e-9); } + #[test] + fn devanagari_run_is_reordered_with_line_clusters_and_advances_per_1000_em() { + use skrifa::MetadataProvider; + use skrifa::instance::{LocationRef, Size}; + let Some(face) = ttf::fallback_for('\u{0915}') else { return }; + let Some(ka) = nominal(&face, '\u{0915}') else { return }; + // "कि" after two other characters of the line: the vowel sign i is drawn before the + // consonant (Indic reordering, which needs the script the shaper guesses from the text), + // both glyphs carry the cluster of the consonant's index in the line, not in the run. + let cs: Vec = "ab\u{0915}\u{093F}".chars().collect(); + let gs = shape_run(&face, &cs, 2..4, false); + let gids: Vec = gs.iter().map(|g| g.gid).collect(); + assert_eq!(gids.len(), 2, "{gids:?}"); + assert!(gids.first() != Some(&ka) && gids.last() == Some(&ka), "vowel sign before ka: {gids:?}"); + assert!(gs.iter().all(|g| g.cluster == 2), "{:?}", gs.iter().map(|g| g.cluster).collect::>()); + // Advances come out at 1000 units per em, whatever the font's own em. + let hmtx = face.font().and_then(|f| f.glyph_metrics(Size::new(1000.0), LocationRef::default()).advance_width(skrifa::GlyphId::new(ka))); + let adv = gs.last().map(|g| g.advance); + assert!(matches!((adv, hmtx), (Some(a), Some(h)) if (a - f64::from(h)).abs() < 1e-3), "{adv:?} vs {hmtx:?}"); + } + #[test] fn hostile_rtl_lines_never_panic() { for s in ["\u{202E}%%u\u{05D0}%%", "\u{2067}\u{0644}\u{0627}\u{2069}%%o", "\u{064E}\u{064E}\u{05D0}", "\u{05D0}\u{E000}\u{10FFFF}"] { From e61e328edb1e08a3f3f767dc9391e6abc716331a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 08:50:32 +0200 Subject: [PATCH 43/57] io: read SVG element and attribute names with quick-xml 0.42 --- crates/io/src/svg_read.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/io/src/svg_read.rs b/crates/io/src/svg_read.rs index 1d150fc8b..4b2892c3d 100644 --- a/crates/io/src/svg_read.rs +++ b/crates/io/src/svg_read.rs @@ -207,15 +207,15 @@ impl Parser { match reader.read_event().map_err(parse_err)? { Event::Start(e) => { let name = e.name(); - self.on_start(name.as_ref(), &e)?; + self.on_start(name.into_inner().as_bytes(), &e)?; } Event::Empty(e) => { let name = e.name(); - self.on_element(name.as_ref(), &e)?; + self.on_element(name.into_inner().as_bytes(), &e)?; } Event::End(e) => { let name = e.name(); - self.on_end(name.as_ref()); + self.on_end(name.into_inner().as_bytes()); } Event::Eof => break, _ => {} @@ -578,7 +578,7 @@ fn hidden(e: &BytesStart) -> bool { fn attr(e: &BytesStart, name: &[u8]) -> Option { for a in e.attributes().flatten() { - if a.key.as_ref() == name { + if a.key.into_inner().as_bytes() == name { return a.normalized_value(XmlVersion::Explicit1_0).ok().map(|v| v.into_owned()); } } From f344cf4460be9916958c97639d8a1d1833e03c03 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 08:50:32 +0200 Subject: [PATCH 44/57] engine: pass the query envelope by value to rstar 0.13 --- crates/engine/src/spatial.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/spatial.rs b/crates/engine/src/spatial.rs index 98999b71f..ca15bd4c4 100644 --- a/crates/engine/src/spatial.rs +++ b/crates/engine/src/spatial.rs @@ -208,7 +208,7 @@ impl SpatialIndex { let pad = (b.width() + b.height()) * 1e-9 + (b.min.x.abs() + b.min.y.abs() + b.max.x.abs() + b.max.y.abs()) * 1e-12 + 1e-300; let env = AABB::from_corners([b.min.x - pad, b.min.y - pad], [b.max.x + pad, b.max.y + pad]); let mut out: Vec = - self.tree.locate_in_envelope_intersecting(&env).filter_map(|it| self.offsets.get(&it.data.0).map(|off| off + it.data.1)).collect(); + self.tree.locate_in_envelope_intersecting(env).filter_map(|it| self.offsets.get(&it.data.0).map(|off| off + it.data.1)).collect(); if with_infinite { out.extend_from_slice(&self.infinite); } From 5648a06ac438c54fff1392f17d57448e83e31817 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 09:14:51 +0200 Subject: [PATCH 45/57] Hold the egui crates at 0.36 until craft-ui moves to 0.37 (craft-libs#199) --- Cargo.lock | 489 ++++++++++++++++++++--------------------------------- Cargo.toml | 8 +- 2 files changed, 192 insertions(+), 305 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f1bf96a72..e5076e82e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,11 +13,11 @@ dependencies = [ "byteorder", "encoding_rs", "flate2", - "foldhash 0.2.0", + "foldhash", "indexmap", "itoa", "memmap2", - "nom 7.1.3", + "nom", "oem_cp", "once_cell", "rayon", @@ -31,15 +31,6 @@ name = "accesskit" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" -dependencies = [ - "uuid", -] - -[[package]] -name = "accesskit" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "enumn", "serde", @@ -48,51 +39,57 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.21.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" dependencies = [ - "accesskit 0.25.1", - "accesskit_consumer", + "accesskit", + "accesskit_consumer 0.36.0", "atspi-common", - "phf 0.14.0", + "phf 0.13.1", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.39.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" dependencies = [ - "accesskit 0.25.1", - "hashbrown 0.17.1", + "accesskit", + "hashbrown 0.16.1", ] [[package]] -name = "accesskit_ios" -version = "0.2.1" +name = "accesskit_consumer" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" dependencies = [ - "accesskit 0.25.1", - "accesskit_consumer", - "hashbrown 0.17.1", - "objc2 0.5.3", - "objc2-foundation 0.2.2", - "objc2-ui-kit 0.2.2", + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", ] [[package]] name = "accesskit_macos" -version = "0.27.1" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" dependencies = [ - "accesskit 0.25.1", - "accesskit_consumer", - "hashbrown 0.17.1", + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -100,11 +97,11 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.24.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" dependencies = [ - "accesskit 0.25.1", + "accesskit", "accesskit_atspi_common", "async-channel", "async-executor", @@ -118,13 +115,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.35.1" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" dependencies = [ - "accesskit 0.25.1", - "accesskit_consumer", - "hashbrown 0.17.1", + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", "static_assertions", "windows", "windows-core", @@ -132,12 +129,11 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.34.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" dependencies = [ - "accesskit 0.25.1", - "accesskit_ios", + "accesskit", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -228,7 +224,6 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.61.2", - "wl-clipboard-rs", "x11rb", ] @@ -560,7 +555,7 @@ dependencies = [ "cadcraft-ui-egui", "chrono", "eframe", - "egui 0.37.0", + "egui", "hidapi", "log", "muda", @@ -725,7 +720,7 @@ dependencies = [ "cadcraft-render", "chrono", "craft-ui", - "egui 0.37.0", + "egui", "egui-wgpu", "egui_kittest", "image", @@ -744,7 +739,7 @@ dependencies = [ "cadcraft-io", "cadcraft-ui-egui", "eframe", - "egui 0.37.0", + "egui", "log", "serde_json", "wasm-bindgen", @@ -766,18 +761,42 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop", + "calloop 0.13.0", "rustix 0.38.44", "wayland-backend", "wayland-client", ] +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cc" version = "1.6.0" @@ -934,7 +953,7 @@ name = "craft-ui" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" dependencies = [ - "egui 0.36.2", + "egui", "serde", ] @@ -1070,31 +1089,22 @@ name = "ecolor" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" -dependencies = [ - "emath 0.36.2", -] - -[[package]] -name = "ecolor" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2" dependencies = [ "bytemuck", - "emath 0.37.0", + "emath", "serde", ] [[package]] name = "eframe" -version = "0.37.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" dependencies = [ "ahash", "bytemuck", "document-features", - "egui 0.37.0", + "egui", "egui-wgpu", "egui-winit", "egui_glow", @@ -1112,7 +1122,6 @@ dependencies = [ "profiling", "raw-window-handle", "ron", - "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1129,31 +1138,11 @@ version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" dependencies = [ - "accesskit 0.24.1", + "accesskit", "ahash", "bitflags 2.13.2", - "emath 0.36.2", - "epaint 0.36.2", - "itertools", - "log", - "nohash-hasher", - "profiling", - "smallvec", - "unicode-segmentation", - "web-sys", -] - -[[package]] -name = "egui" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f" -dependencies = [ - "accesskit 0.25.1", - "ahash", - "bitflags 2.13.2", - "emath 0.37.0", - "epaint 0.37.0", + "emath", + "epaint", "itertools", "log", "nohash-hasher", @@ -1167,15 +1156,15 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.37.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0309cbe1febed48af5b974e4bcc424b35e9664863a4d402697147b57a488c864" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" dependencies = [ "ahash", "bytemuck", "document-features", - "egui 0.37.0", - "epaint 0.37.0", + "egui", + "epaint", "log", "profiling", "thiserror 2.0.21", @@ -1187,37 +1176,35 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.37.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" dependencies = [ "accesskit_winit", "arboard", - "block2 0.6.2", "bytemuck", - "egui 0.37.0", + "egui", "log", "objc2 0.6.5", - "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", "serde", + "smithay-clipboard", "web-time", "webbrowser", - "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.37.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9275ca5cc35a6b7c94e0e8cedf22542f66cb0cf45ed8ab2657c78ff49fec7268" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" dependencies = [ "bytemuck", - "egui 0.37.0", + "egui", "glow", "log", "profiling", @@ -1226,13 +1213,13 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.37.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0979c2e37860fcf13a2bea77508607f067a9a6a84ba9bd47b5e298582fb59b86" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" dependencies = [ "dify", "eframe", - "egui 0.37.0", + "egui", "egui-wgpu", "image", "kittest", @@ -1256,12 +1243,6 @@ name = "emath" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" - -[[package]] -name = "emath" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5" dependencies = [ "bytemuck", "serde", @@ -1324,34 +1305,11 @@ name = "epaint" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" -dependencies = [ - "ahash", - "ecolor 0.36.2", - "emath 0.36.2", - "font-types", - "harfrust 0.12.0", - "log", - "nohash-hasher", - "parking_lot", - "profiling", - "self_cell", - "skrifa 0.44.0", - "smallvec", - "unicode-general-category", - "unicode-segmentation", - "vello_cpu 0.1.0", -] - -[[package]] -name = "epaint" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd" dependencies = [ "ahash", "bytemuck", - "ecolor 0.37.0", - "emath 0.37.0", + "ecolor", + "emath", "epaint_default_fonts", "font-types", "harfrust 0.12.0", @@ -1364,16 +1322,15 @@ dependencies = [ "skrifa 0.44.0", "smallvec", "unicode-general-category", - "unicode-properties", "unicode-segmentation", - "vello_cpu 0.2.0", + "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.37.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" [[package]] name = "equivalent" @@ -1459,12 +1416,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - [[package]] name = "flate2" version = "1.1.10" @@ -1482,12 +1433,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1655,29 +1600,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - "foldhash 0.2.0", + "foldhash", "hashbrown 0.17.1", "log", "peniko", "skrifa 0.44.0", "smallvec", - "vello_common 0.1.0", -] - -[[package]] -name = "glifo" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" -dependencies = [ - "bytemuck", - "foldhash 0.2.0", - "hashbrown 0.17.1", - "log", - "peniko", - "skrifa 0.44.0", - "smallvec", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -1826,15 +1755,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -1843,7 +1763,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1852,7 +1772,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -2164,12 +2084,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.5.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" dependencies = [ - "accesskit 0.25.1", - "accesskit_consumer", + "accesskit", + "accesskit_consumer 0.35.0", ] [[package]] @@ -2481,15 +2401,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2887,16 +2798,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "os_pipe" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "parking" version = "2.2.1" @@ -2945,17 +2846,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", -] - [[package]] name = "phf" version = "0.11.3" @@ -2967,33 +2857,33 @@ dependencies = [ [[package]] name = "phf" -version = "0.14.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared 0.14.0", + "phf_shared 0.13.1", "serde", ] [[package]] name = "phf_generator" -version = "0.14.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared 0.14.0", + "phf_shared 0.13.1", ] [[package]] name = "phf_macros" -version = "0.14.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", - "phf_shared 0.14.0", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.119", @@ -3010,9 +2900,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.14.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ "siphasher", ] @@ -3281,7 +3171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef" dependencies = [ "bytemuck", - "foldhash 0.2.0", + "foldhash", "font-types", "once_cell", "yoke", @@ -3464,12 +3354,6 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - [[package]] name = "serde" version = "1.0.229" @@ -3628,8 +3512,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.2", - "calloop", - "calloop-wayland-source", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", "cursor-icon", "libc", "log", @@ -3646,6 +3530,44 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + [[package]] name = "smol_str" version = "0.2.2" @@ -3902,6 +3824,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3927,17 +3850,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "tree_magic_mini" -version = "3.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" -dependencies = [ - "memchr", - "nom 8.0.0", - "petgraph", -] - [[package]] name = "type-map" version = "0.5.1" @@ -3982,12 +3894,6 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4044,21 +3950,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "vello_common" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" -dependencies = [ - "bytemuck", - "fearless_simd", - "guillotiere", - "log", - "peniko", - "smallvec", - "thiserror 2.0.21", -] - [[package]] name = "vello_cpu" version = "0.1.0" @@ -4066,21 +3957,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" dependencies = [ "bytemuck", - "glifo 0.2.0", + "glifo", "hashbrown 0.17.1", - "vello_common 0.1.0", -] - -[[package]] -name = "vello_cpu" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" -dependencies = [ - "bytemuck", - "glifo 0.3.0", - "hashbrown 0.17.1", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -4224,6 +4103,32 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -4720,7 +4625,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "bytemuck", - "calloop", + "calloop 0.13.0", "cfg_aliases", "concurrent-queue", "core-foundation", @@ -4741,7 +4646,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", - "smithay-client-toolkit", + "smithay-client-toolkit 0.19.2", "smol_str", "tracing", "unicode-segmentation", @@ -4784,24 +4689,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wl-clipboard-rs" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" -dependencies = [ - "libc", - "log", - "os_pipe", - "rustix 1.1.5", - "thiserror 2.0.21", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", -] - [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index 958318b38..a0aea6574 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,10 +51,10 @@ quick-xml = "0.42" # ui craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -egui = "0.37" -egui_kittest = "0.37" -eframe = { version = "0.37", default-features = false } -egui-wgpu = "0.37" +egui = "0.36" +egui_kittest = "0.36" +eframe = { version = "0.36", default-features = false } +egui-wgpu = "0.36" rfd = "0.17" arboard = { version = "3.6", default-features = false } From 9621dcbd9442606f640e489d664733c85a3e5679 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 09:30:10 +0200 Subject: [PATCH 46/57] dwg: drop the dimension bit-0 inversion that acadrust 0.6.5 now does itself --- crates/dwg/src/lib.rs | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index 273229247..f8a48f2a0 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -107,7 +107,6 @@ mod native { current_style_names(&mut doc); table_merges(&mut doc); table_style_rows(&mut doc, false); - dimension_flags(&mut doc, false); let orphans = drop_orphaned_dgn_components(&mut doc); if orphans > 0 { let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") }; @@ -291,24 +290,14 @@ mod native { } } - /// The DWG dimension flags byte. Its bit 0 is the opposite of DXF group 70's bit 128 (set: the - /// text is at its default position) and its bit 1 is group 70's bit 32 (ODA DWG - /// specification, common dimension data), while acadrust 0.6.4 reads and writes bit 0 as - /// `text_user_positioned` itself and keeps no bit 32 from DXF. Without this, every dimension - /// of a DWG CADCraft writes is user-positioned for AutoCAD (#1094), and every dimension of an - /// AutoCAD DWG is read as user-positioned. `to_dwg` sets the fields up for the writer: - /// bit 32 as CADCraft's DXF writer always sets it, and bit 0 through the inverted field; - /// otherwise the field is read from bit 0 after a DWG is read. - fn dimension_flags(doc: &mut acadrust::CadDocument, to_dwg: bool) { + /// The DWG dimension flags byte. Its bit 1 is DXF group 70's bit 32 (ODA DWG specification, + /// common dimension data), which acadrust keeps no copy of from DXF: it is set here as + /// CADCraft's DXF writer always sets it (#1094). Bit 0, the opposite of group 70's bit 128, + /// acadrust 0.6.5 reads and writes itself. + fn dimension_flags(doc: &mut acadrust::CadDocument) { for e in doc.entities_mut() { let acadrust::entities::EntityType::Dimension(d) = e else { continue }; - let b = d.base_mut(); - if to_dwg { - b.dwg_flags_byte |= 0x02; - b.text_user_positioned = !b.text_user_positioned; - } else { - b.text_user_positioned = b.dwg_flags_byte & 0x01 == 0; - } + d.base_mut().dwg_flags_byte |= 0x02; } } @@ -549,7 +538,7 @@ mod native { } other_layout_owners(&mut doc); table_style_rows(&mut doc, true); - dimension_flags(&mut doc, true); + dimension_flags(&mut doc); drop_unwritable(&mut doc); current_dimstyle(&mut doc); current_text_style(&mut doc); From 51db2d52e417578159bdbd0c1820e2d7a905ba2a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 09:49:38 +0200 Subject: [PATCH 47/57] dependabot: leave the egui crates at 0.36 until craft-ui moves to 0.37 (craft-libs#199) --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2c4b8cb1a..093325ba7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,6 +14,17 @@ updates: - "*" commit-message: prefix: "chore(deps)" + # craft-ui is still on egui 0.36, and two eguis in the graph don't build (#1990). Remove this + # once craft-ui moves to egui 0.37 (storytold/craft-libs#199). + ignore: + - dependency-name: "egui" + versions: [">=0.37"] + - dependency-name: "eframe" + versions: [">=0.37"] + - dependency-name: "egui-wgpu" + versions: [">=0.37"] + - dependency-name: "egui_kittest" + versions: [">=0.37"] # Maintain GitHub Actions workflows - package-ecosystem: "github-actions" From 251fddb658a606cd950573fe576bd9894f41c429 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 09:49:38 +0200 Subject: [PATCH 48/57] fonts: harfrust comment names both versions; the advance check skips fonts at 1000 per em --- Cargo.toml | 4 ++-- crates/fonts/src/complex.rs | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a0aea6574..cabb8a06c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,8 +40,8 @@ rayon = "1" tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd", "png-format"] } lyon_tessellation = "1.0.22" skrifa = "0.48" -# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; the version egui's epaint uses) -# and the Unicode Bidirectional Algorithm for right-to-left drawing text. +# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; egui's epaint is still on 0.12, +# so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text. harfrust = "0.14" unicode-bidi = "0.3.18" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } diff --git a/crates/fonts/src/complex.rs b/crates/fonts/src/complex.rs index 1d76f813e..dafabdc7c 100644 --- a/crates/fonts/src/complex.rs +++ b/crates/fonts/src/complex.rs @@ -310,8 +310,13 @@ mod tests { assert_eq!(gids.len(), 2, "{gids:?}"); assert!(gids.first() != Some(&ka) && gids.last() == Some(&ka), "vowel sign before ka: {gids:?}"); assert!(gs.iter().all(|g| g.cluster == 2), "{:?}", gs.iter().map(|g| g.cluster).collect::>()); - // Advances come out at 1000 units per em, whatever the font's own em. - let hmtx = face.font().and_then(|f| f.glyph_metrics(Size::new(1000.0), LocationRef::default()).advance_width(skrifa::GlyphId::new(ka))); + // Advances come out at 1000 units per em, whatever the font's own em. A font that is + // already at 1000 per em could not tell a missing scale apart, so it checks nothing here. + let Some(font) = face.font() else { return }; + if font.metrics(Size::unscaled(), LocationRef::default()).units_per_em == 1000 { + return; + } + let hmtx = font.glyph_metrics(Size::new(1000.0), LocationRef::default()).advance_width(skrifa::GlyphId::new(ka)); let adv = gs.last().map(|g| g.advance); assert!(matches!((adv, hmtx), (Some(a), Some(h)) if (a - f64::from(h)).abs() < 1e-3), "{adv:?} vs {hmtx:?}"); } From 8ad9a3ce158a983c5aa4146a5503ddca81bb54f5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 14:54:19 +0200 Subject: [PATCH 49/57] dwg: require acadrust 0.6.5, the first version that inverts dimension bit 0 itself --- crates/dwg/Cargo.toml | 2 +- crates/io/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/dwg/Cargo.toml b/crates/dwg/Cargo.toml index 398df0a35..a409e5b90 100644 --- a/crates/dwg/Cargo.toml +++ b/crates/dwg/Cargo.toml @@ -10,4 +10,4 @@ workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE). -acadrust = "0.6.4" +acadrust = "0.6.5" diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index b056603c1..395ec4966 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -24,5 +24,5 @@ quick-xml = { workspace = true } [dev-dependencies] # Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's). -acadrust = "0.6.4" +acadrust = "0.6.5" tiny-skia = { workspace = true } From 78decf80f79ead81870e4826c51b765779d90aef Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:16:34 -0400 Subject: [PATCH 50/57] web: allow browser zoom in the viewport meta (SonarCloud Web:S7926) (#1974) user-scalable=no stops people with low vision from zooming the page. The canvas keeps its own pinch/pan handling: eframe already prevent_default()s touch and gesture events on the canvas, so dropping the flag only restores browser zoom (and accessibility zoom) outside of the app's gestures. --- apps/cadcraft-web/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cadcraft-web/index.html b/apps/cadcraft-web/index.html index c8e2e9603..6cd4ab971 100644 --- a/apps/cadcraft-web/index.html +++ b/apps/cadcraft-web/index.html @@ -2,7 +2,7 @@ - + CADCraft