diff --git a/apps/nesinit/src/attach.rs b/apps/nesinit/src/attach.rs new file mode 100644 index 00000000..dae141fd --- /dev/null +++ b/apps/nesinit/src/attach.rs @@ -0,0 +1,420 @@ +// A shell in the box, for whoever is operating it. +// +// The host asks for one on the control channel and this end starts the process +// on a pseudo-terminal, then dials the host on a port of its own and carries +// the terminal's bytes over it. See `nesprotocol::attach` for the frames and +// why they are not on the control channel. +// +// # It is not a launch +// +// A launch is the box's workload and its exit means something to the session. +// A shell has no such meaning: it comes and goes while a game plays, it never +// ends the box, and the box ending ends it. So it takes no part in the +// session's one-running-launch bookkeeping. +// +// # Reaping goes through the same place as everything else +// +// This is PID 1, and the reaper is the only thing allowed to wait on a child +// (see `reap`). The shell is started through `Waiters` like a workload is, and +// its exit arrives the same way, so a shell that dies does not become the one +// zombie the reaper was written to prevent. +// +// # Two modes, one mechanism +// +// With a terminal on the other end the PTY is an ordinary one. Without, it is +// put in raw mode so it is a byte pipe: no echo, and no rewriting of line +// endings, which a command run for its output would otherwise get "\r\n" for +// every "\n". Stderr shares the PTY with stdout, which a terminal does anyway. + +use std::collections::BTreeMap; +use std::io; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::os::unix::process::CommandExt; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use nesprotocol::attach::{ExitStatus, Frame, FrameReader}; +use nesprotocol::lifecycle::{AttachId, Exec, Exit, Winsize}; +use tokio::io::unix::AsyncFd; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::oneshot; + +use crate::reap::{Waiters, Watched}; +use crate::workload::Failure; + +/// What a shell finds on `PATH` when the host named none. +/// +/// The guest's, because only the guest knows its own filesystem. The environment +/// is cleared, as a launch's is, so without this a shell would have no `PATH` +/// at all and find nothing. +const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/bin"; + +/// How long to keep collecting a shell's last output after it has exited. +/// +/// Its output may still be queued in the terminal when its exit arrives, and +/// dropping it would lose the last line -- usually the one that says why. Bounded, +/// because something it started may still hold the terminal open and nothing +/// more is coming. +const DRAIN: Duration = Duration::from_millis(300); + +/// A shell that has been started and not yet connected to anything. +/// +/// Dropping one hangs it up, so a shell whose connection could not be made does +/// not sit in the box with nobody at the other end. +pub struct Shell { + master: Option, + watched: Watched, + exit: Option>, +} + +impl Shell { + pub fn pid(&self) -> i32 { + self.watched.pid + } +} + +impl Drop for Shell { + fn drop(&mut self) { + hang_up(&self.watched); + } +} + +/// Tell the shell its terminal is gone, if it is still ours to tell. +/// +/// The process group, because the shell's children are in it and a `sleep` +/// left holding the terminal is what a hung-up shell is for getting rid of. +/// Asked of the registry first: after the reaper has collected the shell the +/// number may belong to something else. +fn hang_up(watched: &Watched) { + if !watched.running() { + return; + } + // SAFETY: two integers, nothing borrowed. A group that has gone fails with + // ESRCH, which is what hanging up twice should do. + unsafe { + libc::kill(-watched.pid, libc::SIGHUP); + libc::kill(watched.pid, libc::SIGHUP); + } +} + +fn pty(size: Option) -> io::Result<(OwnedFd, OwnedFd)> { + let mut master: libc::c_int = -1; + let mut slave: libc::c_int = -1; + let window = size.map(|s| libc::winsize { + ws_row: s.rows, + ws_col: s.cols, + ws_xpixel: 0, + ws_ypixel: 0, + }); + // SAFETY: two out-pointers to integers and a pointer to a winsize that + // lives for the call; no name buffer and no termios are asked for. + let opened = unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + window.as_ref().map_or(std::ptr::null_mut(), |w| { + w as *const libc::winsize as *mut libc::winsize + }), + ) + }; + if opened != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: openpty returned two descriptors this process now owns. + let (master, slave) = unsafe { (OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) }; + + for fd in [&master, &slave] { + // SAFETY: fcntl on a descriptor this function owns. + if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } != 0 { + return Err(io::Error::last_os_error()); + } + } + // The master is read through the runtime, so it must not block. + // SAFETY: as above. + if unsafe { libc::fcntl(master.as_raw_fd(), libc::F_SETFL, libc::O_NONBLOCK) } != 0 { + return Err(io::Error::last_os_error()); + } + if size.is_none() { + raw_mode(&slave)?; + } + Ok((master, slave)) +} + +/// Make a terminal a byte pipe: no echo, no line editing, no rewriting. +fn raw_mode(slave: &OwnedFd) -> io::Result<()> { + // SAFETY: a zeroed termios is filled by tcgetattr before it is used, and + // every call is on a descriptor this function was handed. + unsafe { + let mut termios: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(slave.as_raw_fd(), &mut termios) != 0 { + return Err(io::Error::last_os_error()); + } + libc::cfmakeraw(&mut termios); + if libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &termios) != 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(()) +} + +/// The environment a shell starts with: what a launch gets, plus a `PATH`. +fn environment(exec: &Exec) -> BTreeMap { + // Made here for the same reason a launch's is: the environment is cleared, + // so nothing a shell inherits points at it, and a tool that wants a runtime + // directory reads `XDG_RUNTIME_DIR`. + let runtime = crate::system::runtime_dir(exec.uid, exec.gid).ok(); + let mut env: BTreeMap = crate::workload::environment(exec, runtime.as_deref()) + .into_iter() + .collect(); + env.entry("PATH".into()) + .or_insert_with(|| DEFAULT_PATH.into()); + env +} + +/// Start the process on a terminal. +/// +/// Synchronous on purpose: everything that can fail before there is anything to +/// connect to fails here, where it can be returned and sent up the control +/// channel. A host that heard nothing would wait out a timeout to learn the +/// program does not exist. +pub fn spawn(waiters: &Waiters, exec: &Exec, size: Option) -> Result { + let Some((program, args)) = exec.argv.split_first() else { + return Err(Failure::new("the command is empty")); + }; + let (master, slave) = pty(size).map_err(|e| Failure::new(format!("no terminal: {e}")))?; + let again = |slave: &OwnedFd| { + slave + .try_clone() + .map_err(|e| Failure::new(format!("no terminal: {e}"))) + }; + + let mut command = Command::new(program); + command + .args(args) + .env_clear() + .envs(environment(exec)) + .current_dir(exec.cwd.as_deref().unwrap_or("/")) + .stdin(Stdio::from(again(&slave)?)) + .stdout(Stdio::from(again(&slave)?)) + .stderr(Stdio::from(slave)); + + let (uid, gid) = (exec.uid, exec.gid); + // SAFETY: the closure runs between fork and exec, where only + // async-signal-safe calls are allowed. These are, and it allocates nothing. + unsafe { + command.pre_exec(move || { + // A session of its own, with the terminal as its controlling one: + // that is what makes ^C reach the foreground job instead of the + // shell's whole group, and what lets job control work at all. + if libc::setsid() < 0 { + return Err(io::Error::last_os_error()); + } + if libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 { + return Err(io::Error::last_os_error()); + } + // gid first: dropping the uid first would lose the privilege needed + // to set the gid at all. + if libc::setgid(gid) != 0 { + return Err(io::Error::last_os_error()); + } + if libc::setuid(uid) != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + + let mut watched = waiters + .watch(|| Ok(command.spawn()?.id() as i32)) + .map_err(|error| Failure::new(format!("{program} as {uid}:{gid}: {error}")))?; + let exit = watched.take_exit().expect("a new watch has its exit"); + Ok(Shell { + master: Some(master), + watched, + exit: Some(exit), + }) +} + +fn read_fd(fd: RawFd, buf: &mut [u8]) -> io::Result { + // SAFETY: a pointer and length into a buffer that outlives the call. + let n = unsafe { libc::read(fd, buf.as_mut_ptr().cast(), buf.len()) }; + if n < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(n as usize) + } +} + +fn write_fd(fd: RawFd, buf: &[u8]) -> io::Result { + // SAFETY: a pointer and length into a buffer that outlives the call. + let n = unsafe { libc::write(fd, buf.as_ptr().cast(), buf.len()) }; + if n < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(n as usize) + } +} + +fn resize(fd: RawFd, cols: u16, rows: u16) { + let window = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + // SAFETY: a pointer to a winsize that lives for the call. The kernel sends + // the foreground job SIGWINCH itself. + unsafe { libc::ioctl(fd, libc::TIOCSWINSZ as _, &window) }; +} + +/// Whether a read failure means the terminal has no process on it any more. +/// +/// A pseudo-terminal's master reports `EIO` once every holder of the slave has +/// gone, and that is its end of file. +fn terminal_gone(error: &io::Error) -> bool { + error.raw_os_error() == Some(libc::EIO) +} + +async fn send(out: &mut W, frame: &Frame) -> io::Result<()> { + let bytes = frame + .encode() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + out.write_all(&bytes).await +} + +async fn send_data(out: &mut W, bytes: &[u8]) -> io::Result<()> { + for frame in Frame::data(bytes) { + send(out, &frame).await?; + } + Ok(()) +} + +/// Write to the terminal, waiting for room. +async fn write_terminal(master: &AsyncFd, mut bytes: &[u8]) -> io::Result<()> { + while !bytes.is_empty() { + let mut ready = master.writable().await?; + match ready.try_io(|fd| write_fd(fd.as_raw_fd(), bytes)) { + Ok(Ok(n)) => bytes = &bytes[n..], + // The shell is gone; what was typed at it has nowhere to go. + Ok(Err(e)) if terminal_gone(&e) => return Ok(()), + Ok(Err(e)) => return Err(e), + Err(_would_block) => {} + } + } + Ok(()) +} + +/// How the process ended, as the stream says it. +fn status_of(exit: Option) -> ExitStatus { + match exit { + Some(Exit { + signal: Some(signal), + .. + }) => ExitStatus::Signal(signal), + Some(Exit { + exit_code: Some(code), + .. + }) => ExitStatus::Code(code), + // The reaper never delivered an exit, which it only fails to do when this + // end is being torn down. Said as a code no shell returns on its own. + _ => ExitStatus::Code(-1), + } +} + +/// Carry a shell's terminal over `stream` until one side is done. +/// +/// Generic over the stream so it can be driven by a real pseudo-terminal and an +/// in-memory pipe, which is the only way to test the part that matters. +pub async fn serve(mut shell: Shell, id: &AttachId, stream: S) -> io::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let master = AsyncFd::new( + shell + .master + .take() + .expect("a shell has its terminal until served"), + )?; + let mut exit = shell + .exit + .take() + .expect("a shell has its exit until served"); + let (mut from_host, mut to_host) = tokio::io::split(stream); + + send(&mut to_host, &Frame::Hello(id.to_string())).await?; + + let mut frames = FrameReader::new(); + let mut input = vec![0u8; 8192]; + let mut output = vec![0u8; 16384]; + let mut terminal_open = true; + + let ended = loop { + tokio::select! { + ready = master.readable(), if terminal_open => { + let mut ready = ready?; + match ready.try_io(|fd| read_fd(fd.as_raw_fd(), &mut output)) { + Ok(Ok(0)) => terminal_open = false, + Ok(Ok(n)) => send_data(&mut to_host, &output[..n]).await?, + Ok(Err(e)) if terminal_gone(&e) => terminal_open = false, + Ok(Err(e)) => return Err(e), + Err(_would_block) => {} + } + } + read = from_host.read(&mut input) => { + let n = read?; + if n == 0 { + // The command line went away. Dropping `shell` hangs it up. + return Ok(()); + } + frames.push(&input[..n]); + loop { + match frames.next_frame() { + Ok(Some(Frame::Data(bytes))) => { + if terminal_open { + write_terminal(&master, &bytes).await?; + } + } + Ok(Some(Frame::Resize { cols, rows })) => resize(master.as_raw_fd(), cols, rows), + Ok(Some(Frame::Hangup)) => return Ok(()), + // Hello and Exit are ours to send. A peer that sends them + // has lost track of which end it is. + Ok(Some(other)) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("the host sent a frame only the guest sends: {other:?}"), + )); + } + Ok(None) => break, + Err(e) => return Err(io::Error::new(io::ErrorKind::InvalidData, e)), + } + } + } + exit = &mut exit => break exit.ok(), + } + }; + + // Collect what it said last, then say how it ended. + if terminal_open { + let drained = tokio::time::timeout(DRAIN, async { + loop { + let mut ready = master.readable().await?; + match ready.try_io(|fd| read_fd(fd.as_raw_fd(), &mut output)) { + Ok(Ok(0)) => return Ok::<(), io::Error>(()), + Ok(Ok(n)) => send_data(&mut to_host, &output[..n]).await?, + Ok(Err(e)) if terminal_gone(&e) => return Ok(()), + Ok(Err(e)) => return Err(e), + Err(_would_block) => return Ok(()), + } + } + }) + .await; + if let Ok(Err(e)) = drained { + return Err(e); + } + } + send(&mut to_host, &Frame::Exit(status_of(ended))).await?; + to_host.flush().await?; + to_host.shutdown().await +} diff --git a/apps/nesinit/src/lib.rs b/apps/nesinit/src/lib.rs index a52936ef..f2b49730 100644 --- a/apps/nesinit/src/lib.rs +++ b/apps/nesinit/src/lib.rs @@ -12,6 +12,7 @@ // It is also the box's only init: there is no service manager in the image, so // the box's own services come up from a table in this binary. ref(d-0064) +pub mod attach; pub mod filesystems; pub mod payload; pub mod platform; diff --git a/apps/nesinit/src/session.rs b/apps/nesinit/src/session.rs index 2958350d..f1493546 100644 --- a/apps/nesinit/src/session.rs +++ b/apps/nesinit/src/session.rs @@ -353,6 +353,24 @@ where running = start(&mut writer, workload, untrusted, id, exec, on_exit).await?; } + HostToGuest::Attach { id, exec, size } => { + // A shell is not a launch: it does not touch `running`, takes no + // part in what an exit means, and a failure to start one is said + // about the shell and never about the session. The control + // channel is where it is said because nothing has connected to + // the data port yet. + if let Err(failure) = workload.attach(id.clone(), &exec, size) { + tracing::warn!(attach = %id, reason = %failure.reason, "a shell would not start"); + send( + &mut writer, + &GuestToHost::AttachFailed { + id, + reason: failure.reason, + }, + ) + .await?; + } + } HostToGuest::Stop { id } => match &running { // Idempotent, and never ends the session by itself. Some(current) if current.id == id => workload.signal_stop(), @@ -1182,6 +1200,93 @@ mod tests { assert!(workload.started.is_empty()); } + /// A shell is handed to the workload layer with everything it was asked for, + /// and it is not a launch: nothing starts, nothing exits, and the session + /// carries on exactly as it was. + #[tokio::test] + async fn an_attach_goes_to_the_workload_layer_and_is_not_a_launch() { + let (guest, host) = tokio::io::duplex(4096); + let mut caller = Caller::new(host); + let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0)))); + + caller.expect_ready().await; + caller + .say(&HostToGuest::Boot { + descriptor: Box::new(descriptor()), + }) + .await; + caller.expect_booted().await; + + let wanted = Exec { + argv: vec!["/bin/bash".into()], + env: [("TERM".to_string(), "xterm-256color".to_string())].into(), + cwd: None, + uid: 0, + gid: 0, + }; + let size = Some(nesprotocol::lifecycle::Winsize { + cols: 132, + rows: 43, + }); + caller + .say(&HostToGuest::Attach { + id: nesprotocol::lifecycle::AttachId::new("a-1"), + exec: wanted.clone(), + size, + }) + .await; + caller.say(&HostToGuest::Shutdown).await; + + let (outcome, workload, _) = session.await.unwrap(); + assert_eq!(outcome, Outcome::Shutdown); + assert_eq!( + workload.attached, + vec![(nesprotocol::lifecycle::AttachId::new("a-1"), wanted, size)] + ); + assert!( + workload.started.is_empty(), + "a shell was started as though it were the workload" + ); + } + + /// Said about the shell, by its id, on the channel the host is already + /// reading -- nothing has connected to the data port yet, and a host that + /// heard nothing would wait out a timeout to find out. + #[tokio::test] + async fn a_shell_that_will_not_start_is_reported_by_id_and_the_box_carries_on() { + let (guest, host) = tokio::io::duplex(4096); + let mut caller = Caller::new(host); + let mut workload = Double::exits_when_stopped(Exit::code(0)); + workload.attach_failure = Some(Failure::new("/bin/nope as 0:0: No such file or directory")); + let session = spawn(guest, Given::new(workload)); + + caller.expect_ready().await; + caller + .say(&HostToGuest::Boot { + descriptor: Box::new(descriptor()), + }) + .await; + caller.expect_booted().await; + caller + .say(&HostToGuest::Attach { + id: nesprotocol::lifecycle::AttachId::new("a-2"), + exec: exec(), + size: None, + }) + .await; + + assert_eq!( + caller.expect().await, + GuestToHost::AttachFailed { + id: nesprotocol::lifecycle::AttachId::new("a-2"), + reason: "/bin/nope as 0:0: No such file or directory".into() + } + ); + caller.say(&HostToGuest::Shutdown).await; + let (outcome, _, _) = session.await.unwrap(); + assert_eq!(outcome, Outcome::Shutdown, "a failed shell ended the box"); + } + /// The compatibility layer is the base the rest of a box is built on, so it /// is mounted before anything that could sit inside it, and a box whose /// image will not mount is refused rather than carried on with. diff --git a/apps/nesinit/src/workload.rs b/apps/nesinit/src/workload.rs index 08be10ca..47ac774f 100644 --- a/apps/nesinit/src/workload.rs +++ b/apps/nesinit/src/workload.rs @@ -11,7 +11,7 @@ use std::future::Future; use std::io; use std::pin::Pin; -use nesprotocol::lifecycle::{Exec, Exit, Image, Mount, Overlay}; +use nesprotocol::lifecycle::{AttachId, Exec, Exit, Image, Mount, Overlay, Winsize}; use std::os::unix::process::CommandExt; @@ -51,6 +51,15 @@ pub trait Workload { /// layer, and the two together where it says. fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure>; + /// Start a process on a terminal and connect it to the host on a data port + /// of its own. + /// + /// Returns once the process is started or has failed to be, and no later: + /// everything that can fail before there is anything to connect to fails + /// here, where it can be sent up the control channel. The connection itself + /// is made in the background. + fn attach(&mut self, id: AttachId, exec: &Exec, size: Option) -> Result<(), Failure>; + /// Start the command the descriptor names. /// /// Returning the exit as a future, rather than a `wait` method, is what @@ -146,6 +155,32 @@ impl Workload for Process { Ok(()) } + fn attach(&mut self, id: AttachId, exec: &Exec, size: Option) -> Result<(), Failure> { + use nesprotocol::lifecycle::ATTACH_PORT; + use tokio_vsock::{VMADDR_CID_HOST, VsockAddr, VsockStream}; + + let shell = crate::attach::spawn(&self.waiters, exec, size)?; + tracing::info!(attach = %id, pid = shell.pid(), uid = exec.uid, "a shell was started"); + tokio::spawn(async move { + // Dialled once. The host is listening before it asks, so a refused + // connection means it stopped wanting the shell, and `shell` + // dropping at the end of this task is what hangs it up. + let address = VsockAddr::new(VMADDR_CID_HOST, ATTACH_PORT); + let stream = match VsockStream::connect(address).await { + Ok(stream) => stream, + Err(error) => { + tracing::warn!(attach = %id, %error, "could not reach the host for a shell"); + return; + } + }; + match crate::attach::serve(shell, &id, stream).await { + Ok(()) => tracing::info!(attach = %id, "a shell ended"), + Err(error) => tracing::warn!(attach = %id, %error, "a shell's connection failed"), + } + }); + Ok(()) + } + fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> { for overlay in overlays { mount_overlay(overlay)?; @@ -263,7 +298,7 @@ impl Workload for Process { /// finds the variable unset does not fail loudly -- the compositor here falls /// back to `/tmp` -- so the sockets land somewhere world-writable and shared /// with every other user, and everything reports success. ref(d-0065) -fn environment(exec: &Exec, runtime: Option<&str>) -> Vec<(String, String)> { +pub(crate) fn environment(exec: &Exec, runtime: Option<&str>) -> Vec<(String, String)> { GRAPHICS .iter() .map(|(k, v)| (k.to_string(), v.to_string())) @@ -950,6 +985,8 @@ pub mod double { pub images: Vec>, /// Which kind of mount was asked for, in the order it was asked. pub order: Vec<&'static str>, + pub attached: Vec<(AttachId, Exec, Option)>, + pub attach_failure: Option, pub started: Vec, pub stops: usize, pub mount_failure: Option, @@ -977,6 +1014,8 @@ pub mod double { overlays: Vec::new(), images: Vec::new(), order: Vec::new(), + attached: Vec::new(), + attach_failure: None, started: Vec::new(), stops: 0, mount_failure: None, @@ -999,6 +1038,19 @@ pub mod double { } } + fn attach( + &mut self, + id: AttachId, + exec: &Exec, + size: Option, + ) -> Result<(), Failure> { + self.attached.push((id, exec.clone(), size)); + match &self.attach_failure { + Some(failure) => Err(failure.clone()), + None => Ok(()), + } + } + fn mount_images(&mut self, images: &[Image]) -> Result<(), Failure> { self.order.push("images"); self.images.push(images.to_vec()); diff --git a/apps/nesinit/tests/attach.rs b/apps/nesinit/tests/attach.rs new file mode 100644 index 00000000..ef8f8d10 --- /dev/null +++ b/apps/nesinit/tests/attach.rs @@ -0,0 +1,308 @@ +// A shell on a real pseudo-terminal, over an in-memory stream. +// +// Its own test binary on purpose, for the reason `reaping.rs` is: the reaper waits +// on any child, so in a binary shared with other tests it would collect +// processes they were waiting for. + +use std::time::{Duration, Instant}; + +use nesinit::attach::{serve, spawn}; +use nesinit::reap::{Waiters, reap_exited}; +use nesprotocol::attach::{ExitStatus, Frame, FrameReader}; +use nesprotocol::lifecycle::{AttachId, Exec, Winsize}; +use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; + +/// Reaping is process-wide, so these run one at a time. +static ONE_REAPER: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// A reaper like the guest's: the only thing that waits, handing exits to +/// whoever asked. +fn start_reaper(waiters: Waiters) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + loop { + for (pid, exit) in reap_exited() { + waiters.deliver(pid, exit); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) +} + +fn me() -> (u32, u32) { + // SAFETY: two calls with no arguments. + unsafe { (libc::getuid(), libc::getgid()) } +} + +fn exec(argv: &[&str]) -> Exec { + let (uid, gid) = me(); + Exec { + argv: argv.iter().map(|s| s.to_string()).collect(), + env: [("TERM".to_string(), "dumb".to_string())].into(), + cwd: None, + uid, + gid, + } +} + +/// The host's end of the stream. +struct Host { + stream: DuplexStream, + frames: FrameReader, +} + +impl Host { + async fn say(&mut self, frame: Frame) { + self.stream + .write_all(&frame.encode().unwrap()) + .await + .unwrap(); + } + + async fn next(&mut self) -> Option { + let mut buf = [0u8; 4096]; + loop { + if let Some(frame) = self.frames.next_frame().unwrap() { + return Some(frame); + } + let n = tokio::time::timeout(Duration::from_secs(10), self.stream.read(&mut buf)) + .await + .expect("the guest went quiet") + .unwrap(); + if n == 0 { + return None; + } + self.frames.push(&buf[..n]); + } + } + + /// Everything until the exit, as (output, status). + async fn until_exit(&mut self) -> (Vec, ExitStatus) { + let mut out = Vec::new(); + loop { + match self.next().await.expect("the stream ended before an exit") { + Frame::Data(bytes) => out.extend(bytes), + Frame::Exit(status) => return (out, status), + other => panic!("unexpected frame from the guest: {other:?}"), + } + } + } +} + +/// Start a shell and serve it; return the host's end and the pid. +async fn attach( + waiters: &Waiters, + argv: &[&str], + size: Option, +) -> (Host, i32, tokio::task::JoinHandle>) { + let shell = spawn(waiters, &exec(argv), size).expect("the shell starts"); + let pid = shell.pid(); + let (guest_end, host_end) = tokio::io::duplex(1 << 16); + let id = AttachId::new("a-test"); + let served = tokio::spawn(async move { serve(shell, &id, guest_end).await }); + let mut host = Host { + stream: host_end, + frames: FrameReader::new(), + }; + assert_eq!( + host.next().await, + Some(Frame::Hello("a-test".into())), + "the first frame must say which shell this is" + ); + (host, pid, served) +} + +fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +#[tokio::test] +async fn a_person_at_a_terminal_types_and_sees_the_answer() { + let _alone = ONE_REAPER.lock().await; + let waiters = Waiters::new(); + let reaper = start_reaper(waiters.clone()); + + let (mut host, _, served) = + attach(&waiters, &["/bin/sh"], Some(Winsize { cols: 80, rows: 24 })).await; + host.say(Frame::Data(b"echo hello-from-the-box\nexit 3\n".to_vec())) + .await; + let (out, status) = host.until_exit().await; + + assert!(text(&out).contains("hello-from-the-box"), "{}", text(&out)); + assert_eq!(status, ExitStatus::Code(3)); + served.await.unwrap().unwrap(); + reaper.abort(); +} + +/// A terminal that was resized tells the program on it, which is what makes a +/// full-screen tool fit the window it is in. +#[tokio::test] +async fn a_resize_reaches_the_program_on_the_terminal() { + let _alone = ONE_REAPER.lock().await; + let waiters = Waiters::new(); + let reaper = start_reaper(waiters.clone()); + + let (mut host, _, served) = + attach(&waiters, &["/bin/sh"], Some(Winsize { cols: 80, rows: 24 })).await; + host.say(Frame::Resize { + cols: 100, + rows: 30, + }) + .await; + host.say(Frame::Data(b"stty size\nexit 0\n".to_vec())).await; + let (out, status) = host.until_exit().await; + + assert!(text(&out).contains("30 100"), "{}", text(&out)); + assert_eq!(status, ExitStatus::Code(0)); + served.await.unwrap().unwrap(); + reaper.abort(); +} + +/// A command run for its output is a byte pipe: nothing rewrites its line +/// endings, and what it wrote to stderr is there too. +#[tokio::test] +async fn a_command_without_a_terminal_gets_its_bytes_back_unrewritten() { + let _alone = ONE_REAPER.lock().await; + let waiters = Waiters::new(); + let reaper = start_reaper(waiters.clone()); + + let (mut host, _, served) = attach( + &waiters, + &["/bin/sh", "-c", "echo out; echo err >&2; exit 5"], + None, + ) + .await; + let (out, status) = host.until_exit().await; + + let out = text(&out); + assert!(out.contains("out\n") && out.contains("err\n"), "{out:?}"); + assert!( + !out.contains('\r'), + "a terminal rewrote the line endings of a command's output: {out:?}" + ); + assert_eq!(status, ExitStatus::Code(5)); + served.await.unwrap().unwrap(); + reaper.abort(); +} + +/// A kill is not an exit code. +#[tokio::test] +async fn a_shell_that_is_killed_says_which_signal() { + let _alone = ONE_REAPER.lock().await; + let waiters = Waiters::new(); + let reaper = start_reaper(waiters.clone()); + + let (mut host, _, served) = attach(&waiters, &["/bin/sh", "-c", "kill -9 $$"], None).await; + let (_, status) = host.until_exit().await; + + assert_eq!(status, ExitStatus::Signal(9)); + served.await.unwrap().unwrap(); + reaper.abort(); +} + +/// The last thing a program printed is what says why it stopped, so it must +/// not be lost to the exit arriving first. +#[tokio::test] +async fn output_written_just_before_the_exit_is_not_lost() { + let _alone = ONE_REAPER.lock().await; + let waiters = Waiters::new(); + let reaper = start_reaper(waiters.clone()); + + let (mut host, _, served) = attach( + &waiters, + &["/bin/sh", "-c", "printf 'the-last-line\\n'; exit 1"], + None, + ) + .await; + let (out, status) = host.until_exit().await; + + assert!(text(&out).contains("the-last-line"), "{}", text(&out)); + assert_eq!(status, ExitStatus::Code(1)); + served.await.unwrap().unwrap(); + reaper.abort(); +} + +/// The command line going away hangs the shell up, and what it started with it. +#[tokio::test] +async fn closing_the_stream_hangs_up_the_shell() { + let _alone = ONE_REAPER.lock().await; + let waiters = Waiters::new(); + let reaper = start_reaper(waiters.clone()); + + let (host, pid, served) = attach(&waiters, &["/bin/sh", "-c", "sleep 60"], None).await; + drop(host); + served.await.unwrap().unwrap(); + + // Gone, and collected: signal zero asks whether the pid still exists. + let deadline = Instant::now() + Duration::from_secs(5); + loop { + // SAFETY: an integer pid and the null signal. + let alive = unsafe { libc::kill(pid, 0) } == 0; + if !alive { + break; + } + assert!( + Instant::now() < deadline, + "the shell outlived the connection that was its reason to exist" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + reaper.abort(); +} + +/// An explicit hangup does the same. +#[tokio::test] +async fn a_hangup_frame_ends_the_shell() { + let _alone = ONE_REAPER.lock().await; + let waiters = Waiters::new(); + let reaper = start_reaper(waiters.clone()); + + let (mut host, pid, served) = attach(&waiters, &["/bin/sh", "-c", "sleep 60"], None).await; + host.say(Frame::Hangup).await; + served.await.unwrap().unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + while unsafe { libc::kill(pid, 0) } == 0 { + assert!(Instant::now() < deadline, "the shell outlived a hangup"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + reaper.abort(); +} + +/// Everything that can be refused before there is a connection is refused where +/// it can be sent up the control channel. +#[tokio::test] +async fn a_program_that_does_not_exist_is_refused_before_anything_connects() { + let _alone = ONE_REAPER.lock().await; + let waiters = Waiters::new(); + let reaper = start_reaper(waiters.clone()); + + let refused = spawn(&waiters, &exec(&["/nonexistent/program"]), None) + .err() + .expect("a missing program started"); + assert!( + refused.reason.contains("/nonexistent/program"), + "{}", + refused.reason + ); + assert!(spawn(&waiters, &exec(&[]), None).is_err()); + reaper.abort(); +} + +/// A peer that speaks the wrong half of the protocol is not obeyed. +#[tokio::test] +async fn a_frame_only_the_guest_sends_is_refused_and_the_shell_hung_up() { + let _alone = ONE_REAPER.lock().await; + let waiters = Waiters::new(); + let reaper = start_reaper(waiters.clone()); + + let (mut host, pid, served) = attach(&waiters, &["/bin/sh", "-c", "sleep 60"], None).await; + host.say(Frame::Exit(ExitStatus::Code(0))).await; + assert!(served.await.unwrap().is_err()); + + let deadline = Instant::now() + Duration::from_secs(5); + while unsafe { libc::kill(pid, 0) } == 0 { + assert!(Instant::now() < deadline, "the shell outlived a bad peer"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + reaper.abort(); +} diff --git a/build/Containerfile b/build/Containerfile index 45537e2e..f87397f4 100644 --- a/build/Containerfile +++ b/build/Containerfile @@ -322,10 +322,16 @@ RUN pacman -Syu --noconfirm --needed \ pipewire pipewire-audio wireplumber opus \ python libunwind sdl2-compat \ python-xlib python-urllib3 python-pyzstd \ + strace lsof less \ && rm -f /usr/share/libalpm/hooks/dbus-reload.hook \ && pacman -Rdd --noconfirm systemd systemd-sysvcompat openssh \ && pacman -Scc --noconfirm +# `strace`, `lsof` and `less` are for the operator's shell (`neslet box exec`): +# a root shell in a box that cannot trace a process, list what it has open or +# page through a log is the situation that feature exists to get out of. They +# are a few hundred kilobytes between them. +# # `libunwind` is Wine's, not ours. `ntdll.so` links it, so without it every # process Wine starts dies at `could not load ntdll.so`, which is the first # thing it loads and reads like Wine itself being broken. Found 2026-09-12, diff --git a/crates/nesprotocol/src/attach.rs b/crates/nesprotocol/src/attach.rs new file mode 100644 index 00000000..199d7a06 --- /dev/null +++ b/crates/nesprotocol/src/attach.rs @@ -0,0 +1,327 @@ +//! The byte stream of an attached shell. +//! +//! A shell in a box is a terminal's worth of bytes in both directions, plus the +//! few things a terminal says out of band: it has been resized, the other end +//! has gone, the process has ended. Those ride a connection of their own (see +//! [`crate::lifecycle::ATTACH_PORT`]) rather than the JSON-lines control +//! channel, because a screenful of output on a channel shared with everything +//! else would hold every other message in the box behind it. +//! +//! # A codec and nothing else +//! +//! No I/O here: [`Frame::encode`] makes bytes and [`FrameReader`] is fed bytes +//! and hands back frames. Three programs speak this -- the guest, the agent and +//! its command line -- each with its own idea of how to read a socket, and none +//! of them should have to agree with the others about anything but this. +//! +//! # Hostile enough to be careful +//! +//! Both ends are ours, but one of them is a guest, and a length prefix read from +//! a guest is a length prefix read from the other side of a trust boundary. A +//! frame longer than [`MAX_PAYLOAD`] is an error and not an allocation, and an +//! unknown tag is an error and not a skip: skipping needs a length this end has +//! no reason to believe. + +/// The longest payload a frame may carry. +pub const MAX_PAYLOAD: usize = 1 << 20; + +const HEADER: usize = 5; + +const TAG_HELLO: u8 = 1; +const TAG_DATA: u8 = 2; +const TAG_RESIZE: u8 = 3; +const TAG_HANGUP: u8 = 4; +const TAG_EXIT: u8 = 5; + +/// How the process ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitStatus { + Code(i32), + /// Killed by this signal. Kept apart from a code: a signalled process has + /// no exit code, and `0` for one would make a kill look like a clean run. + Signal(i32), +} + +/// One message on the stream. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Frame { + /// First frame from the guest: which attach this connection is for. + Hello(String), + /// Terminal bytes, in either direction. + Data(Vec), + /// The terminal changed size. Host to guest. + Resize { cols: u16, rows: u16 }, + /// The host is done with the shell. Host to guest. + Hangup, + /// The process ended. Last frame, guest to host. + Exit(ExitStatus), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameError { + UnknownTag(u8), + TooLarge(usize), + Malformed(&'static str), +} + +impl std::fmt::Display for FrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrameError::UnknownTag(tag) => write!(f, "an unknown frame tag, {tag}"), + FrameError::TooLarge(len) => { + write!(f, "a frame of {len} bytes, past the {MAX_PAYLOAD} allowed") + } + FrameError::Malformed(what) => write!(f, "a malformed frame: {what}"), + } + } +} + +impl std::error::Error for FrameError {} + +impl Frame { + /// This frame as bytes. Refuses a payload it could not be read back from. + pub fn encode(&self) -> Result, FrameError> { + let (tag, payload): (u8, Vec) = match self { + Frame::Hello(id) => (TAG_HELLO, id.as_bytes().to_vec()), + Frame::Data(bytes) => (TAG_DATA, bytes.clone()), + Frame::Resize { cols, rows } => { + let mut p = Vec::with_capacity(4); + p.extend_from_slice(&cols.to_le_bytes()); + p.extend_from_slice(&rows.to_le_bytes()); + (TAG_RESIZE, p) + } + Frame::Hangup => (TAG_HANGUP, Vec::new()), + Frame::Exit(status) => { + let (kind, value) = match status { + ExitStatus::Code(code) => (0u8, *code), + ExitStatus::Signal(signal) => (1u8, *signal), + }; + let mut p = vec![kind]; + p.extend_from_slice(&value.to_le_bytes()); + (TAG_EXIT, p) + } + }; + if payload.len() > MAX_PAYLOAD { + return Err(FrameError::TooLarge(payload.len())); + } + let mut out = Vec::with_capacity(HEADER + payload.len()); + out.push(tag); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(&payload); + Ok(out) + } + + /// Terminal bytes as frames no larger than a frame may be. + pub fn data(bytes: &[u8]) -> impl Iterator + '_ { + bytes + .chunks(MAX_PAYLOAD) + .map(|chunk| Frame::Data(chunk.to_vec())) + } +} + +/// Frames out of bytes that arrive in whatever pieces the socket gives. +#[derive(Debug, Default)] +pub struct FrameReader { + buf: Vec, +} + +impl FrameReader { + pub fn new() -> Self { + Self::default() + } + + /// Bytes that have arrived. + pub fn push(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + + /// The next whole frame, if one has arrived. + /// + /// `Ok(None)` is "not yet". After an error the reader is poisoned in the + /// sense that matters: the stream has lost its framing, nothing after this + /// point can be trusted, and the caller closes the connection. + pub fn next_frame(&mut self) -> Result, FrameError> { + if self.buf.len() < HEADER { + return Ok(None); + } + let tag = self.buf[0]; + let len = u32::from_le_bytes([self.buf[1], self.buf[2], self.buf[3], self.buf[4]]) as usize; + if !(TAG_HELLO..=TAG_EXIT).contains(&tag) { + return Err(FrameError::UnknownTag(tag)); + } + if len > MAX_PAYLOAD { + return Err(FrameError::TooLarge(len)); + } + if self.buf.len() < HEADER + len { + return Ok(None); + } + let payload: Vec = self.buf[HEADER..HEADER + len].to_vec(); + self.buf.drain(..HEADER + len); + + let frame = match tag { + TAG_HELLO => Frame::Hello( + String::from_utf8(payload).map_err(|_| FrameError::Malformed("a non-UTF-8 id"))?, + ), + TAG_DATA => Frame::Data(payload), + TAG_RESIZE => { + let [c0, c1, r0, r1] = payload[..] else { + return Err(FrameError::Malformed("a resize that is not four bytes")); + }; + Frame::Resize { + cols: u16::from_le_bytes([c0, c1]), + rows: u16::from_le_bytes([r0, r1]), + } + } + TAG_HANGUP => { + if !payload.is_empty() { + return Err(FrameError::Malformed("a hangup with a payload")); + } + Frame::Hangup + } + TAG_EXIT => { + let [kind, a, b, c, d] = payload[..] else { + return Err(FrameError::Malformed("an exit that is not five bytes")); + }; + let value = i32::from_le_bytes([a, b, c, d]); + match kind { + 0 => Frame::Exit(ExitStatus::Code(value)), + 1 => Frame::Exit(ExitStatus::Signal(value)), + _ => return Err(FrameError::Malformed("an exit of an unknown kind")), + } + } + _ => unreachable!("the tag range was checked above"), + }; + Ok(Some(frame)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn round_trip(frame: Frame) { + let bytes = frame.encode().unwrap(); + let mut reader = FrameReader::new(); + reader.push(&bytes); + assert_eq!(reader.next_frame().unwrap(), Some(frame)); + assert_eq!(reader.next_frame().unwrap(), None); + } + + #[test] + fn every_frame_survives_the_round_trip() { + round_trip(Frame::Hello("a-1".into())); + round_trip(Frame::Data(b"ls -l\r".to_vec())); + round_trip(Frame::Data(Vec::new())); + round_trip(Frame::Resize { + cols: 220, + rows: 61, + }); + round_trip(Frame::Hangup); + round_trip(Frame::Exit(ExitStatus::Code(0))); + round_trip(Frame::Exit(ExitStatus::Code(127))); + round_trip(Frame::Exit(ExitStatus::Signal(9))); + } + + /// A signal is not a code, and the two must not read back as each other. + #[test] + fn a_kill_is_not_an_exit_code() { + let mut reader = FrameReader::new(); + reader.push(&Frame::Exit(ExitStatus::Signal(9)).encode().unwrap()); + assert_ne!( + reader.next_frame().unwrap(), + Some(Frame::Exit(ExitStatus::Code(9))) + ); + } + + /// A socket hands over bytes in whatever pieces it likes. + #[test] + fn frames_are_reassembled_from_any_split() { + let frames = vec![ + Frame::Hello("x".into()), + Frame::Data(vec![7; 3000]), + Frame::Resize { cols: 80, rows: 24 }, + Frame::Exit(ExitStatus::Code(3)), + ]; + let wire: Vec = frames.iter().flat_map(|f| f.encode().unwrap()).collect(); + for step in [1, 2, 5, 7, 4096] { + let mut reader = FrameReader::new(); + let mut got = Vec::new(); + for piece in wire.chunks(step) { + reader.push(piece); + while let Some(frame) = reader.next_frame().unwrap() { + got.push(frame); + } + } + assert_eq!(got, frames, "split into {step}-byte pieces"); + } + } + + #[test] + fn two_frames_in_one_read_come_out_as_two() { + let mut reader = FrameReader::new(); + let mut wire = Frame::Data(b"a".to_vec()).encode().unwrap(); + wire.extend(Frame::Hangup.encode().unwrap()); + reader.push(&wire); + assert_eq!( + reader.next_frame().unwrap(), + Some(Frame::Data(b"a".to_vec())) + ); + assert_eq!(reader.next_frame().unwrap(), Some(Frame::Hangup)); + assert_eq!(reader.next_frame().unwrap(), None); + } + + /// The length is the other side's to claim, and is not believed past the cap. + #[test] + fn a_length_past_the_cap_is_refused_before_anything_is_waited_for() { + let mut reader = FrameReader::new(); + let mut header = vec![TAG_DATA]; + header.extend_from_slice(&((MAX_PAYLOAD as u32) + 1).to_le_bytes()); + reader.push(&header); + assert_eq!( + reader.next_frame(), + Err(FrameError::TooLarge(MAX_PAYLOAD + 1)) + ); + assert!(Frame::Data(vec![0; MAX_PAYLOAD + 1]).encode().is_err()); + assert!(Frame::Data(vec![0; MAX_PAYLOAD]).encode().is_ok()); + } + + #[test] + fn an_unknown_tag_is_an_error_and_not_a_skip() { + for tag in [0u8, 6, 255] { + let mut reader = FrameReader::new(); + reader.push(&[tag, 0, 0, 0, 0]); + assert_eq!(reader.next_frame(), Err(FrameError::UnknownTag(tag))); + } + } + + #[test] + fn a_frame_whose_payload_is_the_wrong_shape_is_refused() { + for (tag, payload) in [ + (TAG_RESIZE, vec![1, 2, 3]), + (TAG_HANGUP, vec![1]), + (TAG_EXIT, vec![0, 1, 2]), + (TAG_EXIT, vec![9, 0, 0, 0, 0]), + (TAG_HELLO, vec![0xff, 0xfe]), + ] { + let mut wire = vec![tag]; + wire.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + wire.extend_from_slice(&payload); + let mut reader = FrameReader::new(); + reader.push(&wire); + assert!( + matches!(reader.next_frame(), Err(FrameError::Malformed(_))), + "tag {tag} with {payload:?}" + ); + } + } + + #[test] + fn large_output_is_split_into_frames_a_frame_may_be() { + let bytes = vec![1u8; MAX_PAYLOAD * 2 + 10]; + let frames: Vec = Frame::data(&bytes).collect(); + assert_eq!(frames.len(), 3); + for frame in &frames { + assert!(frame.encode().is_ok()); + } + } +} diff --git a/crates/nesprotocol/src/lib.rs b/crates/nesprotocol/src/lib.rs index a0176bf1..44241656 100644 --- a/crates/nesprotocol/src/lib.rs +++ b/crates/nesprotocol/src/lib.rs @@ -2,6 +2,7 @@ // media: frames, audio, cursor, input and stats. One definition, so no two ends // can drift from each other silently. +pub mod attach; pub mod datagram; pub mod delay; pub mod gamepad; diff --git a/crates/nesprotocol/src/lifecycle.rs b/crates/nesprotocol/src/lifecycle.rs index bc974ec1..a6cefe39 100644 --- a/crates/nesprotocol/src/lifecycle.rs +++ b/crates/nesprotocol/src/lifecycle.rs @@ -47,6 +47,15 @@ use serde::{Deserialize, Serialize}; /// slow boot from a dead one. pub const CONTROL_PORT: u32 = 7000; +/// The vsock port an attached shell's bytes travel on. +/// +/// The host sends [`HostToGuest::Attach`] on the control channel and the guest +/// answers by dialling this, the same direction the control channel itself goes, +/// so nothing in the box has to listen. The stream is the frames in +/// [`crate::attach`]. It is a port of its own because a screenful of output on +/// the control channel would hold every other message in the box behind it. +pub const ATTACH_PORT: u32 = 7001; + /// Version of this layer. Both ends compare it during the handshake and refuse /// on mismatch, so a guest built against one version meeting a caller built /// against another fails immediately and legibly, rather than later on a field @@ -183,6 +192,39 @@ impl std::fmt::Display for LaunchId { } } +/// Names one attached shell, for as long as the connection that carries it. +/// +/// Minted by the host and echoed by the guest in the first frame of the stream, +/// which is how the host knows which waiting command line a connection belongs +/// to. Not a [`LaunchId`]: a shell is not a launch and has no exit that means +/// anything to the session. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AttachId(pub String); + +impl AttachId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for AttachId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// A terminal's size, in character cells. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Winsize { + pub cols: u16, + pub rows: u16, +} + /// What the workload exiting means for the session. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct OnExit { @@ -338,6 +380,11 @@ pub enum GuestToHost { #[serde(flatten)] exit: Exit, }, + /// The shell with this id could not be started, in the words the operating + /// system used. Said on the control channel because nothing has connected to + /// the data port yet, and a host that heard nothing would wait out a timeout + /// to find out. + AttachFailed { id: AttachId, reason: String }, /// The launch with this id is running. Started { id: LaunchId }, /// The launch with this id could not be run, in the words the operating @@ -388,6 +435,21 @@ pub enum HostToGuest { /// exists as one message only because a caller sending two has the same /// effect with a worse race in it. The relaunch keeps the id. ref(d-0064) Restart { id: LaunchId }, + /// Run something on a terminal and connect it to the host on + /// [`ATTACH_PORT`]. + /// + /// **Not a launch.** It has no `on_exit`, because nothing it does ends the + /// session, and it is not the running launch, so a shell can come and go + /// while a game plays. `size` present means a person is at a terminal and the + /// PTY is an ordinary one; absent means a command is being run for its + /// output, and the PTY is put in raw mode so it is a byte pipe and not a + /// terminal that rewrites line endings. + Attach { + id: AttachId, + exec: Exec, + #[serde(default)] + size: Option, + }, /// Shut the guest down. Shutdown, /// Bytes for the workload, relayed. See [`Payload`]. @@ -807,3 +869,59 @@ mod image_tests { assert_eq!(back, d); } } + +#[cfg(test)] +mod attach_tests { + use super::*; + + #[test] + fn an_attach_round_trips_with_and_without_a_terminal() { + for size in [ + Some(Winsize { + cols: 120, + rows: 40, + }), + None, + ] { + let attach = HostToGuest::Attach { + id: AttachId::new("a-1"), + exec: Exec { + argv: vec!["/bin/bash".into()], + env: Default::default(), + cwd: None, + uid: 0, + gid: 0, + }, + size, + }; + let back: HostToGuest = from_line(&to_line(&attach).unwrap()).unwrap(); + assert_eq!(back, attach); + } + } + + /// A host that leaves `size` out is asking for a command, not a terminal. + #[test] + fn an_attach_with_no_size_is_not_a_terminal() { + let line = r#"{"type":"attach","id":"a-1","exec":{"argv":["/bin/true"],"uid":0,"gid":0}}"#; + let HostToGuest::Attach { size, .. } = from_line(line).unwrap() else { + panic!("not an attach") + }; + assert_eq!(size, None); + } + + #[test] + fn a_failed_attach_says_which() { + let failed = GuestToHost::AttachFailed { + id: AttachId::new("a-1"), + reason: "no such file".into(), + }; + let back: GuestToHost = from_line(&to_line(&failed).unwrap()).unwrap(); + assert_eq!(back, failed); + } + + /// The attach port is not the control port. + #[test] + fn the_data_port_is_its_own() { + assert_ne!(ATTACH_PORT, CONTROL_PORT); + } +}