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
@@ -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));+}@@ -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 => {