Commands: pointer events refuse pen values and spaces of the wrong type

ui.pointer and the headless pointer call read a pointer event's pressure,
holdMs, tiltX, tiltY and rotation with a default for any value that was not a
number, and treated any space other than screen as document space. Each event
is now checked before the packet runs: those values must be numbers and space
must be doc or screen, with null keeping the default. docs/mcp.md says so.
This commit is contained in:
dotCooCoo
2026-11-11 21:04:01 -08:00
parent 7c28138ce3
commit e83c36785e
6 changed files with 52 additions and 4 deletions
+1
View File
@@ -239,6 +239,7 @@ impl Headless {
let mods = packet_mods(e)?.unwrap_or(base);
let kind = s(e, "kind").unwrap_or("");
let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?;
PointerEvent::json_check(e)?;
if s(e, "space") == Some("screen") {
return Err("screen-space pointer events need the desktop app; use document coordinates".to_string());
}
+5
View File
@@ -1474,6 +1474,11 @@ fn headless_methods_refuse_params_of_the_wrong_type() {
// A whole rectangle comes before the malformed event, and none of it is drawn.
("ui.pointer", after_rect(json!({"kind": "move", "x": "bad", "y": 0})), "x"),
("ui.pointer", after_rect(json!({"kind": "wiggle", "x": 0, "y": 0})), "wiggle"),
("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "pressure": "bad"})), "pressure"),
("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "holdMs": true})), "holdMs"),
("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "tiltX": "5"})), "tiltX"),
("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "rotation": [0]})), "rotation"),
("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "space": "screeen"})), "space"),
] {
let e = h.call(method, params.clone()).unwrap_err();
assert!(e.contains(&format!("`{key}`")), "{method} {params}: {e}");
+34
View File
@@ -109,6 +109,19 @@ impl PointerEvent {
pub fn json_backquote(v: &Value, default: bool) -> Result<bool, String> {
v.get("backquote").map_or(Ok(default), |b| b.as_bool().ok_or_else(|| "`backquote` must be a boolean".to_owned()))
}
/// Checks the optional keys of a JSON pointer event (control channel, MCP): `pressure`,
/// `holdMs`, `tiltX`, `tiltY` and `rotation` must be numbers, and `space` must be `doc` or
/// `screen`. A key that is absent or null keeps its default.
pub fn json_check(e: &Value) -> Result<(), String> {
let given = |k: &str| e.get(k).filter(|v| !v.is_null());
if let Some(k) = ["pressure", "holdMs", "tiltX", "tiltY", "rotation"].into_iter().find(|k| given(k).is_some_and(|v| v.as_f64().is_none())) {
return Err(format!("`{k}` must be a number"));
}
if given("space").is_some_and(|v| !matches!(v.as_str(), Some("doc" | "screen"))) {
return Err("`space` must be doc or screen".to_owned());
}
Ok(())
}
}
#[cfg(test)]
@@ -135,6 +148,27 @@ mod tests {
assert!(PointerEvent::json_backquote(&json!({"backquote": bad}), false).is_err(), "{bad}");
}
}
#[test]
fn json_check_refuses_pen_values_and_spaces_of_the_wrong_type() {
use serde_json::json;
for ok in [
json!({}),
json!({"pressure": 0.5, "holdMs": 10, "tiltX": -5, "tiltY": 5, "rotation": 400, "space": "screen"}),
json!({"pressure": null, "space": null}),
json!({"space": "doc"}),
] {
assert_eq!(PointerEvent::json_check(&ok), Ok(()), "{ok}");
}
for k in ["pressure", "holdMs", "tiltX", "tiltY", "rotation"] {
for bad in [json!("1"), json!(true), json!([1])] {
assert_eq!(PointerEvent::json_check(&json!({k: bad})), Err(format!("`{k}` must be a number")), "{k}: {bad}");
}
}
for bad in [json!("screeen"), json!(true), json!(1)] {
assert_eq!(PointerEvent::json_check(&json!({"space": bad})), Err("`space` must be doc or screen".to_owned()), "{bad}");
}
}
}
/// Keys tools care about.
+1
View File
@@ -984,6 +984,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con
"doubleclick" | "dblclick" => PointerKind::DoubleClick,
other => return Err(format!("unknown pointer kind `{other}`")),
};
PointerEvent::json_check(e)?;
let at = |k: &str| crate::menus::opt_number(e, k).map(|n| n.unwrap_or(0.0));
Ok((kind, at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base)))
})
+8 -2
View File
@@ -457,8 +457,8 @@ fn synthetic_pointer_rejects_a_backquote_that_is_not_a_boolean() {
}
}
/// `ui.pointer` refuses an unknown event kind, or a screen-space event with no canvas, before any
/// of the packet's events runs.
/// `ui.pointer` refuses an unknown event kind, a pen value or `space` of the wrong type, or a
/// screen-space event with no canvas, before any of the packet's events runs.
#[test]
fn synthetic_pointer_rejects_an_unknown_kind_before_any_event_runs() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
@@ -471,6 +471,12 @@ fn synthetic_pointer_rejects_an_unknown_kind_before_any_event_runs() {
for (last, error) in [
(json!({"kind": "wiggle", "x": 145, "y": 125}), "unknown pointer kind `wiggle`"),
(json!({"kind": "move", "x": 145, "y": 125, "space": "screen"}), "no canvas yet"),
(json!({"kind": "move", "x": 145, "y": 125, "pressure": "bad"}), "`pressure` must be a number"),
(json!({"kind": "move", "x": 145, "y": 125, "holdMs": "800"}), "`holdMs` must be a number"),
(json!({"kind": "move", "x": 145, "y": 125, "tiltY": true}), "`tiltY` must be a number"),
(json!({"kind": "move", "x": 145, "y": 125, "rotation": "90"}), "`rotation` must be a number"),
(json!({"kind": "move", "x": 145, "y": 125, "space": "screeen"}), "`space` must be doc or screen"),
(json!({"kind": "move", "x": 145, "y": 125, "space": true}), "`space` must be doc or screen"),
] {
let events = moves.iter().cloned().chain([last]).collect::<Vec<_>>();
let r = input_request(&mut app, "ui.pointer", json!({"events": events}));
+3 -2
View File
@@ -1272,8 +1272,9 @@ type and change nothing: `view.setZoom` (`zoom`, `center`), `view.screenMode` (`
control channel's `ui.key`, `ui.move`, `ui.wheel`, `ui.click`, `ui.drag`, `ui.pointer`, `ui.resize`, `ui.screenshot`,
`ui.render` and `ui.set` for their numbers, switches and `mods`, and the headless session's key, pointer and screenshot
calls. Omitting a param or giving null keeps its default, with two exceptions: `ui.click`'s `count` and `ui.drag`'s
`count` and `steps` refuse null, and a headless pointer event needs its `x` and `y`. A pointer packet with one
malformed event sends none of its events:
`count` and `steps` refuse null, and a headless pointer event needs its `x` and `y`. A pointer event's `pressure`,
`holdMs`, `tiltX`, `tiltY` and `rotation` must be numbers and its `space` must be `doc` or `screen`. A pointer packet
with one malformed event sends none of its events:
```json
{"id": "view.setZoom", "params": {"zoom": "200"}}