remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git
test: cover translate(); reject non-finite pointer deltas
translate() is the security boundary of this crate — the only thing between an
untrusted WebSocket frame and a control socket that can move the pointer and
type into whatever the user has focused — and it had no tests. It is also a
pure function over &str, so it is the one part of the crate testable without a
phone (there is no WebSocket client on this machine).
Writing them surfaced a real hole. f64::from_str accepts "NaN"/"inf"/"infinity"
and {:.2} formats them straight back out, so `m NaN 1` translated to
`pointer-move-by NaN 1.00` and reached the compositor's pointer math verbatim —
parse().ok() is not sufficient validation for a float. New finite() rejects
rather than clamps: no legitimate frame from the page carries one. Confirmed
the test catches it by stripping the guard and watching it fail, not just by
watching it pass.
Nine tests: the accepted shapes, and what must be refused — unknown verbs
including the compositor's own command names (a whitelist that honored
`pointer-click left` would be decorative), malformed and missing arguments,
wf targets outside safe_token, unwhitelisted cmd names, and the property that
no input makes the output span two lines, since control_command() appends a
newline and an embedded one would be a second command.
Also pins the half-to-even rounding of the {:.2} formatting, and updates
CLAUDE.md, which described this function as untested.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 18 ++++++--
src/main.rs | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 159 insertions(+), 7 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index f5aea05..d254cf4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -153,9 +153,21 @@ deltas, so near-unity is the trackpad-like 1:1 feel. A 300px swipe used to scrol
The awkward part: **there is no WebSocket client on this machine** (no `websocat`, no
`wscat`, no python `websockets`), so the WS path — which is most of the logic — can only
-be driven from a real phone, or by writing a throwaway client. `translate()` is a pure
-function over `&str` with no test module; that is the cheap place to lock behavior down
-if you touch the whitelist.
+be driven from a real phone, or by writing a throwaway client.
+
+`translate()` is the exception, and it is where the crate's one invariant is actually
+enforced, so it carries the crate's only tests (`cargo test -p cce-remote`, 9 of them,
+in `main.rs`). They cover the accepted shapes and — more to the point — everything that
+must be refused: unknown verbs *including the compositor's own command names*,
+malformed and missing arguments, `wf` targets outside `safe_token`, unwhitelisted `cmd`
+names, and the property that no input can make the output span two lines (an embedded
+newline would be a second command, since `control_command` appends one). Extend them
+when you touch the whitelist; they are much cheaper than the phone.
+
+One of them, `non_finite_coordinates_are_dropped`, guards a hole that was live until
+2026-08-22: `f64::from_str` accepts `"NaN"`/`"inf"` and `{:.2}` prints them straight back,
+so `m NaN 1` used to reach the compositor's pointer math verbatim. `parse().ok()` is not
+sufficient validation for a float — hence `finite()`.
What can be checked from the desktop, and is confirmed working:
diff --git a/src/main.rs b/src/main.rs
index b389da1..1740363 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -97,6 +97,14 @@ fn safe_token(t: &str) -> bool {
&& t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':'))
}
+/// `f64::from_str` accepts "NaN" / "inf" / "infinity", and `{:.2}` formats them
+/// straight back out, so without this a frame of `m NaN NaN` would reach the
+/// compositor's pointer math verbatim. Reject rather than clamp: no legitimate
+/// frame from the page contains one.
+fn finite(v: f64) -> Option<f64> {
+ v.is_finite().then_some(v)
+}
+
/// Translate one WS frame into a control-socket command. Returns None for
/// frames that don't parse — they're dropped, never forwarded raw (the WS
/// payload is untrusted; only these fixed shapes reach the compositor).
@@ -104,13 +112,13 @@ fn translate(frame: &str) -> Option<String> {
let mut it = frame.split_ascii_whitespace();
let cmd = match it.next()? {
"m" => {
- let dx: f64 = it.next()?.parse().ok()?;
- let dy: f64 = it.next()?.parse().ok()?;
+ let dx = finite(it.next()?.parse().ok()?)?;
+ let dy = finite(it.next()?.parse().ok()?)?;
format!("pointer-move-by {dx:.2} {dy:.2}")
}
"s" => {
- let dy: f64 = it.next()?.parse().ok()?;
- let dx: f64 = it.next().unwrap_or("0").parse().ok()?;
+ let dy = finite(it.next()?.parse().ok()?)?;
+ let dx = finite(it.next().unwrap_or("0").parse().ok()?)?;
format!("pointer-scroll {dy:.3} {dx:.3}")
}
"b" => {
@@ -442,3 +450,135 @@ fn main() {
});
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // `translate` is the security boundary of this crate: it is the only thing
+ // between an untrusted WebSocket frame and a control socket that can move
+ // the pointer and type into whatever the user has focused. These tests
+ // cover the two halves of that job — the fixed shapes it accepts, and
+ // everything it must refuse — because a regression here is not a wrong
+ // pixel, it is remote input injection.
+
+ #[test]
+ fn pointer_and_scroll_carry_fixed_precision() {
+ assert_eq!(translate("m 1 -2").unwrap(), "pointer-move-by 1.00 -2.00");
+ assert_eq!(translate("m 0.126 -0.126").unwrap(), "pointer-move-by 0.13 -0.13");
+ // Exact .5 ties round half-to-even, not away from zero — sub-pixel
+ // detail the page never notices, but pin it so a formatting change
+ // shows up here rather than as drifting pointer feel.
+ assert_eq!(translate("m 0.125 0.135").unwrap(), "pointer-move-by 0.12 0.14");
+ // `s` takes dy first; dx is optional and defaults to 0.
+ assert_eq!(translate("s 5").unwrap(), "pointer-scroll 5.000 0.000");
+ assert_eq!(translate("s 5 -1.5").unwrap(), "pointer-scroll 5.000 -1.500");
+ }
+
+ #[test]
+ fn buttons_map_to_press_release_click() {
+ assert_eq!(translate("b left down").unwrap(), "pointer-press left");
+ assert_eq!(translate("b left up").unwrap(), "pointer-release left");
+ assert_eq!(translate("b right click").unwrap(), "pointer-click right");
+ assert_eq!(translate("b middle click").unwrap(), "pointer-click middle");
+ }
+
+ #[test]
+ fn keys_map_to_tap_and_hold() {
+ assert_eq!(translate("k 28").unwrap(), "keypress 28");
+ assert_eq!(translate("kd 42").unwrap(), "key-down 42");
+ assert_eq!(translate("ku 42").unwrap(), "key-up 42");
+ }
+
+ #[test]
+ fn unknown_verbs_are_dropped() {
+ // Note the compositor's own command names: a frame naming one directly
+ // must NOT be honored, or the whitelist would be decorative.
+ for frame in [
+ "", " ", "x 1", "exit", "spawn foot", "reload",
+ "pointer-click left", "keypress 28", "restart-compositor",
+ ] {
+ assert!(translate(frame).is_none(), "{frame:?} should be dropped");
+ }
+ }
+
+ #[test]
+ fn missing_or_malformed_arguments_are_dropped() {
+ for frame in [
+ "m", "m 1", "m a b", "m 1 b",
+ "s", "s abc", "s 1 abc",
+ "k", "k abc", "k -1", "k 1.5", "k 99999999999999999999",
+ "kd", "ku",
+ "b", "b left", "b left bogus", "b sideways click", "b LEFT click",
+ "wf", "cmd",
+ ] {
+ assert!(translate(frame).is_none(), "{frame:?} should be dropped");
+ }
+ }
+
+ #[test]
+ fn non_finite_coordinates_are_dropped() {
+ // The hazard is real rather than theoretical — this is exactly what
+ // finite() exists to stop, and it is why parse().ok() alone is not
+ // enough validation for a float.
+ assert!("NaN".parse::<f64>().is_ok());
+ assert_eq!(format!("{:.2}", "NaN".parse::<f64>().unwrap()), "NaN");
+ assert_eq!(format!("{:.2}", "inf".parse::<f64>().unwrap()), "inf");
+
+ for frame in [
+ "m NaN 1", "m 1 NaN", "m inf 0", "m -inf 0", "m 1 infinity",
+ "s NaN", "s 1 inf", "s nan 0",
+ ] {
+ assert!(translate(frame).is_none(), "{frame:?} should be dropped");
+ }
+ }
+
+ #[test]
+ fn focus_target_is_restricted_to_safe_tokens() {
+ assert_eq!(translate("wf 12").unwrap(), "focus-window 12");
+ assert_eq!(translate("wf org.cce.files").unwrap(), "focus-window org.cce.files");
+ assert_eq!(translate("wf a-b_c:d.1").unwrap(), "focus-window a-b_c:d.1");
+ for frame in [
+ "wf ../etc", "wf a/b", "wf a;b", "wf a$b", "wf a*b",
+ "wf a'b", "wf a\"b", "wf a|b", "wf a&b", "wf a\\b",
+ ] {
+ assert!(translate(frame).is_none(), "{frame:?} should be dropped");
+ }
+ // safe_token's length bound, exercised on both sides.
+ assert!(translate(&format!("wf {}", "a".repeat(128))).is_some());
+ assert!(translate(&format!("wf {}", "a".repeat(129))).is_none());
+ }
+
+ #[test]
+ fn named_commands_are_whitelisted_never_passed_through() {
+ assert_eq!(translate("cmd restart-compositor").unwrap(), "restart-compositor");
+ // Trailing junk is discarded, not appended.
+ assert_eq!(translate("cmd restart-compositor rm -rf").unwrap(), "restart-compositor");
+ for frame in ["cmd exit", "cmd reload", "cmd spawn foot", "cmd RESTART-COMPOSITOR"] {
+ assert!(translate(frame).is_none(), "{frame:?} should be dropped");
+ }
+ }
+
+ #[test]
+ fn a_newline_can_never_smuggle_a_second_command() {
+ // control_command() appends "\n", so an embedded newline in the output
+ // would be a second command on the socket. split_ascii_whitespace()
+ // eats it and every command is REBUILT from re-parsed values, so
+ // trailing tokens are discarded rather than forwarded.
+ assert_eq!(translate("m 1 2\npointer-click left").unwrap(), "pointer-move-by 1.00 2.00");
+ assert_eq!(translate("wf 12\nexit").unwrap(), "focus-window 12");
+
+ // The property that matters, over every shape the page can send plus
+ // deliberate junk: whatever comes out is a single line.
+ for frame in [
+ "m 1 2\nexit", "m 1\n2", "s 1\nexit", "b left\nclick", "b left click\nexit",
+ "k 28\nexit", "kd 42\nexit", "ku 42\nexit", "wf 1\nexit",
+ "cmd restart-compositor\nexit", "m\t1\t2", "wf\n12", " m 1 2 ",
+ ] {
+ if let Some(out) = translate(frame) {
+ assert!(!out.contains('\n'), "{frame:?} produced a multi-line command: {out:?}");
+ assert!(!out.contains('\r'), "{frame:?} produced a CR: {out:?}");
+ }
+ }
+ }
+}