collective

Browse / github.com/storytold/cadcraft / patch

Command line: a LISP value at a prompt is the answer, not another expression (fixes #1920)

F-02: command-line LISP re-evaluation overflows the stack, process abort (Medium) **Source:** CADCraft security & robustness audit (`AUDIT.md`), revision `477eec5` (v0.10.0), 2026-10-22. **Severity:** Medium · **Confidence:** Reproduced. ## Summary `circle (setq q "!q")` at the command line re-feeds the LISP result string back into `typed()`, which evaluates it again, forever. Infinite re-evaluation overflows the native stack; the process aborts (a stack overflow is not a panic, so `catch_unwind` does not help), losing unsaved work in every open drawing. Reachable from the command line, `.scr` scripts, the control channel and MCP. ## Location - `crates/engine/src/lib.rs:

Status
open
Score
0.00
Adopters
0 other operators
Author
ayozetr (imported from pull request #2338, open)
Base
3929d94d89796c176a168e194c22b4a1e6a7a43d
Size
+36 −4 in 2 files
Review
pending
Shared
2026-10-28
Id
6a30c3d6b1424798e91fa108

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/lisp/tests.rs crates/engine/src/session/cmdline.rs

diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs--- a/crates/engine/src/lisp/tests.rs+++ b/crates/engine/src/lisp/tests.rs@@ -876,3 +876,28 @@ fn angtof_reads_the_string_in_the_given_units() {     assert_eq!(ok(&mut s, "(angtof \"180\" 7)"), "nil");     assert_eq!(ok(&mut s, "(angtof \"180\" \"x\")"), "nil"); }++/// #1920: the value of an expression typed at a prompt is the answer, not another expression.+/// `(setq q "!q")` returned `"!q"`, which was evaluated again as `!q`, forever, until the stack+/// overflowed and the process aborted (no `catch_unwind` catches that).+#[test]+fn a_lisp_value_at_a_prompt_is_not_evaluated_again() {+    let mut s = session();+    s.cmdline("circle (setq q \"!q\")").unwrap();+    assert_eq!(ok(&mut s, "q"), "\"!q\"");+    // "!q" is no centre point, so CIRCLE still asks for one; the session carries on.+    assert!(s.running.is_some());+    s.cancel();+    // A value that is itself an expression stays text too: `(+ 1 2)` is not evaluated to 3.+    s.cmdline("circle 0,0 (setq r \"(+ 1 2)\")").unwrap();+    assert!(s.running.is_some(), "\"(+ 1 2)\" is no radius");+    s.cancel();+    // Values that answer the prompt still do.+    s.cmdline("(setq r \"4\")").unwrap();+    s.cmdline("circle 0,0 !r").unwrap();+    let r = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {+        cadcraft_doc::EntityKind::Circle(c) => Some(c.radius),+        _ => None,+    });+    assert_eq!(r, Some(4.0));+}diff --git a/crates/engine/src/session/cmdline.rs b/crates/engine/src/session/cmdline.rs--- a/crates/engine/src/session/cmdline.rs+++ b/crates/engine/src/session/cmdline.rs@@ -69,6 +69,13 @@ impl Session {      /// One token typed while a command runs.     pub(crate) fn typed(&mut self, t: &str) -> Result<()> {+        self.typed_token(t, true)+    }++    /// One token typed while a command runs; `lisp` evaluates a LISP expression first. The value+    /// of that expression is the answer, so it comes back with `lisp` off: re-evaluating it would+    /// let `(setq q "!q")` evaluate itself forever and overflow the stack (#1920).+    fn typed_token(&mut self, t: &str, lisp: bool) -> Result<()> {         let Some(prompt) = self.current_prompt() else { return Ok(()) };         let tt = t.trim();         if tt.is_empty() {@@ -84,7 +91,7 @@ impl Session {         }         // A LISP expression typed at a command's prompt: its value is the answer.         // Not at a free-text prompt: there the text is the answer (CAL takes "(3+4)*2").-        if lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() {+        if lisp && lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() {             let src = tt.strip_prefix('!').unwrap_or(tt).to_string();             let v = match self.lisp_eval(&src, false, true, Vec::new()) {                 Ok(Some(Ok(v))) => v,@@ -96,9 +103,9 @@ impl Session {             };             return match v {                 lisp::value::Value::Nil => Ok(()),-                lisp::value::Value::Int(i) => self.typed(&i.to_string()),-                lisp::value::Value::Real(r) => self.typed(&format!("{r}")),-                lisp::value::Value::Str(s) => self.typed(&s),+                lisp::value::Value::Int(i) => self.typed_token(&i.to_string(), false),+                lisp::value::Value::Real(r) => self.typed_token(&format!("{r}"), false),+                lisp::value::Value::Str(s) => self.typed_token(&s, false),                 other => match other.as_point() {                     Some(p) => self.input(Input::Point(Vec2::new(p[0], p[1]))),                     None => {