use std::fs; use std::io::{Read, Write}; use std::net::UdpSocket; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::thread; use std::time::Duration; use base64::Engine; use base64::engine::general_purpose::STANDARD; use dosh::auth::{build_bootstrap, load_or_create_server_secret}; use dosh::config::load_server_config; use dosh::crypto; use dosh::native::{host_public_key, load_or_create_host_key, ssh_ed25519_public_blob, trust_host}; use dosh::protocol::{ self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, }; use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; fn free_udp_port() -> u16 { let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); socket.local_addr().unwrap().port() } fn write_server_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf { write_server_config_with_timeout(dir, port, 30) } fn write_server_config_with_timeout( dir: &tempfile::TempDir, port: u16, client_timeout_secs: u64, ) -> std::path::PathBuf { let config_dir = dir.path().join(".config/dosh"); fs::create_dir_all(&config_dir).unwrap(); let config = config_dir.join("server.toml"); fs::write( &config, format!( r#" port = {port} bind = "127.0.0.1" scrollback = 5000 auth_ttl_secs = 30 attach_ticket_ttl_secs = 3600 allow_attach_tickets = true client_timeout_secs = {client_timeout_secs} retransmit_window = 256 default_input_mode = "read-write" prewarm_sessions = ["default"] create_on_attach = true shell = "/bin/sh" sessions_dir = "{sessions}" secret_path = "{secret}" host_key = "{host_key}" authorized_keys = ["{authorized_keys}"] "#, sessions = dir.path().join("sessions").display(), secret = dir.path().join("secret").display(), host_key = dir.path().join("host_key").display(), authorized_keys = dir.path().join("authorized_keys").display(), ), ) .unwrap(); config } fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child { let server = env!("CARGO_BIN_EXE_dosh-server"); let child = Command::new(server) .arg("serve") .arg("--config") .arg(config) .env("HOME", dir.path()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .unwrap(); thread::sleep(Duration::from_millis(500)); child } fn attach_once(dir: &tempfile::TempDir, port: u16, cache: bool) -> std::process::Output { let client = env!("CARGO_BIN_EXE_dosh-client"); let mut cmd = Command::new(client); cmd.arg("--local-auth") .arg("--attach-only") .arg("-v") .arg("--session") .arg("default") .arg("--dosh-port") .arg(port.to_string()) .arg("local") .env("HOME", dir.path()); if !cache { cmd.arg("--no-cache"); } cmd.output().unwrap() } fn native_attach_once(dir: &tempfile::TempDir, port: u16) -> std::process::Output { let client = env!("CARGO_BIN_EXE_dosh-client"); Command::new(client) .arg("--auth") .arg("native") .arg("--attach-only") .arg("-v") .arg("--session") .arg("default") .arg("--dosh-host") .arg("127.0.0.1") .arg("--dosh-port") .arg(port.to_string()) .arg("local") .env("HOME", dir.path()) .output() .unwrap() } fn native_attach_once_with_agent( dir: &tempfile::TempDir, port: u16, agent_sock: &Path, ) -> std::process::Output { let client = env!("CARGO_BIN_EXE_dosh-client"); Command::new(client) .arg("--auth") .arg("native") .arg("--attach-only") .arg("-v") .arg("--session") .arg("default") .arg("--dosh-host") .arg("127.0.0.1") .arg("--dosh-port") .arg(port.to_string()) .arg("local") .env("HOME", dir.path()) .env("SSH_AUTH_SOCK", agent_sock) .output() .unwrap() } fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) { let ssh_dir = dir.path().join(".ssh"); fs::create_dir_all(&ssh_dir).unwrap(); let signing = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]); let keypair = ssh_key::private::Ed25519Keypair::from(&signing); let private = ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "test").unwrap(); private .write_openssh_file(&ssh_dir.join("id_ed25519"), ssh_key::LineEnding::LF) .unwrap(); fs::write( dir.path().join("authorized_keys"), format!("{}\n", private.public_key().to_openssh().unwrap()), ) .unwrap(); let config = load_server_config(Some(config_path.to_path_buf())).unwrap(); let host_signing = load_or_create_host_key(&config).unwrap(); let known_hosts = dir.path().join(".config/dosh/known_hosts"); trust_host( &known_hosts, "local", &host_public_key(&host_signing), "test", false, ) .unwrap(); } fn write_native_agent_auth( dir: &tempfile::TempDir, config_path: &std::path::Path, signing: &SigningKey, ) { let public = VerifyingKey::from(signing).to_bytes(); fs::write( dir.path().join("authorized_keys"), format!( "ssh-ed25519 {} agent-key\n", STANDARD.encode(ssh_ed25519_public_blob(&public)) ), ) .unwrap(); let config = load_server_config(Some(config_path.to_path_buf())).unwrap(); let host_signing = load_or_create_host_key(&config).unwrap(); let known_hosts = dir.path().join(".config/dosh/known_hosts"); trust_host( &known_hosts, "local", &host_public_key(&host_signing), "test", false, ) .unwrap(); } fn start_fake_ssh_agent(dir: &tempfile::TempDir, signing: SigningKey) -> PathBuf { let socket_path = dir.path().join("agent.sock"); let public = VerifyingKey::from(&signing).to_bytes(); let key_blob = ssh_ed25519_public_blob(&public); let listener = UnixListener::bind(&socket_path).unwrap(); thread::spawn(move || fake_ssh_agent(listener, key_blob, signing)); socket_path } fn fake_ssh_agent(listener: UnixListener, key_blob: Vec, signing: SigningKey) { let (mut stream, _) = listener.accept().unwrap(); let request = read_agent_packet(&mut stream).unwrap(); assert_eq!(request, vec![11]); let mut response = Vec::new(); response.push(12); response.extend_from_slice(&1u32.to_be_bytes()); write_ssh_string(&mut response, &key_blob); write_ssh_string(&mut response, b"integration-agent-key"); write_agent_packet(&mut stream, &response).unwrap(); let request = read_agent_packet(&mut stream).unwrap(); let mut cursor = request.as_slice(); assert_eq!(read_u8(&mut cursor), 13); assert_eq!(read_ssh_string(&mut cursor), key_blob.as_slice()); let transcript = read_ssh_string(&mut cursor); assert_eq!(read_u32(&mut cursor), 0); assert!(cursor.is_empty()); let signature = signing.sign(transcript).to_bytes(); let mut signature_blob = Vec::new(); write_ssh_string(&mut signature_blob, b"ssh-ed25519"); write_ssh_string(&mut signature_blob, &signature); let mut response = Vec::new(); response.push(14); write_ssh_string(&mut response, &signature_blob); write_agent_packet(&mut stream, &response).unwrap(); } fn read_agent_packet(stream: &mut UnixStream) -> std::io::Result> { let mut len = [0u8; 4]; stream.read_exact(&mut len)?; let len = u32::from_be_bytes(len) as usize; let mut payload = vec![0u8; len]; stream.read_exact(&mut payload)?; Ok(payload) } fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> std::io::Result<()> { stream.write_all(&(payload.len() as u32).to_be_bytes())?; stream.write_all(payload) } fn read_u8(cursor: &mut &[u8]) -> u8 { let value = cursor[0]; *cursor = &cursor[1..]; value } fn read_u32(cursor: &mut &[u8]) -> u32 { let value = u32::from_be_bytes(cursor[..4].try_into().unwrap()); *cursor = &cursor[4..]; value } fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> &'a [u8] { let len = read_u32(cursor) as usize; let (value, rest) = cursor.split_at(len); *cursor = rest; value } fn write_ssh_string(out: &mut Vec, value: &[u8]) { out.extend_from_slice(&(value.len() as u32).to_be_bytes()); out.extend_from_slice(value); } fn direct_attach( config: &std::path::Path, port: u16, mode: &str, ) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) { let config = load_server_config(Some(config.to_path_buf())).unwrap(); let secret = load_or_create_server_secret(&config).unwrap(); let bootstrap = build_bootstrap( &config, &secret, "tester".to_string(), "default".to_string(), mode.to_string(), (80, 24), crypto::random_12(), "127.0.0.1".to_string(), ) .unwrap(); let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); socket .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); let req = BootstrapAttachRequest { bootstrap: bootstrap.clone(), cols: 80, rows: 24, }; let packet = protocol::encode_plain( PacketKind::BootstrapAttachRequest, [0u8; 16], 1, 0, &protocol::to_body(&req).unwrap(), ) .unwrap(); socket .send_to(&packet, format!("127.0.0.1:{port}")) .unwrap(); let mut buf = [0u8; 65535]; let (n, _) = socket.recv_from(&mut buf).unwrap(); let packet = protocol::decode(&buf[..n]).unwrap(); assert_eq!(packet.header.kind, PacketKind::AttachOk); let plain = protocol::decrypt_body(&packet, &bootstrap.session_key, SERVER_TO_CLIENT).unwrap(); let ok: AttachOk = protocol::from_body(&plain).unwrap(); (socket, bootstrap, ok) } fn send_encrypted( socket: &UdpSocket, port: u16, kind: PacketKind, client_id: [u8; 16], seq: u64, ack: u64, key: &[u8; 32], body: &[u8], ) { let packet = protocol::encode_encrypted(kind, client_id, seq, ack, key, CLIENT_TO_SERVER, body).unwrap(); socket .send_to(&packet, format!("127.0.0.1:{port}")) .unwrap(); } fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(protocol::Header, Frame)> { let mut buf = [0u8; 65535]; let (n, _) = socket.recv_from(&mut buf).ok()?; let packet = protocol::decode(&buf[..n]).ok()?; match packet.header.kind { PacketKind::Frame | PacketKind::ResumeOk => { let plain = protocol::decrypt_body(&packet, key, SERVER_TO_CLIENT).ok()?; let frame: Frame = protocol::from_body(&plain).ok()?; Some((packet.header, frame)) } _ => None, } } fn collect_frame_text(socket: &UdpSocket, key: &[u8; 32], millis: u64) -> String { socket .set_read_timeout(Some(Duration::from_millis(100))) .unwrap(); let deadline = std::time::Instant::now() + Duration::from_millis(millis); let mut text = String::new(); while std::time::Instant::now() < deadline { if let Some((_header, frame)) = recv_frame(socket, key) { text.push_str(&String::from_utf8_lossy(&frame.bytes)); } } socket .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); text } #[test] fn unknown_live_client_gets_reject() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let mut server = start_server(&dir, &config); let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); socket .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); let client_id = crypto::random_16(); let key = crypto::random_32(); let packet = protocol::encode_encrypted( PacketKind::Ping, client_id, 1, 0, &key, CLIENT_TO_SERVER, b"", ) .unwrap(); socket .send_to(&packet, format!("127.0.0.1:{port}")) .unwrap(); let mut buf = [0u8; 65535]; let (n, _) = socket.recv_from(&mut buf).unwrap(); let packet = protocol::decode(&buf[..n]).unwrap(); let reject: AttachReject = protocol::from_body(&packet.body).unwrap(); let _ = server.kill(); let _ = server.wait(); assert_eq!(packet.header.kind, PacketKind::AttachReject); assert_eq!(packet.header.conn_id, client_id); assert_eq!(reject.reason, "unknown client"); } #[test] fn disconnected_client_times_out_and_gets_reject() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config_with_timeout(&dir, port, 1); let mut server = start_server(&dir, &config); let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); thread::sleep(Duration::from_secs(6)); send_encrypted( &socket, port, PacketKind::Ping, ok.client_id, 2, 0, &bootstrap.session_key, b"", ); let mut buf = [0u8; 65535]; let (n, _) = socket.recv_from(&mut buf).unwrap(); let packet = protocol::decode(&buf[..n]).unwrap(); let reject: AttachReject = protocol::from_body(&packet.body).unwrap(); let _ = server.kill(); let _ = server.wait(); assert_eq!(packet.header.kind, PacketKind::AttachReject); assert_eq!(packet.header.conn_id, ok.client_id); assert_eq!(reject.reason, "unknown client"); } #[test] fn local_attach_only_smoke() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let mut server = start_server(&dir, &config); let output = attach_once(&dir, port, false); let _ = server.kill(); let _ = server.wait(); assert!( output.status.success(), "stderr={}", String::from_utf8_lossy(&output.stderr) ); assert!( String::from_utf8_lossy(&output.stderr).contains("terminal_ready"), "stderr={}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn native_attach_only_smoke() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); write_native_client_auth(&dir, &config); let mut server = start_server(&dir, &config); let output = native_attach_once(&dir, port); let _ = server.kill(); let _ = server.wait(); let stderr = String::from_utf8_lossy(&output.stderr); assert!(output.status.success(), "stderr={stderr}"); assert!(stderr.contains("native_auth"), "stderr={stderr}"); assert!(stderr.contains("terminal_ready"), "stderr={stderr}"); } #[test] fn native_attach_only_smoke_uses_ssh_agent() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let signing = SigningKey::from_bytes(&[53u8; 32]); write_native_agent_auth(&dir, &config, &signing); let agent_sock = start_fake_ssh_agent(&dir, signing); let mut server = start_server(&dir, &config); let output = native_attach_once_with_agent(&dir, port, &agent_sock); let _ = server.kill(); let _ = server.wait(); let stderr = String::from_utf8_lossy(&output.stderr); assert!(output.status.success(), "stderr={stderr}"); assert!(stderr.contains("native_auth"), "stderr={stderr}"); assert!(stderr.contains("terminal_ready"), "stderr={stderr}"); } #[test] fn ticket_attach_after_server_restart_smoke() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let mut server = start_server(&dir, &config); let first = attach_once(&dir, port, true); let _ = server.kill(); let _ = server.wait(); assert!( first.status.success(), "stderr={}", String::from_utf8_lossy(&first.stderr) ); let mut server = start_server(&dir, &config); let second = attach_once(&dir, port, true); let _ = server.kill(); let _ = server.wait(); let stderr = String::from_utf8_lossy(&second.stderr); assert!(second.status.success(), "stderr={stderr}"); assert!( stderr.contains("udp_ticket_attach_ready"), "stderr={stderr}" ); } #[test] fn view_only_input_is_rejected_by_server() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let mut server = start_server(&dir, &config); let (view_socket, view_bootstrap, view_ok) = direct_attach(&config, port, "view-only"); let input = Input { bytes: b"echo DOSH_VIEW_ONLY_BUG\n".to_vec(), }; let packet = protocol::encode_encrypted( PacketKind::Input, view_ok.client_id, 2, 0, &view_bootstrap.session_key, CLIENT_TO_SERVER, &protocol::to_body(&input).unwrap(), ) .unwrap(); view_socket .send_to(&packet, format!("127.0.0.1:{port}")) .unwrap(); thread::sleep(Duration::from_millis(250)); let (_rw_socket, _rw_bootstrap, rw_ok) = direct_attach(&config, port, "read-write"); let snapshot = String::from_utf8_lossy(&rw_ok.snapshot); let _ = server.kill(); let _ = server.wait(); assert!( !snapshot.contains("DOSH_VIEW_ONLY_BUG"), "view-only input reached PTY; snapshot={snapshot:?}" ); } #[test] fn multiple_clients_share_one_session_screen() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let mut server = start_server(&dir, &config); let (writer_socket, writer_bootstrap, writer_ok) = direct_attach(&config, port, "read-write"); let (reader_socket, reader_bootstrap, _reader_ok) = direct_attach(&config, port, "read-write"); let input = Input { bytes: b"printf DOSH_MULTI_CLIENT\\n\n".to_vec(), }; send_encrypted( &writer_socket, port, PacketKind::Input, writer_ok.client_id, 2, 0, &writer_bootstrap.session_key, &protocol::to_body(&input).unwrap(), ); let text = collect_frame_text(&reader_socket, &reader_bootstrap.session_key, 2000); let _ = server.kill(); let _ = server.wait(); assert!( text.contains("DOSH_MULTI_CLIENT"), "expected second client to see first client's output, got {text:?}" ); } #[test] fn server_retransmits_unacked_output_frames() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let mut server = start_server(&dir, &config); let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); let input = Input { bytes: b"printf DOSH_RETRANSMIT\\n\n".to_vec(), }; send_encrypted( &socket, port, PacketKind::Input, ok.client_id, 2, 0, &bootstrap.session_key, &protocol::to_body(&input).unwrap(), ); let mut seen = Vec::new(); let mut duplicate = None; let deadline = std::time::Instant::now() + Duration::from_secs(3); while std::time::Instant::now() < deadline { if let Some((header, frame)) = recv_frame(&socket, &bootstrap.session_key) { if String::from_utf8_lossy(&frame.bytes).contains("DOSH_RETRANSMIT") { if seen .iter() .any(|(_, output_seq)| *output_seq == frame.output_seq) { duplicate = Some((header.seq, frame.output_seq)); break; } seen.push((header.seq, frame.output_seq)); } } } let _ = server.kill(); let _ = server.wait(); assert!( duplicate.is_some(), "expected retransmitted same output seq, seen={seen:?}" ); } #[test] fn resize_updates_pty_size() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let mut server = start_server(&dir, &config); let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); let resize = Resize { cols: 100, rows: 10, }; send_encrypted( &socket, port, PacketKind::Resize, ok.client_id, 2, 0, &bootstrap.session_key, &protocol::to_body(&resize).unwrap(), ); thread::sleep(Duration::from_millis(100)); let input = Input { bytes: b"stty size\n".to_vec(), }; send_encrypted( &socket, port, PacketKind::Input, ok.client_id, 3, 0, &bootstrap.session_key, &protocol::to_body(&input).unwrap(), ); let text = collect_frame_text(&socket, &bootstrap.session_key, 2000); let _ = server.kill(); let _ = server.wait(); assert!( text.contains("10 100"), "expected resized pty to report 10 100, got {text:?}" ); } #[test] fn pty_exit_sends_closed_frame() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let mut server = start_server(&dir, &config); let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); let input = Input { bytes: b"exit\n".to_vec(), }; send_encrypted( &socket, port, PacketKind::Input, ok.client_id, 2, 0, &bootstrap.session_key, &protocol::to_body(&input).unwrap(), ); let mut closed = false; let deadline = std::time::Instant::now() + Duration::from_secs(3); while std::time::Instant::now() < deadline { if let Some((_header, frame)) = recv_frame(&socket, &bootstrap.session_key) { if frame.closed { closed = true; break; } } } let _ = server.kill(); let _ = server.wait(); assert!(closed, "expected closed frame after shell exit"); } #[test] fn pty_sets_terminal_environment() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let mut server = start_server(&dir, &config); let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); let input = Input { bytes: b"printf \"$TERM\\n\"\n".to_vec(), }; send_encrypted( &socket, port, PacketKind::Input, ok.client_id, 2, 0, &bootstrap.session_key, &protocol::to_body(&input).unwrap(), ); let text = collect_frame_text(&socket, &bootstrap.session_key, 2000); let _ = server.kill(); let _ = server.wait(); assert!( text.contains("xterm-256color"), "expected TERM=xterm-256color, got {text:?}" ); } #[test] fn live_output_forwards_terminal_control_sequences() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let mut server = start_server(&dir, &config); let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); let input = Input { bytes: b"printf '\\033[?1049hDOSH_ALT_SCREEN\\033[?1049l\\n'\n".to_vec(), }; send_encrypted( &socket, port, PacketKind::Input, ok.client_id, 2, 0, &bootstrap.session_key, &protocol::to_body(&input).unwrap(), ); let text = collect_frame_text(&socket, &bootstrap.session_key, 2000); let _ = server.kill(); let _ = server.wait(); assert!( text.contains("\x1b[?1049h") && text.contains("\x1b[?1049l"), "expected raw alternate-screen control sequences, got {text:?}" ); } #[test] fn resume_snapshot_preserves_alternate_screen_mode() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let mut server = start_server(&dir, &config); let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); let input = Input { bytes: b"printf '\\033[?1049hALT_SNAPSHOT'\n".to_vec(), }; send_encrypted( &socket, port, PacketKind::Input, ok.client_id, 2, 0, &bootstrap.session_key, &protocol::to_body(&input).unwrap(), ); let _ = collect_frame_text(&socket, &bootstrap.session_key, 500); let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap(); new_socket .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); let resume = ResumeRequest { session: "default".to_string(), last_rendered_seq: ok.initial_seq, cols: 80, rows: 24, }; send_encrypted( &new_socket, port, PacketKind::ResumeRequest, ok.client_id, 3, 0, &bootstrap.session_key, &protocol::to_body(&resume).unwrap(), ); let (_header, resume_frame) = recv_frame(&new_socket, &bootstrap.session_key).expect("resume response"); let _ = server.kill(); let _ = server.wait(); assert!(resume_frame.snapshot); assert!( resume_frame.bytes.starts_with(b"\x1b[?1049h"), "snapshot did not enter alternate screen first: {:?}", String::from_utf8_lossy(&resume_frame.bytes) ); } #[test] fn resume_updates_udp_endpoint_for_roaming() { let dir = tempfile::tempdir().unwrap(); let port = free_udp_port(); let config = write_server_config(&dir, port); let mut server = start_server(&dir, &config); let (_old_socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap(); new_socket .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); let resume = ResumeRequest { session: "default".to_string(), last_rendered_seq: ok.initial_seq, cols: 80, rows: 24, }; send_encrypted( &new_socket, port, PacketKind::ResumeRequest, ok.client_id, 1, 0, &bootstrap.session_key, &protocol::to_body(&resume).unwrap(), ); let (_header, resume_frame) = recv_frame(&new_socket, &bootstrap.session_key).expect("resume response on new socket"); assert!(resume_frame.snapshot); let input = Input { bytes: b"printf DOSH_ROAM\\n\n".to_vec(), }; send_encrypted( &new_socket, port, PacketKind::Input, ok.client_id, 2, resume_frame.output_seq, &bootstrap.session_key, &protocol::to_body(&input).unwrap(), ); let text = collect_frame_text(&new_socket, &bootstrap.session_key, 2000); let _ = server.kill(); let _ = server.wait(); assert!( text.contains("DOSH_ROAM"), "expected output on resumed socket, got {text:?}" ); }