anvilsign in

collin/browser-terminal-extension

1//! End-to-end: browser-shaped client -> WebSocket -> pty -> shell -> back.
2//!
3//! Uses `sh` rather than tmux so the test doesn't depend on a tmux server or
4//! leave sessions behind. The pty path is identical either way.
5
6use std::time::Duration;
7
8use futures_util::{SinkExt, StreamExt};
9use tokio_tungstenite::tungstenite::client::IntoClientRequest;
10use tokio_tungstenite::tungstenite::Message;
11
12use termbridge::pty::Profile;
13use termbridge::{Config, Server};
14
15const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
16const ORIGIN: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop";
17
18fn sh_profile() -> Profile {
19 Profile {
20 program: "/bin/sh".into(),
21 args: vec![],
22 }
23}
24
25async fn connect_authed(
26 server: &Server,
27) -> tokio_tungstenite::WebSocketStream<
28 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
29> {
30 let mut req = server.url().into_client_request().unwrap();
31 req.headers_mut().insert("origin", ORIGIN.parse().unwrap());
32 let (mut ws, _) = tokio_tungstenite::connect_async(req).await.unwrap();
33
34 ws.send(Message::Text(
35 serde_json::json!({"type": "auth", "token": TOKEN})
36 .to_string()
37 .into(),
38 ))
39 .await
40 .unwrap();
41 let ok = ws.next().await.unwrap().unwrap();
42 assert!(ok.to_text().unwrap().contains("\"ok\""), "{ok:?}");
43 ws
44}
45
46/// Read binary frames until `needle` shows up in the accumulated output.
47async fn wait_for(
48 ws: &mut tokio_tungstenite::WebSocketStream<
49 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
50 >,
51 needle: &str,
52) -> String {
53 let mut acc = Vec::new();
54 let found = tokio::time::timeout(Duration::from_secs(10), async {
55 while let Some(Ok(msg)) = ws.next().await {
56 if let Message::Binary(b) = msg {
57 acc.extend_from_slice(&b);
58 if String::from_utf8_lossy(&acc).contains(needle) {
59 return true;
60 }
61 }
62 }
63 false
64 })
65 .await
66 .unwrap_or(false);
67
68 let text = String::from_utf8_lossy(&acc).to_string();
69 assert!(found, "never saw {needle:?}; got:\n{}", printable(&text));
70 text
71}
72
73/// Escape control bytes so a failing assertion can't repaint the terminal that
74/// is printing it.
75fn printable(s: &str) -> String {
76 s.chars()
77 .map(|c| match c {
78 '\n' | '\t' => c.to_string(),
79 c if (c as u32) < 0x20 || c as u32 == 0x7f => format!("\\x{:02x}", c as u32),
80 c => c.to_string(),
81 })
82 .collect()
83}
84
85/// Wait until the shell inside tmux is actually consuming input.
86///
87/// tmux drops keystrokes that arrive before its client has finished attaching,
88/// so anything typed immediately after `open` can vanish. Probe until the echo
89/// comes back rather than assuming a fixed delay is enough.
90async fn wait_until_ready(
91 ws: &mut tokio_tungstenite::WebSocketStream<
92 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
93 >,
94 probe: &str,
95) {
96 let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
97 let mut acc = Vec::new();
98 loop {
99 assert!(
100 tokio::time::Instant::now() < deadline,
101 "shell never became ready; saw:\n{}",
102 printable(&String::from_utf8_lossy(&acc))
103 );
104 ws.send(Message::Binary(format!("echo {probe}\n").into_bytes().into()))
105 .await
106 .unwrap();
107
108 let until = tokio::time::Instant::now() + Duration::from_millis(700);
109 loop {
110 match tokio::time::timeout_at(until, ws.next()).await {
111 Ok(Some(Ok(Message::Binary(b)))) => {
112 acc.extend_from_slice(&b);
113 // Twice: once as terminal echo of what we typed, once as
114 // the command's own output. One occurrence only proves the
115 // characters were displayed, not that a shell ran them.
116 if String::from_utf8_lossy(&acc).matches(probe).count() >= 2 {
117 return;
118 }
119 }
120 Ok(Some(Ok(_))) => {}
121 Ok(Some(Err(e))) => panic!("connection error while waiting: {e}"),
122 Ok(None) => panic!("connection closed while waiting for the shell"),
123 Err(_) => break, // no progress this round; probe again
124 }
125 }
126 }
127}
128
129async fn start(profile: Profile) -> Server {
130 let mut cfg = Config::new(TOKEN, vec![ORIGIN.to_string()]);
131 cfg.profile = profile;
132 Server::start(cfg, 0).await.unwrap()
133}
134
135#[tokio::test]
136async fn shell_runs_and_output_comes_back_as_binary() {
137 let server = start(sh_profile()).await;
138 let mut ws = connect_authed(&server).await;
139
140 ws.send(Message::Text(
141 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
142 .to_string()
143 .into(),
144 ))
145 .await
146 .unwrap();
147
148 // Keystrokes go as binary, exactly as the sidebar will send them.
149 ws.send(Message::Binary(b"echo hello-from-pty\n".to_vec().into()))
150 .await
151 .unwrap();
152
153 wait_for(&mut ws, "hello-from-pty").await;
154}
155
156/// The pty must report the size we asked for — this is what makes tmux and vim
157/// lay out correctly in a narrow sidebar.
158#[tokio::test]
159async fn winsize_is_applied_and_resizable() {
160 let server = start(sh_profile()).await;
161 let mut ws = connect_authed(&server).await;
162
163 ws.send(Message::Text(
164 serde_json::json!({"type": "open", "cols": 40, "rows": 20})
165 .to_string()
166 .into(),
167 ))
168 .await
169 .unwrap();
170 ws.send(Message::Binary(b"stty size\n".to_vec().into()))
171 .await
172 .unwrap();
173 wait_for(&mut ws, "20 40").await;
174
175 // Now resize, as the sidebar does when the user drags its edge.
176 ws.send(Message::Text(
177 serde_json::json!({"type": "resize", "cols": 100, "rows": 30})
178 .to_string()
179 .into(),
180 ))
181 .await
182 .unwrap();
183 tokio::time::sleep(Duration::from_millis(200)).await;
184 ws.send(Message::Binary(b"stty size\n".to_vec().into()))
185 .await
186 .unwrap();
187 wait_for(&mut ws, "30 100").await;
188}
189
190/// The pty is a byte pipe. Anything that isn't valid UTF-8 must survive, which
191/// is the property a JSON transport could not have given us.
192#[tokio::test]
193async fn non_utf8_output_survives_intact() {
194 let server = start(sh_profile()).await;
195 let mut ws = connect_authed(&server).await;
196 ws.send(Message::Text(
197 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
198 .to_string()
199 .into(),
200 ))
201 .await
202 .unwrap();
203
204 // 0xff is not valid UTF-8 anywhere. Bracket it so we can find it.
205 ws.send(Message::Binary(
206 b"printf 'S\\377\\376E\\n'\n".to_vec().into(),
207 ))
208 .await
209 .unwrap();
210
211 let mut acc = Vec::new();
212 let found = tokio::time::timeout(Duration::from_secs(10), async {
213 while let Some(Ok(Message::Binary(b))) = ws.next().await {
214 acc.extend_from_slice(&b);
215 if acc.windows(4).any(|w| w == [b'S', 0xff, 0xfe, b'E']) {
216 return true;
217 }
218 }
219 false
220 })
221 .await
222 .unwrap_or(false);
223
224 assert!(
225 found,
226 "raw bytes were mangled in transit; got {:x?}",
227 &acc[..acc.len().min(400)]
228 );
229}
230
231/// Exiting the shell must be reported, not silently hang the sidebar.
232#[tokio::test]
233async fn shell_exit_is_reported() {
234 let server = start(sh_profile()).await;
235 let mut ws = connect_authed(&server).await;
236 ws.send(Message::Text(
237 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
238 .to_string()
239 .into(),
240 ))
241 .await
242 .unwrap();
243 ws.send(Message::Binary(b"exit 7\n".to_vec().into()))
244 .await
245 .unwrap();
246
247 let got = tokio::time::timeout(Duration::from_secs(10), async {
248 while let Some(Ok(msg)) = ws.next().await {
249 if let Message::Text(t) = msg {
250 if t.contains("\"exit\"") {
251 return Some(t.to_string());
252 }
253 }
254 }
255 None
256 })
257 .await
258 .unwrap_or(None);
259
260 assert!(got.is_some(), "expected an exit message");
261}
262
263/// The client cannot choose what runs. Even authenticated, the protocol has no
264/// field for argv — an auth bypass gets you the configured profile, not `exec`.
265#[tokio::test]
266async fn client_cannot_choose_the_program() {
267 let server = start(sh_profile()).await;
268 let mut ws = connect_authed(&server).await;
269
270 // Every field an attacker might hope is honoured.
271 ws.send(Message::Text(
272 serde_json::json!({
273 "type": "open", "cols": 80, "rows": 24,
274 "cmd": ["/bin/sh", "-c", "echo PWNED"],
275 "command": "echo PWNED",
276 "program": "/usr/bin/id",
277 "args": ["-a"],
278 "env": {"LD_PRELOAD": "/tmp/evil.so"}
279 })
280 .to_string()
281 .into(),
282 ))
283 .await
284 .unwrap();
285
286 // Prove the session is the configured shell and that nothing else ran.
287 ws.send(Message::Binary(b"echo marker-$((6*7))\n".to_vec().into()))
288 .await
289 .unwrap();
290 let out = wait_for(&mut ws, "marker-42").await;
291 assert!(
292 !out.contains("PWNED"),
293 "client-supplied command was executed:\n{out}"
294 );
295}
296
297/// The whole reason tmux is the persistence layer: state must survive the
298/// sidebar closing. Disconnect, reconnect, and the shell is still there.
299#[tokio::test]
300async fn tmux_session_survives_disconnect() {
301 if !Profile::tmux_available() {
302 eprintln!("skipping: tmux not installed");
303 return;
304 }
305 // Private socket + session so we never touch the user's real tmux.
306 let sock = "termbridge-test";
307 let sess = "termbridge-e2e";
308 let tmux = |args: &[&str]| {
309 std::process::Command::new("tmux")
310 .args(["-L", sock])
311 .args(args)
312 .output()
313 };
314 let _ = tmux(&["kill-server"]);
315
316 let profile = Profile {
317 program: "tmux".into(),
318 // Force /bin/sh: tmux's default-shell may be fish, whose assignment
319 // syntax differs. The test is about persistence, not shell dialects.
320 args: ["-L", sock, "new-session", "-A", "-s", sess, "/bin/sh"]
321 .iter()
322 .map(|s| s.to_string())
323 .collect(),
324 };
325
326 let server = start(profile).await;
327
328 // First attach: leave a marker in the shell's state.
329 {
330 let mut ws = connect_authed(&server).await;
331 ws.send(Message::Text(
332 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
333 .to_string()
334 .into(),
335 ))
336 .await
337 .unwrap();
338 wait_until_ready(&mut ws, "boot-one").await;
339 ws.send(Message::Binary(
340 b"MARKER=survived-the-disconnect\n".to_vec().into(),
341 ))
342 .await
343 .unwrap();
344 ws.send(Message::Binary(b"echo first-attach-ok\n".to_vec().into()))
345 .await
346 .unwrap();
347 wait_for(&mut ws, "first-attach-ok").await;
348 ws.close(None).await.unwrap();
349 } // socket dropped == sidebar closed
350
351 tokio::time::sleep(Duration::from_millis(500)).await;
352
353 // Second attach: same session, shell variable still set.
354 {
355 let mut ws = connect_authed(&server).await;
356 ws.send(Message::Text(
357 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
358 .to_string()
359 .into(),
360 ))
361 .await
362 .unwrap();
363 wait_until_ready(&mut ws, "boot-two").await;
364 ws.send(Message::Binary(b"echo \"[$MARKER]\"\n".to_vec().into()))
365 .await
366 .unwrap();
367 wait_for(&mut ws, "[survived-the-disconnect]").await;
368 ws.close(None).await.unwrap();
369 }
370
371 let _ = tmux(&["kill-server"]);
372}
373
374// --- tmux session selection -------------------------------------------------
375
376/// Session names reach `execvp` as a separate argv element, so shell
377/// metacharacters cannot inject a command. A leading dash is the real hazard —
378/// tmux would read it as a flag — and the charset check backs that up.
379#[test]
380fn session_name_validation() {
381 use termbridge::pty::valid_session_name as v;
382
383 for good in ["browser", "work", "my-session", "proj_2", "A1"] {
384 assert_eq!(v(good).as_deref(), Some(good), "{good} should be accepted");
385 }
386
387 for bad in [
388 "",
389 " ",
390 "-C", // tmux would read this as a flag
391 "--help",
392 "a b", // tmux disallows
393 "a.b", // tmux disallows '.'
394 "a:b", // tmux disallows ':'
395 "a/b",
396 "a;id",
397 "a$(id)",
398 "a`id`",
399 "a|b",
400 "a&b",
401 "a\nb",
402 "a\0b",
403 "a'b",
404 "a\"b",
405 "../../etc/passwd",
406 "sess$IFS",
407 ] {
408 assert_eq!(v(bad), None, "{bad:?} must be rejected");
409 }
410
411 assert_eq!(v(&"x".repeat(65)), None, "absurd length must be rejected");
412 assert_eq!(v(" padded ").as_deref(), Some("padded"), "trims");
413}
414
415/// A rejected session name must fall back to the default, never reach tmux, and
416/// never change what program runs.
417#[tokio::test]
418async fn hostile_session_name_falls_back_and_cannot_inject() {
419 if !Profile::tmux_available() {
420 eprintln!("skipping: tmux not installed");
421 return;
422 }
423 let sock = "termbridge-inject";
424 let _ = std::process::Command::new("tmux")
425 .args(["-L", sock, "kill-server"])
426 .output();
427
428 // A tmux profile pinned to a private socket so the test is self-contained.
429 let profile = Profile {
430 program: "tmux".into(),
431 args: ["-L", sock, "new-session", "-A", "-s", "safe", "/bin/sh"]
432 .iter()
433 .map(|s| s.to_string())
434 .collect(),
435 };
436 let server = start(profile).await;
437 let mut ws = connect_authed(&server).await;
438
439 ws.send(Message::Text(
440 serde_json::json!({
441 "type": "open", "cols": 80, "rows": 24,
442 "session": "evil; touch /tmp/termbridge-pwned; #"
443 })
444 .to_string()
445 .into(),
446 ))
447 .await
448 .unwrap();
449
450 wait_until_ready(&mut ws, "boot-inject").await;
451 ws.send(Message::Binary(b"echo marker-$((6*7))\n".to_vec().into()))
452 .await
453 .unwrap();
454 wait_for(&mut ws, "marker-42").await;
455
456 assert!(
457 !std::path::Path::new("/tmp/termbridge-pwned").exists(),
458 "session name was interpreted by a shell"
459 );
460
461 let _ = std::process::Command::new("tmux")
462 .args(["-L", sock, "kill-server"])
463 .output();
464}
465
466/// The status channel, end to end: the daemon's control-mode client reports
467/// which session we're on, lists the others, and moves the live client when the
468/// sidebar asks — all on a private tmux server so the user's own is untouched.
469#[tokio::test]
470async fn status_frames_track_the_session_and_switching_moves_the_client() {
471 if !Profile::tmux_available() {
472 eprintln!("skipping: tmux not installed");
473 return;
474 }
475 let sock = "termbridge-status-test";
476 let (first, second) = ("tb-status-one", "tb-status-two");
477 let tmux = |args: &[&str]| {
478 std::process::Command::new("tmux")
479 .args(["-L", sock])
480 .args(args)
481 .output()
482 };
483 let _ = tmux(&["kill-server"]);
484 let _ = tmux(&["new-session", "-d", "-s", second, "/bin/sh"]);
485
486 let profile = Profile {
487 program: "tmux".into(),
488 args: ["-L", sock, "new-session", "-A", "-s", first, "/bin/sh"]
489 .iter()
490 .map(|s| s.to_string())
491 .collect(),
492 };
493 let server = start(profile).await;
494 let mut ws = connect_authed(&server).await;
495 ws.send(Message::Text(
496 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
497 .to_string()
498 .into(),
499 ))
500 .await
501 .unwrap();
502
503 let on_first = next_status(&mut ws, first).await;
504 let names: Vec<&str> = on_first["sessions"]
505 .as_array()
506 .unwrap()
507 .iter()
508 .map(|s| s["name"].as_str().unwrap())
509 .collect();
510 assert!(
511 names.contains(&first) && names.contains(&second),
512 "switcher should see both sessions, got {names:?}"
513 );
514
515 // The whole point of doing this over control mode: no reconnect, no second
516 // pty, the same WebSocket keeps streaming.
517 ws.send(Message::Text(
518 serde_json::json!({"type": "tmux", "cmd": "switch", "session": second})
519 .to_string()
520 .into(),
521 ))
522 .await
523 .unwrap();
524 next_status(&mut ws, second).await;
525
526 let _ = tmux(&["kill-server"]);
527}
528
529/// Read frames until a `status` frame reports `session`, ignoring terminal
530/// output and the intermediate states tmux passes through.
531async fn next_status(
532 ws: &mut tokio_tungstenite::WebSocketStream<
533 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
534 >,
535 session: &str,
536) -> serde_json::Value {
537 let mut seen = Vec::new();
538 let found = tokio::time::timeout(Duration::from_secs(20), async {
539 while let Some(Ok(msg)) = ws.next().await {
540 let Message::Text(t) = msg else { continue };
541 let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) else {
542 continue;
543 };
544 if v["type"] != "status" {
545 continue;
546 }
547 seen.push(v["session"].as_str().unwrap_or("").to_string());
548 if v["session"] == session {
549 return Some(v);
550 }
551 }
552 None
553 })
554 .await
555 .ok()
556 .flatten();
557
558 found.unwrap_or_else(|| panic!("never saw a status frame for {session:?}; saw {seen:?}"))
559}