fix(control): reject unknown entities_transform actions instead of panicking

Bug:
control_entities_transform validated the action field only inside the
transform closure - and invoked that closure from three of the match's
arms. Any other action fell into unreachable!("validated by
transform()") and aborted the process. The claim in the panic message
was false: for unknown actions the validating code never ran. The handler
is reachable from every remote surface (REST, MCP, --serve) because they
share this dispatch. Two extra triggers: "move " (trailing space, no
trim) and an all-whitespace action.

Reproduce:
1. Start OpenCADStudio with --http 8765.
2. POST /api/v1/entities (a LINE), take handles[0].
3. POST /api/v1/entities/transform with
   {"handles":["<handle>"],"action":"explode"}.
4. Before: "internal error: entered unreachable code" and the process
   exits.
5. After: {"ok":false,"code":"invalid_action",...} and the server keeps
   serving. "action":"move " is now accepted (trimmed); "   " is
   rejected.

Fix:
The action is trimmed, lowercased and checked against the six-value
whitelist after require_existing (error precedence unchanged: a bad
handle still reports first) but before push_undo_snapshot, so an invalid
request no longer dirties the undo stack. The match's fallback arm
returns the same invalid_action error instead of unreachable!, so a
future mismatch between the whitelist and the match fails closed.
This commit is contained in:
Karim Jerbi committed 2026-09-30 19:15:49 +01:00
1 parent be8a7b95c4
commit ee188a6e3c
2 files changed
+64 -2

No files matched your search

+24 -2
View File
@@ -334,9 +334,24 @@ impl OpenCADStudio {
req: &Value,
) -> Result<Task<Message>, 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<EntityTransform> {
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!({
+40
View File
@@ -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}");
}
}