diff --git a/src/app/control/entities.rs b/src/app/control/entities.rs index 8e6ce324d..7eb5acd64 100644 --- a/src/app/control/entities.rs +++ b/src/app/control/entities.rs @@ -334,9 +334,24 @@ impl OpenCADStudio { req: &Value, ) -> Result, Value> { let handles = hex_handles(req, "handles")?; - let action = req["action"].as_str().unwrap_or("move").to_ascii_lowercase(); + let action = req["action"] + .as_str() + .unwrap_or("move") + .trim() + .to_ascii_lowercase(); let i = self.active_tab; require_existing(&self.tabs[i].scene.document, &handles)?; + if !matches!( + action.as_str(), + "move" | "copy" | "rotate" | "scale" | "mirror" | "array" + ) { + return Err(failure( + "invalid_action", + format!( + "Unknown transform action '{action}'. Use move, copy, rotate, scale, mirror or array" + ), + )); + } let transform = |req: &Value| -> Parsed { Ok(match action.as_str() { @@ -473,7 +488,14 @@ impl OpenCADStudio { } affected = handles.len(); } - _ => unreachable!("validated by transform()"), + other => { + return Err(failure( + "invalid_action", + format!( + "Unknown transform action '{other}'. Use move, copy, rotate, scale, mirror or array" + ), + )) + } } self.post_ref_op(i); self.set_control_result(json!({ diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index f2af51592..9fb65a887 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -2271,4 +2271,44 @@ mod tests { let _ = std::fs::remove_file(&pdf); let _ = std::fs::remove_file(&plain); } + + /// An unknown `entities_transform` action must come back as a clean + /// validation error. Pre-fix it reached + /// `unreachable!("validated by transform()")` (entities.rs:476) and + /// killed the whole process — over REST, MCP and `--serve` alike — + /// because validation lived inside the `transform` closure, which the + /// dispatch match only invokes from some of its arms. + #[test] + fn entities_transform_rejects_unknown_actions_without_panicking() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,0"})); + let lines = app.control_request(json!({"op":"query","type":"Line"})).0; + let handle = lines["entities"][0]["handle"].as_str().unwrap().to_string(); + + // Not one of the six whitelisted actions. + let rejected = request(&mut app, json!({ + "op":"entities_transform", + "handles":[handle], + "action":"explode" + })); + assert_eq!(rejected["code"], "invalid_action", "{rejected}"); + + // Whitespace-only is unknown too — not a panic. + let blank = request(&mut app, json!({ + "op":"entities_transform", + "handles":[handle], + "action":" " + })); + assert_eq!(blank["code"], "invalid_action", "{blank}"); + + // A valid action is matched case- and whitespace-insensitively. + let moved = request(&mut app, json!({ + "op":"entities_transform", + "handles":[handle], + "action":" move ", + "vector":[5,0,0] + })); + assert_eq!(moved["ok"], true, "{moved}"); + } }