git.lucas.co / cce-remote
remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git

src/main.rs (48K)

   1 //! cce-remote — use a phone as a trackpad + keyboard for the cce desktop.
   2 //!
   3 //! One small server, no GUI: it serves the embedded `index.html` (a touch
   4 //! trackpad + keyboard page) over HTTP on the tailnet (see `reachable`), accepts a WebSocket at
   5 //! `/ws`, and translates the page's compact input events into the
   6 //! compositor's line-oriented control socket (`/tmp/cce-{WAYLAND_DISPLAY}.sock`
   7 //! — the same channel `ccectl` uses, so injection goes through the real
   8 //! compositor input path: `pointer-move-by`, `pointer-scroll`,
   9 //! `pointer-press/release/click`, `keypress`, `key-down`/`key-up`).
  10 //!
  11 //! Wire protocol (WS text frames, space-separated, one event per frame):
  12 //!   m <dx> <dy>          relative pointer move (logical px)
  13 //!   s <dy> <dx>          scroll (wayland axis units)
  14 //!   b <btn> <down|up|click>   btn = left|right|middle
  15 //!   k <keycode>          tap an evdev keycode
  16 //!   kd <keycode> / ku <keycode>   hold / release (modifiers)
  17 //!
  18 //! Security model: pairing PIN. A persistent 6-digit PIN (generated on first
  19 //! run, stored 0600 under ~/.config/cce/cce-remote.pin, printed at startup)
  20 //! must arrive as the FIRST WebSocket frame (`auth <pin>`) before any input
  21 //! event is accepted; anything else closes the connection. The page remembers
  22 //! the PIN in localStorage after the first pairing.
  23 
  24 use std::io::{Read, Write};
  25 use std::net::{TcpListener, TcpStream};
  26 use std::os::unix::net::UnixStream;
  27 use std::time::Duration;
  28 
  29 mod screencopy;
  30 mod stream;
  31 mod winstream;
  32 
  33 const INDEX_HTML: &str = include_str!("../index.html");
  34 
  35 /// Identity of the embedded page (FNV-1a). Sent to the page after auth so it
  36 /// can notice that a restarted server is serving a NEWER page than the one it
  37 /// is running, and reload itself — the page is baked into the binary, so
  38 /// every UI change otherwise needs a manual refresh on the phone.
  39 fn page_version() -> &'static str {
  40     static V: std::sync::OnceLock<String> = std::sync::OnceLock::new();
  41     V.get_or_init(|| {
  42         let mut h: u64 = 0xcbf2_9ce4_8422_2325;
  43         for b in INDEX_HTML.bytes() {
  44             h ^= b as u64;
  45             h = h.wrapping_mul(0x0000_0100_0000_01b3);
  46         }
  47         format!("{h:016x}")
  48     })
  49 }
  50 const DEFAULT_PORT: u16 = 17017;
  51 
  52 fn control_socket_path() -> String {
  53     let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
  54     format!("/tmp/cce-{display}.sock")
  55 }
  56 
  57 fn pin_path() -> std::path::PathBuf {
  58     let base = std::env::var("XDG_CONFIG_HOME")
  59         .map(std::path::PathBuf::from)
  60         .unwrap_or_else(|_| {
  61             let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
  62             std::path::PathBuf::from(home).join(".config")
  63         });
  64     base.join("cce").join("cce-remote.pin")
  65 }
  66 
  67 /// The pairing PIN: read from disk, or generated (6 digits from /dev/urandom)
  68 /// and stored 0600 on first run.
  69 fn load_or_create_pin() -> std::io::Result<String> {
  70     let path = pin_path();
  71     if let Ok(existing) = std::fs::read_to_string(&path) {
  72         let trimmed = existing.trim().to_string();
  73         if !trimmed.is_empty() {
  74             return Ok(trimmed);
  75         }
  76     }
  77     let mut bytes = [0u8; 4];
  78     std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
  79     let pin = format!("{:06}", u32::from_le_bytes(bytes) % 1_000_000);
  80     if let Some(dir) = path.parent() {
  81         std::fs::create_dir_all(dir)?;
  82     }
  83     {
  84         use std::os::unix::fs::OpenOptionsExt;
  85         let mut f = std::fs::OpenOptions::new()
  86             .write(true)
  87             .create(true)
  88             .truncate(true)
  89             .mode(0o600)
  90             .open(&path)?;
  91         writeln!(f, "{pin}")?;
  92     }
  93     Ok(pin)
  94 }
  95 
  96 /// One control-socket command, one connection: the compositor's IPC server is
  97 /// one-shot (read → reply → close), so a fresh connect per command is the
  98 /// correct framing — the reply is everything until EOF (commands like
  99 /// `windows --json` reply with multiple lines).
 100 fn control_command(cmd: &str) -> std::io::Result<String> {
 101     let mut s = UnixStream::connect(control_socket_path())?;
 102     s.write_all(cmd.as_bytes())?;
 103     s.write_all(b"\n")?;
 104     let mut reply = String::new();
 105     s.read_to_string(&mut reply)?;
 106     Ok(reply)
 107 }
 108 
 109 /// True for tokens safe to splice into a control command (window queries:
 110 /// numeric ids or app_ids). The WS payload is untrusted — nothing unvalidated
 111 /// reaches the compositor.
 112 fn safe_token(t: &str) -> bool {
 113     !t.is_empty() && t.len() <= 128
 114         && t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':'))
 115 }
 116 
 117 /// Whether `candidate` is the pairing PIN.
 118 ///
 119 /// An empty PIN never authorizes anything. `load_or_create_pin` cannot produce
 120 /// one — it regenerates on an empty file — but that is a property of a
 121 /// different function, and if it ever stopped holding, a bare `X-Pin:` header
 122 /// or an `auth ` frame with nothing after it would authenticate every request.
 123 /// Gate on the dangerous state here rather than trusting the caller.
 124 fn pin_matches(candidate: &str, pin: &str) -> bool {
 125     !pin.is_empty() && candidate == pin
 126 }
 127 
 128 /// The WebSocket auth gate: the FIRST frame must be `auth <pin>`. Everything
 129 /// else — a wrong PIN, a different verb, an input event sent before pairing —
 130 /// closes the connection, so no input can be injected unauthenticated.
 131 fn auth_frame_ok(frame: &str, pin: &str) -> bool {
 132     frame
 133         .strip_prefix("auth ")
 134         .is_some_and(|candidate| pin_matches(candidate.trim(), pin))
 135 }
 136 
 137 /// PIN carried by an `X-Pin` header. The header name is matched
 138 /// case-insensitively (HTTP field names are), the value is not.
 139 fn header_pin_ok(request_head: &str, pin: &str) -> bool {
 140     request_head.lines().any(|line| {
 141         line.to_ascii_lowercase()
 142             .starts_with("x-pin:")
 143             .then(|| line["x-pin:".len()..].trim())
 144             .is_some_and(|candidate| pin_matches(candidate, pin))
 145     })
 146 }
 147 
 148 /// PIN carried as a `?pin=` query parameter — needed because an `<img src>`
 149 /// cannot send headers, so `/stream` has no other way to authenticate.
 150 ///
 151 /// Parsed as an actual parameter rather than searched for as a substring: the
 152 /// old `target.contains("pin=<pin>")` also accepted `?notpin=<pin>` and
 153 /// `?pin=<pin>trailing-garbage`. Neither is exploitable without already knowing
 154 /// the PIN, but "close enough to the right string" is not a check.
 155 fn query_pin_ok(request_head: &str, pin: &str) -> bool {
 156     request_head
 157         .split_whitespace()
 158         .nth(1)
 159         .and_then(|target| target.split_once('?'))
 160         .is_some_and(|(_, query)| {
 161             query
 162                 .split('&')
 163                 .any(|kv| kv.strip_prefix("pin=").is_some_and(|c| pin_matches(c, pin)))
 164         })
 165 }
 166 
 167 /// Loopback, or an address Tailscale hands out: 100.64.0.0/10 and
 168 /// fd7a:115c:a1e0::/48.
 169 fn tailnet_or_loopback(ip: std::net::IpAddr) -> bool {
 170     match ip.to_canonical() {
 171         std::net::IpAddr::V4(v4) => {
 172             let o = v4.octets();
 173             v4.is_loopback() || (o[0] == 100 && (o[1] & 0xc0) == 64)
 174         }
 175         std::net::IpAddr::V6(v6) => {
 176             let s = v6.segments();
 177             v6.is_loopback() || (s[0] == 0xfd7a && s[1] == 0x115c && s[2] == 0xa1e0)
 178         }
 179     }
 180 }
 181 
 182 /// Whether a connection may be served at all, before any PIN is asked for.
 183 ///
 184 /// The PIN crosses the wire in clear — HTTP, a WS frame, a `/stream` URL — so
 185 /// on a network someone else can watch, anyone there who sees one pairing
 186 /// owns the keyboard. By default, then, only the tailnet is served: WireGuard
 187 /// carries it encrypted end to end, and loopback never leaves the machine.
 188 /// Both ends are checked. The PEER must be a tailnet (or loopback) address,
 189 /// and so must the LOCAL address it reached: Linux will accept a packet for
 190 /// the tailnet address arriving on the Wi-Fi interface, and a LAN peer
 191 /// spoofing a 100.x source cannot finish the handshake, since the reply is
 192 /// routed into the tunnel. `lan` (`--lan` / `CCE_REMOTE_LAN=1`) serves every
 193 /// interface, the old behaviour, for a network you trust.
 194 fn reachable(peer: std::net::IpAddr, local: std::net::IpAddr, lan: bool) -> bool {
 195     lan || (tailnet_or_loopback(peer) && tailnet_or_loopback(local))
 196 }
 197 
 198 /// Failed PIN attempts allowed back-to-back from one peer before it must wait.
 199 /// Sized for a human mistyping a PIN, not for a client retry loop.
 200 const PIN_ATTEMPT_BURST: f64 = 5.0;
 201 /// Sustained rate a peer recovers attempts at: one per 30s. That caps a
 202 /// brute-force at ~2/min, so walking a 6-digit space takes on the order of a
 203 /// year rather than the couple of hours an unthrottled LAN socket allows.
 204 const PIN_ATTEMPT_REFILL_PER_SEC: f64 = 1.0 / 30.0;
 205 /// Backstop on the tracking table so the limiter cannot itself be turned into
 206 /// a memory-exhaustion vector by cycling source addresses.
 207 const PIN_MAX_TRACKED_PEERS: usize = 4096;
 208 /// Failed attempts allowed back-to-back across ALL peers. The per-peer budget
 209 /// alone is per address, and anyone who can claim many addresses (a /22 LAN
 210 /// has a thousand) gets a budget for each — which brought walking the PIN
 211 /// space down from a year to hours. Room for a few devices mistyping at once.
 212 const PIN_GLOBAL_BURST: f64 = 20.0;
 213 /// What every peer together recovers: the same one per 30s a single peer
 214 /// does, so the year-long walk holds however many addresses do the guessing.
 215 const PIN_GLOBAL_REFILL_PER_SEC: f64 = 1.0 / 30.0;
 216 
 217 #[derive(Clone, Copy, Debug)]
 218 struct Bucket {
 219     tokens: f64,
 220     last: std::time::Instant,
 221 }
 222 
 223 /// Per-peer token bucket over failed PIN attempts.
 224 ///
 225 /// The PIN is ~20 bits and every gate compares it with `==`, so the thing that
 226 /// actually makes it a credential is that an attacker cannot try often. Only
 227 /// FAILURES consume tokens — a paired client reconnecting its stream, which
 228 /// the page does on every hiccup, must never be throttled — and a success
 229 /// clears the peer's record entirely.
 230 ///
 231 /// `now` is a parameter rather than read inside, so the behavior is testable
 232 /// without sleeping.
 233 ///
 234 /// Behind the per-peer buckets sits one GLOBAL bucket every failure also
 235 /// drains, so guessing from many addresses buys no more attempts than
 236 /// guessing from one. The price: whoever drains it locks out everyone,
 237 /// a paired phone included, until it refills. That is the trade — a denial
 238 /// of a trackpad for a while, against keyboard control of the desktop — and
 239 /// with the default reach (loopback and the tailnet, see `reachable`) only
 240 /// your own devices can drain it.
 241 struct RateLimiter {
 242     peers: std::sync::Mutex<std::collections::HashMap<std::net::IpAddr, Bucket>>,
 243     global: std::sync::Mutex<Bucket>,
 244 }
 245 
 246 impl RateLimiter {
 247     fn new() -> Self {
 248         Self {
 249             peers: std::sync::Mutex::new(std::collections::HashMap::new()),
 250             global: std::sync::Mutex::new(Bucket {
 251                 tokens: PIN_GLOBAL_BURST,
 252                 last: std::time::Instant::now(),
 253             }),
 254         }
 255     }
 256 
 257     fn refilled(bucket: Bucket, now: std::time::Instant) -> Bucket {
 258         Self::refilled_at(bucket, now, PIN_ATTEMPT_REFILL_PER_SEC, PIN_ATTEMPT_BURST)
 259     }
 260 
 261     fn refilled_at(bucket: Bucket, now: std::time::Instant, rate: f64, burst: f64) -> Bucket {
 262         let elapsed = now.saturating_duration_since(bucket.last).as_secs_f64();
 263         Bucket {
 264             tokens: (bucket.tokens + elapsed * rate).min(burst),
 265             last: now,
 266         }
 267     }
 268 
 269     fn global_refilled(bucket: Bucket, now: std::time::Instant) -> Bucket {
 270         Self::refilled_at(bucket, now, PIN_GLOBAL_REFILL_PER_SEC, PIN_GLOBAL_BURST)
 271     }
 272 
 273     /// May this peer attempt a PIN right now? Does not consume anything —
 274     /// a correct PIN costs nothing.
 275     fn allow(&self, ip: std::net::IpAddr, now: std::time::Instant) -> bool {
 276         if Self::global_refilled(*self.global.lock().unwrap(), now).tokens < 1.0 {
 277             return false;
 278         }
 279         let peers = self.peers.lock().unwrap();
 280         match peers.get(&ip) {
 281             Some(&b) => Self::refilled(b, now).tokens >= 1.0,
 282             None => true,
 283         }
 284     }
 285 
 286     /// Charge this peer — and the global budget — for a wrong PIN.
 287     fn record_failure(&self, ip: std::net::IpAddr, now: std::time::Instant) {
 288         {
 289             let mut g = self.global.lock().unwrap();
 290             let mut b = Self::global_refilled(*g, now);
 291             b.tokens = (b.tokens - 1.0).max(0.0);
 292             *g = b;
 293         }
 294         let mut peers = self.peers.lock().unwrap();
 295         // A fully refilled bucket is indistinguishable from an absent one, so
 296         // dropping those keeps the table proportional to peers currently being
 297         // penalized rather than to every peer ever seen.
 298         peers.retain(|_, b| Self::refilled(*b, now).tokens < PIN_ATTEMPT_BURST);
 299         if peers.len() >= PIN_MAX_TRACKED_PEERS && !peers.contains_key(&ip) {
 300             // At capacity: evict whoever is closest to having recovered.
 301             if let Some(&victim) = peers
 302                 .iter()
 303                 .max_by(|a, b| a.1.tokens.total_cmp(&b.1.tokens))
 304                 .map(|(k, _)| k)
 305             {
 306                 peers.remove(&victim);
 307             }
 308         }
 309         let entry = peers
 310             .entry(ip)
 311             .or_insert(Bucket { tokens: PIN_ATTEMPT_BURST, last: now });
 312         let mut b = Self::refilled(*entry, now);
 313         b.tokens = (b.tokens - 1.0).max(0.0);
 314         *entry = b;
 315     }
 316 
 317     /// A correct PIN clears the peer's record.
 318     fn record_success(&self, ip: std::net::IpAddr) {
 319         self.peers.lock().unwrap().remove(&ip);
 320     }
 321 }
 322 
 323 /// `f64::from_str` accepts "NaN" / "inf" / "infinity", and `{:.2}` formats them
 324 /// straight back out, so without this a frame of `m NaN NaN` would reach the
 325 /// compositor's pointer math verbatim. Reject rather than clamp: no legitimate
 326 /// frame from the page contains one.
 327 fn finite(v: f64) -> Option<f64> {
 328     v.is_finite().then_some(v)
 329 }
 330 
 331 /// Translate one WS frame into the control-socket commands it means. Returns
 332 /// None for frames that don't parse — they're dropped, never forwarded raw
 333 /// (the WS payload is untrusted; only these fixed shapes reach the
 334 /// compositor). A list rather than one command so a verb can expand to
 335 /// several commands without the expansion living inline at the call site (the
 336 /// retired view-mode tap did: absolute move, then click); keeping every
 337 /// expansion here is what makes this the single place input is validated.
 338 fn translate(frame: &str) -> Option<Vec<String>> {
 339     let mut it = frame.split_ascii_whitespace();
 340     let cmd = match it.next()? {
 341         "m" => {
 342             let dx = finite(it.next()?.parse().ok()?)?;
 343             let dy = finite(it.next()?.parse().ok()?)?;
 344             format!("pointer-move-by {dx:.2} {dy:.2}")
 345         }
 346         "s" => {
 347             let dy = finite(it.next()?.parse().ok()?)?;
 348             let dx = finite(it.next().unwrap_or("0").parse().ok()?)?;
 349             format!("pointer-scroll {dy:.3} {dx:.3}")
 350         }
 351         "b" => {
 352             let btn = match it.next()? {
 353                 b @ ("left" | "right" | "middle") => b,
 354                 _ => return None,
 355             };
 356             match it.next()? {
 357                 "down" => format!("pointer-press {btn}"),
 358                 "up" => format!("pointer-release {btn}"),
 359                 "click" => format!("pointer-click {btn}"),
 360                 _ => return None,
 361             }
 362         }
 363         "k" => format!("keypress {}", it.next()?.parse::<u32>().ok()?),
 364         "kd" => format!("key-down {}", it.next()?.parse::<u32>().ok()?),
 365         "ku" => format!("key-up {}", it.next()?.parse::<u32>().ok()?),
 366         "wf" => {
 367             let target = it.next()?;
 368             if !safe_token(target) {
 369                 return None;
 370             }
 371             format!("focus-window {target}")
 372         }
 373         // Named commands, individually whitelisted — never pass-through.
 374         "cmd" => match it.next()? {
 375             "restart-compositor" => "restart-compositor".to_string(),
 376             _ => return None,
 377         },
 378         _ => return None,
 379     };
 380     Some(vec![cmd])
 381 }
 382 
 383 /// The `windows --json` reply (one JSON object per line) as a JSON array
 384 /// for the page's switcher.
 385 fn window_list_json() -> String {
 386     let reply = control_command("windows --json").unwrap_or_default();
 387     let objs: Vec<&str> = reply.lines().filter(|l| l.trim_start().starts_with('{')).collect();
 388     format!("windows [{}]", objs.join(","))
 389 }
 390 
 391 /// Pull a numeric field out of one windows-json line (no serde — the values
 392 /// are flat numbers on a single line per window).
 393 fn json_num(line: &str, key: &str) -> Option<f64> {
 394     let pat = format!("\"{key}\":");
 395     let rest = &line[line.find(&pat)? + pat.len()..];
 396     let end = rest
 397         .find(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.'))
 398         .unwrap_or(rest.len());
 399     rest[..end].parse().ok()
 400 }
 401 
 402 /// The focused app window as (id, x, y, w, h) in layout px.
 403 fn focused_window() -> Option<(u64, f64, f64, f64, f64)> {
 404     let reply = control_command("windows --json").ok()?;
 405     for line in reply.lines() {
 406         if line.contains("\"focused\":true") && !line.contains("\"mode\":\"Status\"") {
 407             return Some((
 408                 json_num(line, "id")? as u64,
 409                 json_num(line, "x")?,
 410                 json_num(line, "y")?,
 411                 json_num(line, "w")?,
 412                 json_num(line, "h")?,
 413             ));
 414         }
 415     }
 416     None
 417 }
 418 
 419 /// Screenshot the focused window via the compositor (it replies with the PNG
 420 /// path), read the bytes, and DELETE the file — the remote view must not
 421 /// litter ~/Pictures/screenshots.
 422 fn take_screenshot() -> Option<(Vec<u8>, (u64, f64, f64, f64, f64))> {
 423     let win = focused_window()?;
 424     let reply = control_command(&format!("screenshot window {}", win.0)).ok()?;
 425     let path = reply.trim().strip_prefix("ok ")?.trim().to_string();
 426     let mut bytes = None;
 427     for _ in 0..5 {
 428         match std::fs::read(&path) {
 429             Ok(b) if !b.is_empty() => {
 430                 bytes = Some(b);
 431                 break;
 432             }
 433             _ => std::thread::sleep(Duration::from_millis(60)),
 434         }
 435     }
 436     let _ = std::fs::remove_file(&path);
 437     Some((bytes?, win))
 438 }
 439 
 440 fn handle_ws(stream: TcpStream, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
 441     let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
 442     // Unauthenticated clients can hold the socket only briefly.
 443     let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
 444     let mut ws = match tungstenite::accept(stream) {
 445         Ok(ws) => ws,
 446         Err(e) => {
 447             eprintln!("[cce-remote] ws handshake failed ({peer}): {e}");
 448             return;
 449         }
 450     };
 451     // First frame MUST be `auth <pin>` — anything else (or a timeout, or a
 452     // wrong PIN) closes the connection before any input can be injected.
 453     if !limiter.allow(ip, std::time::Instant::now()) {
 454         // Close WITHOUT "auth fail": that message makes the page drop its
 455         // stored PIN and prompt, so sending it here would punish a correctly
 456         // paired client for someone else's guessing on the same address. It
 457         // reconnects on close and succeeds once the bucket refills.
 458         eprintln!("[cce-remote] auth rate-limited: {peer}");
 459         let _ = ws.close(None);
 460         return;
 461     }
 462     let authed = matches!(
 463         ws.read(),
 464         Ok(tungstenite::Message::Text(t)) if auth_frame_ok(&t, pin)
 465     );
 466     if !authed {
 467         limiter.record_failure(ip, std::time::Instant::now());
 468         eprintln!("[cce-remote] auth failed: {peer}");
 469         let _ = ws.send(tungstenite::Message::Text("auth fail".into()));
 470         let _ = ws.close(None);
 471         return;
 472     }
 473     limiter.record_success(ip);
 474     let _ = ws.get_ref().set_read_timeout(None);
 475     let _ = ws.send(tungstenite::Message::Text("auth ok".into()));
 476     let _ = ws.send(tungstenite::Message::Text(format!("ver {}", page_version())));
 477     println!("[cce-remote] client connected: {peer}");
 478     loop {
 479         match ws.read() {
 480             Ok(msg) => {
 481                 if let tungstenite::Message::Text(text) = msg {
 482                     if text.trim() == "wl" {
 483                         // Window-list request: the one message with a reply.
 484                         let _ = ws.send(tungstenite::Message::Text(window_list_json()));
 485                     } else if text.trim() == "pl" {
 486                         // Pointer location (view mode's cursor marker):
 487                         // "x=N y=N" → "ploc N N".
 488                         if let Ok(reply) = control_command("pointer-location") {
 489                             let coords: String = reply
 490                                 .split_whitespace()
 491                                 .filter_map(|kv| kv.strip_prefix("x=").or_else(|| kv.strip_prefix("y=")))
 492                                 .collect::<Vec<_>>()
 493                                 .join(" ");
 494                             if !coords.is_empty() {
 495                                 let _ = ws.send(tungstenite::Message::Text(format!("ploc {coords}")));
 496                             }
 497                         }
 498                     } else if let Some(cmds) = translate(&text) {
 499                         // Every input-bearing frame goes through translate() —
 500                         // `wl` and `pl` above are the only exceptions, and they
 501                         // send fixed commands with no caller-supplied content.
 502                         for cmd in cmds {
 503                             let _ = control_command(&cmd);
 504                         }
 505                     }
 506                 }
 507             }
 508             Err(_) => break,
 509         }
 510     }
 511     println!("[cce-remote] client disconnected: {peer}");
 512 }
 513 
 514 /// MJPEG stream of the focused window: multipart/x-mixed-replace, one JPEG
 515 /// part per frame taken from the same latest-wins slot `/wstream` uses (the
 516 /// producer picks the source and follows focus). Fixed 560/q60, no acks — the
 517 /// curl-debuggable endpoint, not the page's path. Runs until the client closes
 518 /// the socket. PIN via X-Pin header or ?pin= query (an <img src> can't carry
 519 /// headers).
 520 fn handle_stream(mut stream: TcpStream, request_head: &str, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
 521     if !limiter.allow(ip, std::time::Instant::now()) {
 522         let _ = write!(stream, "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 30\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
 523         return;
 524     }
 525     // Header OR query: an <img src> cannot carry a header, so /stream accepts
 526     // the PIN in the URL. /shot does not — see handle_http.
 527     let pin_ok = header_pin_ok(request_head, pin) || query_pin_ok(request_head, pin);
 528     if !pin_ok {
 529         limiter.record_failure(ip, std::time::Instant::now());
 530         let _ = write!(stream, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
 531         return;
 532     }
 533     limiter.record_success(ip);
 534     if write!(
 535         stream,
 536         "HTTP/1.1 200 OK\r\nContent-Type: multipart/x-mixed-replace; boundary=frame\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n"
 537     )
 538     .is_err()
 539     {
 540         return;
 541     }
 542     // Source selection (winstream → screencopy → grim) lives in the producer;
 543     // this endpoint is the curl-debuggable MJPEG view over the same slot the
 544     // page's ack-clocked /wstream uses.
 545     let slot = stream::Slot::new();
 546     let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
 547     stream::spawn_producer(std::sync::Arc::clone(&slot), std::sync::Arc::clone(&stop));
 548     stream::run_mjpeg_sender(&mut stream, &slot);
 549     stop.store(true, std::sync::atomic::Ordering::Relaxed);
 550 }
 551 
 552 /// The page's live view: ack-clocked latest-wins frame delivery over a
 553 /// dedicated WebSocket. Same first-frame `auth <pin>` gate as the input WS —
 554 /// and a separate socket on purpose: frames are 30-150KB and input events are
 555 /// bytes, so sharing one TCP stream would head-of-line-block pointer motion
 556 /// behind every frame on a slow link.
 557 fn handle_wstream(stream: TcpStream, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
 558     let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
 559     let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
 560     let mut ws = match tungstenite::accept(stream) {
 561         Ok(ws) => ws,
 562         Err(e) => {
 563             eprintln!("[cce-remote] wstream handshake failed ({peer}): {e}");
 564             return;
 565         }
 566     };
 567     if !limiter.allow(ip, std::time::Instant::now()) {
 568         let _ = ws.close(None);
 569         return;
 570     }
 571     let authed = matches!(
 572         ws.read(),
 573         Ok(tungstenite::Message::Text(t)) if auth_frame_ok(&t, pin)
 574     );
 575     if !authed {
 576         limiter.record_failure(ip, std::time::Instant::now());
 577         eprintln!("[cce-remote] wstream auth failed: {peer}");
 578         let _ = ws.close(None);
 579         return;
 580     }
 581     limiter.record_success(ip);
 582     let _ = ws.send(tungstenite::Message::Text("auth ok".into()));
 583     let slot = stream::Slot::new();
 584     let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
 585     stream::spawn_producer(std::sync::Arc::clone(&slot), std::sync::Arc::clone(&stop));
 586     stream::run_ws_sender(&mut ws, &slot);
 587     stop.store(true, std::sync::atomic::Ordering::Relaxed);
 588     let _ = ws.close(None);
 589 }
 590 
 591 fn handle_http(mut stream: TcpStream, request_head: &str, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
 592     if request_head.starts_with("GET /stream") {
 593         handle_stream(stream, request_head, pin, ip, limiter);
 594         return;
 595     }
 596     // /shot: the focused window's screenshot, PIN-gated via the X-Pin header.
 597     // A debug endpoint now — the page's live view rides /wstream and nothing
 598     // in the page fetches this.
 599     if request_head.starts_with("GET /shot") {
 600         if !limiter.allow(ip, std::time::Instant::now()) {
 601             let _ = write!(stream, "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 30\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
 602             return;
 603         }
 604         // Header only: nothing loads this as an <img src>, so unlike /stream
 605         // there is no reason to let the PIN travel in a URL (where it lands in
 606         // logs).
 607         if !header_pin_ok(request_head, pin) {
 608             limiter.record_failure(ip, std::time::Instant::now());
 609             let _ = write!(stream, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
 610             return;
 611         }
 612         limiter.record_success(ip);
 613         match take_screenshot() {
 614             Some((bytes, (id, x, y, w, h))) => {
 615                 let _ = write!(
 616                     stream,
 617                     "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nX-Win: {id} {x} {y} {w} {h}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
 618                     bytes.len(),
 619                 );
 620                 let _ = stream.write_all(&bytes);
 621             }
 622             None => {
 623                 let _ = write!(stream, "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
 624             }
 625         }
 626         return;
 627     }
 628     let ok = request_head.starts_with("GET / ") || request_head.starts_with("GET /index.html ");
 629     let (status, body) = if ok {
 630         ("200 OK", INDEX_HTML)
 631     } else {
 632         ("404 Not Found", "not found")
 633     };
 634     // no-cache: a reload (manual or the automatic version-mismatch one) must
 635     // refetch the page, not revalidate a heuristic cache entry.
 636     let _ = write!(
 637         stream,
 638         "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nCache-Control: no-cache\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
 639         body.len(),
 640     );
 641 }
 642 
 643 fn main() {
 644     let mut port = DEFAULT_PORT;
 645     let mut lan = std::env::var("CCE_REMOTE_LAN").is_ok_and(|v| v == "1");
 646     for arg in std::env::args().skip(1) {
 647         if arg == "--lan" {
 648             lan = true;
 649         } else if let Ok(p) = arg.parse::<u16>() {
 650             port = p;
 651         } else {
 652             eprintln!("[cce-remote] usage: cce-remote [--lan] [port]");
 653             std::process::exit(2);
 654         }
 655     }
 656     let pin = match load_or_create_pin() {
 657         Ok(p) => p,
 658         Err(e) => {
 659             eprintln!("[cce-remote] cannot read/create PIN file {:?}: {e}", pin_path());
 660             std::process::exit(1);
 661         }
 662     };
 663     let listener = match TcpListener::bind(("0.0.0.0", port)) {
 664         Ok(l) => l,
 665         Err(e) => {
 666             eprintln!("[cce-remote] cannot bind port {port}: {e}");
 667             std::process::exit(1);
 668         }
 669     };
 670     let limiter = std::sync::Arc::new(RateLimiter::new());
 671     if lan {
 672         println!("[cce-remote] serving EVERY interface on :{port} (--lan: the PIN crosses the network in clear)");
 673     } else {
 674         println!("[cce-remote] serving loopback and the tailnet on :{port} (--lan or CCE_REMOTE_LAN=1 for every interface)");
 675     }
 676     println!("[cce-remote] control socket: {}", control_socket_path());
 677     println!("[cce-remote] pairing PIN: {pin}   (stored in {:?})", pin_path());
 678 
 679     for stream in listener.incoming() {
 680         let stream = match stream {
 681             Ok(s) => s,
 682             Err(_) => continue,
 683         };
 684         // Identify the peer once, here: every PIN gate is rate-limited per
 685         // source address, and a socket whose peer cannot be resolved cannot be
 686         // held accountable for its guesses, so it is refused rather than
 687         // exempted.
 688         let Ok(ip) = stream.peer_addr().map(|a| a.ip()) else { continue };
 689         let Ok(local) = stream.local_addr().map(|a| a.ip()) else { continue };
 690         if !reachable(ip, local, lan) {
 691             // Say why rather than just dropping it, so a phone still holding
 692             // the old LAN address learns where to go; nothing past this line
 693             // is reachable from here, the request is never parsed.
 694             eprintln!("[cce-remote] refused {ip} -> {local}: not the tailnet (--lan to serve it)");
 695             std::thread::spawn(move || {
 696                 let mut s = stream;
 697                 let _ = s.set_read_timeout(Some(Duration::from_secs(2)));
 698                 let mut sink = [0u8; 1024];
 699                 let _ = s.read(&mut sink);
 700                 let body = "cce-remote serves the tailnet only: open it at this machine's Tailscale address.\n";
 701                 let _ = write!(
 702                     s,
 703                     "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
 704                     body.len()
 705                 );
 706             });
 707             continue;
 708         }
 709         // Input events and stream acks are tiny and latency-critical; Nagle
 710         // would batch them behind delayed ACKs.
 711         let _ = stream.set_nodelay(true);
 712         let pin = pin.clone();
 713         let limiter = std::sync::Arc::clone(&limiter);
 714         std::thread::spawn(move || {
 715             // Peek the request head without consuming it, so a WS upgrade can
 716             // be handed to tungstenite with the handshake bytes intact.
 717             let mut buf = [0u8; 1024];
 718             let n = match stream.peek(&mut buf) {
 719                 Ok(n) if n > 0 => n,
 720                 _ => return,
 721             };
 722             let head = String::from_utf8_lossy(&buf[..n]).to_string();
 723             if head.starts_with("GET /wstream") {
 724                 // before /ws: "GET /ws" is a prefix of this
 725                 handle_wstream(stream, &pin, ip, &limiter);
 726             } else if head.starts_with("GET /ws") {
 727                 handle_ws(stream, &pin, ip, &limiter);
 728             } else {
 729                 // Consume the request before replying (keeps curl happy).
 730                 let mut sink = [0u8; 1024];
 731                 let mut s = stream;
 732                 let _ = s.read(&mut sink);
 733                 handle_http(s, &head, &pin, ip, &limiter);
 734             }
 735         });
 736     }
 737 }
 738 
 739 #[cfg(test)]
 740 mod tests {
 741     use super::*;
 742 
 743     // `translate` is the security boundary of this crate: it is the only thing
 744     // between an untrusted WebSocket frame and a control socket that can move
 745     // the pointer and type into whatever the user has focused. These tests
 746     // cover the two halves of that job — the fixed shapes it accepts, and
 747     // everything it must refuse — because a regression here is not a wrong
 748     // pixel, it is remote input injection.
 749 
 750     /// A frame expected to mean exactly one command.
 751     fn one(frame: &str) -> String {
 752         let cmds = translate(frame).expect("frame should translate");
 753         assert_eq!(cmds.len(), 1, "{frame:?} yielded {cmds:?}, expected one command");
 754         cmds.into_iter().next().unwrap()
 755     }
 756 
 757     // ---- rate limiting ----
 758     //
 759     // What actually makes a 6-digit PIN a credential: not the comparison, but
 760     // that a peer cannot try often. Time is injected, so none of this sleeps.
 761 
 762     use std::net::{IpAddr, Ipv4Addr};
 763     use std::time::Instant;
 764 
 765     fn ip(last: u8) -> IpAddr {
 766         IpAddr::V4(Ipv4Addr::new(192, 168, 1, last))
 767     }
 768 
 769     #[test]
 770     fn a_peer_gets_a_burst_then_must_wait() {
 771         let rl = RateLimiter::new();
 772         let t0 = Instant::now();
 773         let peer = ip(10);
 774 
 775         for i in 0..PIN_ATTEMPT_BURST as u32 {
 776             assert!(rl.allow(peer, t0), "attempt {i} should be allowed");
 777             rl.record_failure(peer, t0);
 778         }
 779         assert!(!rl.allow(peer, t0), "the burst must be exhausted");
 780 
 781         // Still locked out just short of the refill interval, allowed after it.
 782         assert!(!rl.allow(peer, t0 + Duration::from_secs(29)));
 783         assert!(rl.allow(peer, t0 + Duration::from_secs(31)));
 784     }
 785 
 786     #[test]
 787     fn recovery_is_capped_at_the_burst_size() {
 788         // Asserted on refilled() directly rather than through the public API:
 789         // record_failure() prunes recovered peers and re-creates them at full,
 790         // which masks a missing cap end-to-end. (It did — this test passed
 791         // against a build with the .min() removed until it was written this
 792         // way.) Idle time must not bank attempts.
 793         let t0 = Instant::now();
 794         let drained = Bucket { tokens: 0.0, last: t0 };
 795         // Full recovery takes BURST * 30s = 150s; well past that, nothing accrues.
 796         for idle in [300u64, 3600, 86_400] {
 797             let b = RateLimiter::refilled(drained, t0 + Duration::from_secs(idle));
 798             assert_eq!(
 799                 b.tokens, PIN_ATTEMPT_BURST,
 800                 "{idle}s idle banked {} attempts", b.tokens
 801             );
 802         }
 803         // Partial recovery is proportional, not all-or-nothing.
 804         let b = RateLimiter::refilled(drained, t0 + Duration::from_secs(60));
 805         assert!(b.tokens > 1.0);
 806         let b = RateLimiter::refilled(drained, t0 + Duration::from_secs(15));
 807         assert!(b.tokens < 1.0, "half an interval must not buy a whole attempt");
 808     }
 809 
 810     #[test]
 811     fn peers_are_limited_independently() {
 812         let rl = RateLimiter::new();
 813         let t0 = Instant::now();
 814         let (attacker, phone) = (ip(20), ip(21));
 815         for _ in 0..PIN_ATTEMPT_BURST as u32 {
 816             rl.record_failure(attacker, t0);
 817         }
 818         assert!(!rl.allow(attacker, t0));
 819         assert!(rl.allow(phone, t0), "one peer's guessing must not lock out another");
 820     }
 821 
 822     #[test]
 823     fn a_correct_pin_costs_nothing_and_clears_the_record() {
 824         let rl = RateLimiter::new();
 825         let t0 = Instant::now();
 826         let peer = ip(30);
 827 
 828         // The page reconnects its stream on every hiccup, each time presenting
 829         // a correct PIN. If that consumed budget it would throttle itself.
 830         for _ in 0..1000 {
 831             assert!(rl.allow(peer, t0));
 832         }
 833 
 834         for _ in 0..(PIN_ATTEMPT_BURST as u32 - 1) {
 835             rl.record_failure(peer, t0);
 836         }
 837         rl.record_success(peer);
 838         for _ in 0..PIN_ATTEMPT_BURST as u32 {
 839             assert!(rl.allow(peer, t0), "success should restore the full burst");
 840             rl.record_failure(peer, t0);
 841         }
 842     }
 843 
 844     #[test]
 845     fn the_tracking_table_does_not_grow_without_bound() {
 846         let rl = RateLimiter::new();
 847         let t0 = Instant::now();
 848 
 849         // Recovered peers carry no information and must not be retained.
 850         for i in 0..200u8 {
 851             rl.record_failure(ip(i), t0);
 852         }
 853         assert!(rl.peers.lock().unwrap().len() > 1);
 854         rl.record_failure(ip(255), t0 + Duration::from_secs(3600));
 855         assert_eq!(
 856             rl.peers.lock().unwrap().len(),
 857             1,
 858             "fully refilled peers should have been pruned"
 859         );
 860 
 861         // And the table is capped even when every entry is still penalized.
 862         let mut rl2 = RateLimiter::new();
 863         // One failure per peer is enough to create (and hold) an entry.
 864         for i in 0..(PIN_MAX_TRACKED_PEERS + 50) {
 865             rl2.record_failure(IpAddr::V4(Ipv4Addr::from((i as u32).to_be_bytes())), t0);
 866         }
 867         assert!(
 868             rl2.peers.get_mut().unwrap().len() <= PIN_MAX_TRACKED_PEERS,
 869             "table exceeded its cap"
 870         );
 871     }
 872 
 873     #[test]
 874     fn many_addresses_share_one_global_budget() {
 875         // One failure from each of a LAN's worth of addresses: every per-peer
 876         // bucket still has four left, and still the guessing stops.
 877         let rl = RateLimiter::new();
 878         let t0 = Instant::now();
 879         for i in 0..PIN_GLOBAL_BURST as u8 {
 880             assert!(rl.allow(ip(i), t0), "global attempt {i} should be allowed");
 881             rl.record_failure(ip(i), t0);
 882         }
 883         assert!(!rl.allow(ip(200), t0), "a fresh address must not bring a fresh budget");
 884         // The documented price: a paired phone waits too, then recovers.
 885         assert!(!rl.allow(ip(201), t0 + Duration::from_secs(29)));
 886         assert!(rl.allow(ip(201), t0 + Duration::from_secs(31)));
 887         // A success does not refund the global budget.
 888         rl.record_success(ip(0));
 889         assert!(!rl.allow(ip(0), t0));
 890     }
 891 
 892     #[test]
 893     fn only_loopback_and_the_tailnet_are_served_by_default() {
 894         let v4 = |a, b, c, d| IpAddr::V4(Ipv4Addr::new(a, b, c, d));
 895         let me_ts = v4(100, 105, 214, 102);
 896         let me_lan = v4(192, 168, 68, 55);
 897         let phone_ts = v4(100, 90, 1, 2);
 898         let phone_lan = v4(192, 168, 68, 77);
 899         let lo = v4(127, 0, 0, 1);
 900 
 901         assert!(reachable(phone_ts, me_ts, false));
 902         assert!(reachable(lo, lo, false));
 903         assert!(!reachable(phone_lan, me_lan, false), "the LAN is opt-in");
 904         // Linux takes a packet for the tailnet address off the Wi-Fi; the
 905         // peer is what gives it away.
 906         assert!(!reachable(phone_lan, me_ts, false));
 907         // A tailnet-shaped source aimed at the LAN address is not the tunnel.
 908         assert!(!reachable(phone_ts, me_lan, false));
 909         // Just outside 100.64.0.0/10 on either side.
 910         assert!(!reachable(v4(100, 63, 255, 255), me_ts, false));
 911         assert!(!reachable(v4(100, 128, 0, 1), me_ts, false));
 912         // Tailscale's IPv6 range, and v4 seen through a dual-stack socket.
 913         let ts6: IpAddr = "fd7a:115c:a1e0::dd34:d667".parse().unwrap();
 914         let other6: IpAddr = "fd7a:115c:a1e1::1".parse().unwrap();
 915         assert!(reachable(ts6, ts6, false));
 916         assert!(!reachable(other6, ts6, false));
 917         let mapped: IpAddr = "::ffff:100.90.1.2".parse().unwrap();
 918         assert!(reachable(mapped, me_ts, false));
 919 
 920         assert!(reachable(phone_lan, me_lan, true), "--lan serves everything");
 921     }
 922 
 923     // ---- the pairing PIN ----
 924     //
 925     // The other half of the security model: translate() decides what a paired
 926     // client may say, these decide who is paired at all. Both are reachable by
 927     // anyone who can open a socket to this port.
 928 
 929     const PIN: &str = "123456";
 930 
 931     fn head(lines: &[&str]) -> String {
 932         format!("{}\r\n\r\n", lines.join("\r\n"))
 933     }
 934 
 935     #[test]
 936     fn ws_auth_requires_exactly_auth_then_pin() {
 937         assert!(auth_frame_ok("auth 123456", PIN));
 938         assert!(auth_frame_ok("auth   123456  ", PIN)); // value is trimmed
 939         for frame in [
 940             "auth 123457",      // wrong PIN
 941             "auth 12345",       // prefix of it
 942             "auth 1234567",     // superstring of it
 943             "auth ",            // empty candidate
 944             "auth",             // no separator
 945             "AUTH 123456",      // verb is case-sensitive
 946             "auth123456",
 947             " auth 123456",     // must be the whole frame, unprefixed
 948             "m 1 2",            // an input event before pairing
 949             "",
 950         ] {
 951             assert!(!auth_frame_ok(frame, PIN), "{frame:?} must not authenticate");
 952         }
 953     }
 954 
 955     #[test]
 956     fn an_empty_pin_authorizes_nothing() {
 957         // load_or_create_pin() regenerates on an empty file, so this should be
 958         // unreachable — which is exactly why it is worth pinning. A truncated
 959         // PIN file must fail closed, not open.
 960         assert!(!auth_frame_ok("auth ", ""));
 961         assert!(!auth_frame_ok("auth", ""));
 962         assert!(!header_pin_ok(&head(&["GET /shot HTTP/1.1", "X-Pin:"]), ""));
 963         assert!(!header_pin_ok(&head(&["GET /shot HTTP/1.1", "X-Pin: "]), ""));
 964         assert!(!query_pin_ok(&head(&["GET /stream?pin= HTTP/1.1"]), ""));
 965     }
 966 
 967     #[test]
 968     fn x_pin_header_is_matched_case_insensitively_by_name_only() {
 969         for name in ["X-Pin", "x-pin", "X-PIN", "x-PiN"] {
 970             let h = head(&["GET /shot HTTP/1.1", &format!("{name}: {PIN}"), "Host: x"]);
 971             assert!(header_pin_ok(&h, PIN), "{name} should be accepted");
 972         }
 973         // Value whitespace is trimmed; the value itself must match exactly.
 974         assert!(header_pin_ok(&head(&["GET /shot HTTP/1.1", "X-Pin:   123456  "]), PIN));
 975         for bad in ["X-Pin: 123457", "X-Pin: 12345", "X-Pin: 1234567", "X-Pin:", "X-Pinx: 123456"] {
 976             let h = head(&["GET /shot HTTP/1.1", bad]);
 977             assert!(!header_pin_ok(&h, PIN), "{bad:?} must not authenticate");
 978         }
 979         // No header at all.
 980         assert!(!header_pin_ok(&head(&["GET /shot HTTP/1.1", "Host: x"]), PIN));
 981     }
 982 
 983     #[test]
 984     fn query_pin_is_a_parameter_not_a_substring() {
 985         assert!(query_pin_ok(&head(&["GET /stream?pin=123456 HTTP/1.1"]), PIN));
 986         assert!(query_pin_ok(&head(&["GET /stream?pin=123456&g=7 HTTP/1.1"]), PIN));
 987         assert!(query_pin_ok(&head(&["GET /stream?g=7&pin=123456 HTTP/1.1"]), PIN));
 988         for bad in [
 989             "GET /stream?notpin=123456 HTTP/1.1",  // substring match used to pass this
 990             "GET /stream?pin=1234567 HTTP/1.1",    // and this
 991             "GET /stream?xpin=123456 HTTP/1.1",
 992             "GET /stream?pin=12345 HTTP/1.1",
 993             "GET /stream?pin= HTTP/1.1",
 994             "GET /stream?pin HTTP/1.1",
 995             "GET /stream HTTP/1.1",                // no query at all
 996             "GET /pin=123456 HTTP/1.1",            // in the PATH, not the query
 997         ] {
 998             assert!(!query_pin_ok(&head(&[bad]), PIN), "{bad:?} must not authenticate");
 999         }
1000     }
1001 
1002     #[test]
1003     fn the_two_http_gates_are_not_interchangeable() {
1004         // /stream takes either (an <img src> cannot send headers); /shot takes
1005         // the header only, so the PIN stays out of URLs and logs where it can.
1006         let query_only = head(&["GET /stream?pin=123456 HTTP/1.1", "Host: x"]);
1007         assert!(query_pin_ok(&query_only, PIN));
1008         assert!(!header_pin_ok(&query_only, PIN), "/shot must not accept a URL PIN");
1009 
1010         let header_only = head(&["GET /shot HTTP/1.1", "X-Pin: 123456"]);
1011         assert!(header_pin_ok(&header_only, PIN));
1012         assert!(!query_pin_ok(&header_only, PIN));
1013     }
1014 
1015     #[test]
1016     fn pointer_and_scroll_carry_fixed_precision() {
1017         assert_eq!(one("m 1 -2"), "pointer-move-by 1.00 -2.00");
1018         assert_eq!(one("m 0.126 -0.126"), "pointer-move-by 0.13 -0.13");
1019         // Exact .5 ties round half-to-even, not away from zero — sub-pixel
1020         // detail the page never notices, but pin it so a formatting change
1021         // shows up here rather than as drifting pointer feel.
1022         assert_eq!(one("m 0.125 0.135"), "pointer-move-by 0.12 0.14");
1023         // `s` takes dy first; dx is optional and defaults to 0.
1024         assert_eq!(one("s 5"), "pointer-scroll 5.000 0.000");
1025         assert_eq!(one("s 5 -1.5"), "pointer-scroll 5.000 -1.500");
1026     }
1027 
1028     #[test]
1029     fn buttons_map_to_press_release_click() {
1030         assert_eq!(one("b left down"), "pointer-press left");
1031         assert_eq!(one("b left up"), "pointer-release left");
1032         assert_eq!(one("b right click"), "pointer-click right");
1033         assert_eq!(one("b middle click"), "pointer-click middle");
1034     }
1035 
1036     #[test]
1037     fn keys_map_to_tap_and_hold() {
1038         assert_eq!(one("k 28"), "keypress 28");
1039         assert_eq!(one("kd 42"), "key-down 42");
1040         assert_eq!(one("ku 42"), "key-up 42");
1041     }
1042 
1043     #[test]
1044     fn unknown_verbs_are_dropped() {
1045         // Note the compositor's own command names: a frame naming one directly
1046         // must NOT be honored, or the whitelist would be decorative.
1047         for frame in [
1048             "", "   ", "x 1", "exit", "spawn foot", "reload",
1049             "pointer-click left", "keypress 28", "restart-compositor",
1050             // retired 2026-08-23: view-mode taps are plain trackpad clicks,
1051             // so the verbs left the whitelist rather than lingering as
1052             // unused injection surface
1053             "tap 100 200", "tapr 5 5",
1054         ] {
1055             assert!(translate(frame).is_none(), "{frame:?} should be dropped");
1056         }
1057     }
1058 
1059     #[test]
1060     fn missing_or_malformed_arguments_are_dropped() {
1061         for frame in [
1062             "m", "m 1", "m a b", "m 1 b",
1063             "s", "s abc", "s 1 abc",
1064             "k", "k abc", "k -1", "k 1.5", "k 99999999999999999999",
1065             "kd", "ku",
1066             "b", "b left", "b left bogus", "b sideways click", "b LEFT click",
1067             "wf", "cmd",
1068         ] {
1069             assert!(translate(frame).is_none(), "{frame:?} should be dropped");
1070         }
1071     }
1072 
1073     #[test]
1074     fn non_finite_coordinates_are_dropped() {
1075         // The hazard is real rather than theoretical — this is exactly what
1076         // finite() exists to stop, and it is why parse().ok() alone is not
1077         // enough validation for a float.
1078         assert!("NaN".parse::<f64>().is_ok());
1079         assert_eq!(format!("{:.2}", "NaN".parse::<f64>().unwrap()), "NaN");
1080         assert_eq!(format!("{:.2}", "inf".parse::<f64>().unwrap()), "inf");
1081 
1082         for frame in [
1083             "m NaN 1", "m 1 NaN", "m inf 0", "m -inf 0", "m 1 infinity",
1084             "s NaN", "s 1 inf", "s nan 0",
1085         ] {
1086             assert!(translate(frame).is_none(), "{frame:?} should be dropped");
1087         }
1088     }
1089 
1090     #[test]
1091     fn focus_target_is_restricted_to_safe_tokens() {
1092         assert_eq!(one("wf 12"), "focus-window 12");
1093         assert_eq!(one("wf org.cce.files"), "focus-window org.cce.files");
1094         assert_eq!(one("wf a-b_c:d.1"), "focus-window a-b_c:d.1");
1095         for frame in [
1096             "wf ../etc", "wf a/b", "wf a;b", "wf a$b", "wf a*b",
1097             "wf a'b", "wf a\"b", "wf a|b", "wf a&b", "wf a\\b",
1098         ] {
1099             assert!(translate(frame).is_none(), "{frame:?} should be dropped");
1100         }
1101         // safe_token's length bound, exercised on both sides.
1102         assert!(translate(&format!("wf {}", "a".repeat(128))).is_some());
1103         assert!(translate(&format!("wf {}", "a".repeat(129))).is_none());
1104     }
1105 
1106     #[test]
1107     fn named_commands_are_whitelisted_never_passed_through() {
1108         assert_eq!(one("cmd restart-compositor"), "restart-compositor");
1109         // Trailing junk is discarded, not appended.
1110         assert_eq!(one("cmd restart-compositor rm -rf"), "restart-compositor");
1111         for frame in ["cmd exit", "cmd reload", "cmd spawn foot", "cmd RESTART-COMPOSITOR"] {
1112             assert!(translate(frame).is_none(), "{frame:?} should be dropped");
1113         }
1114     }
1115 
1116     #[test]
1117     fn a_newline_can_never_smuggle_a_second_command() {
1118         // control_command() appends "\n", so an embedded newline in the output
1119         // would be a second command on the socket. split_ascii_whitespace()
1120         // eats it and every command is REBUILT from re-parsed values, so
1121         // trailing tokens are discarded rather than forwarded.
1122         assert_eq!(one("m 1 2\npointer-click left"), "pointer-move-by 1.00 2.00");
1123         assert_eq!(one("wf 12\nexit"), "focus-window 12");
1124 
1125         // The property that matters, over every shape the page can send plus
1126         // deliberate junk: whatever comes out is a single line.
1127         for frame in [
1128             "m 1 2\nexit", "m 1\n2", "s 1\nexit", "b left\nclick", "b left click\nexit",
1129             "k 28\nexit", "kd 42\nexit", "ku 42\nexit", "wf 1\nexit",
1130             "cmd restart-compositor\nexit", "m\t1\t2", "wf\n12", "  m   1   2  ",
1131             "tap 1 2\nexit",
1132         ] {
1133             for out in translate(frame).unwrap_or_default() {
1134                 assert!(!out.contains('\n'), "{frame:?} produced a multi-line command: {out:?}");
1135                 assert!(!out.contains('\r'), "{frame:?} produced a CR: {out:?}");
1136             }
1137         }
1138     }
1139 }