Skip to main content

ferroday_cage/relay/
mod.rs

1//! Driving an interactive terminal session from the caller's own terminal.
2//!
3//! A terminal launch gives the sandbox a pseudoterminal of its own. What is left
4//! is the caller side: moving bytes between the caller's terminal and the
5//! sandbox's, putting the caller's into raw mode so keystrokes pass through
6//! unbuffered, and restoring it afterwards on every exit the process can
7//! observe. [`Relay`] is that loop, and `fcage --terminal` is nothing but a
8//! configuration of it.
9//!
10//! ```no_run
11//! use std::os::fd::AsFd as _;
12//!
13//! use ferroday_cage::relay::{Relay, Signals, install_panic_restore};
14//! use ferroday_cage::{Cage, Terminal};
15//!
16//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
17//! // First, before the sandbox and before anything that might start a thread.
18//! let signals = Signals::install()?;
19//! // Restores the terminal before a backtrace prints, which a guard cannot.
20//! install_panic_restore();
21//!
22//! let cage = Cage::builder()
23//!     .rootfs("/srv/rootfs/alpine")
24//!     .command("/bin/sh")
25//!     .env("TERM", "xterm-256color")
26//!     .build()?;
27//! let (mut running, pty) = cage.spawn_terminal(&Terminal::new().size(24, 80))?;
28//!
29//! let stdin = std::io::stdin();
30//! let stdout = std::io::stdout();
31//! let outcome = Relay::new(stdin.as_fd(), stdout.as_fd())
32//!     .raw(true)
33//!     .run(&mut running, &pty, &signals)?;
34//! # let _ = outcome;
35//! # Ok(())
36//! # }
37//! ```
38//!
39//! # What stays the caller's
40//!
41//! The relay does nothing process-global as a side effect, and reads nothing
42//! from the environment. Four decisions are therefore the caller's to make, and
43//! a program driving a session makes all four:
44//!
45//! - **Install [`Signals`]** — early, on the main thread. Blocking the relayed
46//!   set is process-wide, so it is an explicit call rather than something
47//!   [`Relay::run`] does on the way past.
48//! - **Decide raw mode.** [`Relay::raw`] is off unless asked; whether the
49//!   caller's standard input is a terminal at all is the caller's reading, not
50//!   the relay's.
51//! - **Decide the panic hook.** [`install_panic_restore`] is one call, or a
52//!   program with a hook of its own calls [`restore_terminal`] from it.
53//! - **Decide the sandbox's terminal.** [`Terminal`](crate::Terminal) is a
54//!   stated size; a caller that wants its own window's size reads it and passes
55//!   it, because the library reads nothing of the host.
56//!
57//! # Signals are data
58//!
59//! The relay reads the signals it handles as one channel of its poll loop, from
60//! the descriptor [`Signals::install`] opened. No library code runs in signal
61//! context: there are no handlers, no async-signal-safety reasoning, and no
62//! shared flags — a signal is a poll event handled by ordinary code.
63//!
64//! - **`SIGWINCH`** — read the size the *supplied* input descriptor reports and
65//!   call [`Pty::resize`], which is how a full-screen program inside learns to
66//!   redraw.
67//! - **`SIGINT`, `SIGTERM`, `SIGHUP`, `SIGQUIT`** — forward: terminate the
68//!   sandbox and keep relaying. The sandbox's exit closes the replicas,
69//!   end-of-file ends the loop, and restoration happens on the normal path. A
70//!   second such signal escalates to a kill. These arrive only from *outside*:
71//!   in raw mode a typed `^C` is a byte the relay forwards, which the sandbox's
72//!   own line discipline turns into the sandbox's own `SIGINT`, delivered to the
73//!   sandbox's own foreground process group. Forwarding the four from outside is
74//!   what keeps the caller's shell managing the relaying process as one job and
75//!   that process passing the management on.
76//! - **`SIGTSTP`** — restore the terminal, then stop with a self-directed
77//!   `SIGSTOP`.
78//! - **`SIGCONT`** — re-enter raw mode and re-read the window size, which may
79//!   have changed while stopped. The discipline `less` follows.
80//! - **`SIGTTOU`** — blocked for the effect of blocking it, and otherwise
81//!   ignored; see [`Signals`].
82//!
83//! # The two directions are not symmetric
84//!
85//! The sandbox-bound direction cannot stall the loop: the primary is set
86//! non-blocking and what it will not take waits in a pending buffer behind
87//! `POLLOUT`, so a command that stops reading its terminal never blocks the
88//! signal channel. The caller-bound direction is a plain blocking write, outside
89//! the poll, and that is deliberate.
90//!
91//! The symmetric treatment would need `O_NONBLOCK` on the output descriptor.
92//! That flag lives on the open file description, not on the descriptor, so
93//! setting it sets it for everything sharing that description — the caller's
94//! shell, and every other process in the pipeline the caller was started from,
95//! which would begin seeing `EAGAIN` from writes they have no reason to expect
96//! it on, and would keep seeing it if the process exited without restoring the
97//! flag. That is a worse failure than the one it prevents, and it is a failure
98//! in someone else's process.
99//!
100//! What is given up: with the output on a pipe whose reader has stopped, a write
101//! blocks the whole loop, so a `SIGINT` or a deadline is not observed until the
102//! reader drains. `ssh` has the same shape for the same reason. For the
103//! interactive session this exists for, the output is the caller's terminal,
104//! which does not stop reading.
105//!
106//! # One restorer, three triggers
107//!
108//! Raw mode is entered last — everything fallible happens first, so a failure
109//! before the loop leaves the caller's terminal untouched — and undone by three
110//! things between them covering every exit the process can observe: a guard in
111//! [`Relay::run`]'s own frame, dropped on normal exit, on error, and on a panic
112//! unwinding through it; the optional panic hook, which restores *before* the
113//! default hook prints, because with `OPOST` off a backtrace prints
114//! stair-stepped and a drop runs after the hook; and the stop path, which
115//! restores before the process stops and re-enters raw mode when it continues.
116//! [`restore_terminal`] states what restoration does and does not promise.
117
118mod escalation;
119mod raw;
120mod signals;
121
122use std::io;
123use std::os::fd::BorrowedFd;
124use std::time::{Duration, Instant};
125
126use rustix::event::{PollFd, PollFlags};
127use rustix::fs::OFlags;
128use rustix::io::Errno;
129use rustix::process::Signal;
130use rustix::termios::{self, SpecialCodeIndex};
131
132use crate::error::Error;
133use crate::mechanism::frame;
134use crate::poll;
135use crate::running::Running;
136use crate::status::ExitStatus;
137use crate::terminal::Pty;
138use raw::RawMode;
139
140pub use escalation::Escalation;
141pub use raw::{install_panic_restore, restore_terminal};
142pub use signals::Signals;
143
144/// The relay's read buffer, per direction and per wake.
145const RELAY_BUF: usize = 8192;
146
147/// Which channel a poll slot watches.
148///
149/// The caller's input is last because it is the one channel that retires: a
150/// retired slot is dropped from the polled slice rather than muted, which is
151/// what keeps the loop from spinning. `poll` reports `POLLHUP`, `POLLERR`, and
152/// `POLLNVAL` whatever the requested events are, so a muted slot on a hung-up
153/// pipe — `producer | relaying-program ...` after the producer exits — is ready
154/// on every pass and the loop never blocks again. [`poll::slot`] states the
155/// other technique that works, for the loops whose retiring channel is not last.
156///
157/// The other two indices stay stable across the change, and an unpolled slot's
158/// `revents` stays as `PollFd::new` left it: empty.
159const PRIMARY: usize = 0;
160const SIGNALS: usize = 1;
161const INPUT: usize = 2;
162
163/// A byte loop between the caller's terminal and the sandbox's.
164///
165/// Built around two descriptors the caller supplies and driven by
166/// [`run`](Self::run), which returns when the session ends. Nothing here is read
167/// from the environment: the terminal whose size a `SIGWINCH` follows is the
168/// supplied input descriptor, which is the caller's statement about what its
169/// terminal is rather than an ambient reading of the process's.
170///
171/// # What it changes
172///
173/// Two things, both stated:
174///
175/// - The caller's input descriptor enters raw mode for the loop's duration when
176///   [`raw`](Self::raw) asked for it, and is restored on every exit from
177///   [`run`](Self::run), a panic unwinding through it included.
178/// - The [`Pty`] is left non-blocking. A large paste must not stall the loop, so
179///   the primary is `O_NONBLOCK` and what it will not take waits behind
180///   `POLLOUT`. The flag is not undone: the session is over by then, and the
181///   `Pty` is the caller's to drop.
182///
183/// The output descriptor must be blocking; see the module's asymmetry section
184/// for why it is not given the treatment the primary gets.
185#[derive(Debug)]
186pub struct Relay<'fd> {
187    /// What the caller types.
188    input: BorrowedFd<'fd>,
189    /// Where the sandbox's bytes go.
190    output: BorrowedFd<'fd>,
191    /// Whether the input is put into raw mode for the loop's duration.
192    raw: bool,
193    /// The bounded stop, or `None` for a session bounded only by the command.
194    escalation: Option<Escalation>,
195}
196
197impl<'fd> Relay<'fd> {
198    /// A relay between `input` — what the caller types — and `output`, where the
199    /// sandbox's bytes go.
200    ///
201    /// For an interactive session both are the same terminal. Either may be a
202    /// pipe, and the relay still relays: a session driven from a script has no
203    /// terminal of its own to put into raw mode, and needs none.
204    pub fn new(input: BorrowedFd<'fd>, output: BorrowedFd<'fd>) -> Relay<'fd> {
205        Relay {
206            input,
207            output,
208            raw: false,
209            escalation: None,
210        }
211    }
212
213    /// Puts `input` into raw mode for the loop's duration.
214    ///
215    /// Off by default: the relay never touches termios unasked. Raw mode is what
216    /// makes an interactive session behave — keystrokes pass through unbuffered,
217    /// and the sandbox's own line discipline turns a typed `^C` into a `SIGINT`
218    /// for the sandbox's own foreground process group.
219    ///
220    /// Asking for it on an input that is not a terminal fails the run rather
221    /// than being ignored, because a caller that says so has said something the
222    /// descriptor cannot do. Whether the caller's own input *is* a terminal is
223    /// the caller's reading to make.
224    pub fn raw(mut self, raw: bool) -> Relay<'fd> {
225        self.raw = raw;
226        self
227    }
228
229    /// Bounds the session: the `timeout(1)` policy [`Escalation`] states.
230    ///
231    /// Without one the session runs until the command ends. The deadline runs
232    /// from where the [`Escalation`] was built, so build it where the run
233    /// begins.
234    pub fn escalation(mut self, escalation: Escalation) -> Relay<'fd> {
235        self.escalation = Some(escalation);
236        self
237    }
238
239    /// Drives the session to end-of-file on the primary, restores what it
240    /// changed, and collects the outcome.
241    ///
242    /// `Ok(None)` means a deadline passed, whatever became of the command
243    /// afterwards — [`Escalation::outcome`]'s convention. Returns as soon as the
244    /// session ends: end-of-file on the primary means every replica is closed
245    /// and the command is already gone, so the [`wait`](Running::wait) this
246    /// finishes with does not block.
247    ///
248    /// The order on the way in is everything fallible first, then raw mode
249    /// immediately before the loop, so every failure before that point leaves
250    /// the caller's terminal untouched. On the way out it reverses: the loop
251    /// ends, the terminal is restored, and only then is the outcome collected —
252    /// which is what keeps a diagnostic from printing stair-stepped down a
253    /// screen still in raw mode.
254    pub fn run(
255        mut self,
256        running: &mut Running<'_>,
257        pty: &Pty,
258        signals: &Signals,
259    ) -> Result<Option<ExitStatus>, RelayError> {
260        // A large paste must not stall the loop, so the primary is non-blocking
261        // and what does not fit waits behind POLLOUT.
262        let flags = rustix::fs::fcntl_getfl(pty)
263            .map_err(RelayError::at("reading the primary's descriptor flags"))?;
264        rustix::fs::fcntl_setfl(pty, flags | OFlags::NONBLOCK)
265            .map_err(RelayError::at("setting the primary non-blocking"))?;
266
267        // Entered last, and dropped at every exit from here on, so the terminal
268        // is restored before anything is printed or collected.
269        let guard = match self.raw {
270            true => Some(RawMode::enter(self.input)?),
271            false => None,
272        };
273
274        // No escalation is one with no deadline: nothing ever expires, so the
275        // grace it carries is never consulted.
276        let mut escalation = self
277            .escalation
278            .take()
279            .unwrap_or_else(|| Escalation::new(None, Duration::ZERO));
280        let outcome = self.pump(running, pty, signals, guard.as_ref(), &mut escalation);
281        drop(guard);
282        outcome?;
283
284        // The wait is prompt: the loop ended at end-of-file on the primary,
285        // which means every replica is closed and the command is already gone.
286        let status = running.wait()?;
287        Ok(escalation.outcome(status))
288    }
289
290    /// The loop proper.
291    fn pump(
292        &self,
293        running: &mut Running<'_>,
294        pty: &Pty,
295        signals: &Signals,
296        guard: Option<&RawMode<'_>>,
297        escalation: &mut Escalation,
298    ) -> Result<(), RelayError> {
299        let signal_fd = signals.as_fd();
300        let mut input_open = true;
301        let mut pending: Vec<u8> = Vec::new();
302        let mut buf = [0u8; RELAY_BUF];
303        // A second interrupt escalates; the first is a request.
304        let mut interrupted = false;
305
306        loop {
307            // The caller's input is watched only while there is room to put what
308            // it produces: with a full pending buffer, reading more would grow
309            // it without bound.
310            let watch_input = input_open && pending.is_empty();
311            let mut primary_events = PollFlags::IN;
312            if !pending.is_empty() {
313                primary_events |= PollFlags::OUT;
314            }
315            let mut slots = [
316                PollFd::new(pty, primary_events),
317                PollFd::new(&signal_fd, PollFlags::IN),
318                PollFd::new(&self.input, PollFlags::IN),
319            ];
320            // The retired input slot leaves the polled slice rather than being
321            // muted; see the slot constants for why muting does not work.
322            let watched = match watch_input {
323                true => &mut slots[..],
324                false => &mut slots[..INPUT],
325            };
326
327            let wait = escalation
328                .deadline()
329                .map(|deadline| poll::timeout(deadline.saturating_duration_since(Instant::now())));
330            match rustix::event::poll(watched, wait.as_ref()) {
331                Ok(_) => {}
332                Err(Errno::INTR) => continue,
333                Err(errno) => return Err(RelayError::at("waiting on the session")(errno)),
334            }
335
336            // Asked on every wakeup, not only on the one `poll` timed out on.
337            // Once the deadline is behind the loop the poll timeout is zero, so
338            // a descriptor with something to say answers `Ok(n > 0)` first and a
339            // sandbox printing without pause would postpone the escalation for
340            // as long as it kept winning that race. Consulting the clock instead
341            // makes the expiry a consequence of the clock.
342            if escalation.passed() {
343                // Escalate, and keep relaying: the sandbox's exit closes the
344                // replicas, end-of-file ends the loop, and restoration happens
345                // on the normal path.
346                escalation.expire(running)?;
347            }
348
349            let ready: [PollFlags; 3] = std::array::from_fn(|index| slots[index].revents());
350
351            if ready[SIGNALS].intersects(PollFlags::IN) {
352                self.handle_signals(running, pty, signals, guard, &mut interrupted)?;
353            }
354
355            // Drain whatever the primary can take of the pending buffer, then
356            // whatever the caller's input has produced since.
357            if ready[PRIMARY].intersects(PollFlags::OUT) {
358                flush_pending(pty, &mut pending)?;
359            }
360            // Every way the slot can be ready leads into the read: readable,
361            // hung up, in error, or naming no open description at all. The last
362            // is a caller started with its input closed, which reports
363            // `POLLNVAL` and would otherwise be ready forever without anything
364            // acting on it.
365            if ready[INPUT]
366                .intersects(PollFlags::IN | PollFlags::HUP | PollFlags::ERR | PollFlags::NVAL)
367            {
368                match rustix::io::read(self.input, &mut buf) {
369                    // End of input. There is no way to close a
370                    // pseudoterminal's write side, so this is sent as the
371                    // replica's own VEOF character, and the channel retires.
372                    //
373                    // An invalid input takes the same path: there is nothing to
374                    // relay and never will be, which is the same thing end of
375                    // input says, and the session continues on the caller-bound
376                    // direction alone.
377                    Ok(0) | Err(Errno::BADF) => {
378                        pending.push(replica_veof(pty));
379                        flush_pending(pty, &mut pending)?;
380                        input_open = false;
381                    }
382                    Ok(read) => {
383                        pending.extend_from_slice(&buf[..read]);
384                        flush_pending(pty, &mut pending)?;
385                    }
386                    Err(Errno::INTR | Errno::AGAIN) => {}
387                    Err(errno) => {
388                        return Err(RelayError::at("reading the caller's input")(errno));
389                    }
390                }
391            }
392
393            if ready[PRIMARY].intersects(PollFlags::IN | PollFlags::HUP) {
394                match rustix::io::read(pty, &mut buf) {
395                    // End-of-file on the primary is the session-over signal.
396                    Ok(0) => return Ok(()),
397                    // Blocking, and outside the poll; see the module's "The two
398                    // directions are not symmetric".
399                    Ok(read) => write_all(self.output, &buf[..read])?,
400                    Err(Errno::INTR | Errno::AGAIN) => {}
401                    // Hangup on a pseudoterminal primary is EIO, and it is the
402                    // same end of session.
403                    Err(Errno::IO) => return Ok(()),
404                    Err(errno) => {
405                        return Err(RelayError::at("reading the sandbox's terminal")(errno));
406                    }
407                }
408            }
409        }
410    }
411
412    /// Drains the signal descriptor and acts on everything it carried.
413    fn handle_signals(
414        &self,
415        running: &mut Running<'_>,
416        pty: &Pty,
417        signals: &Signals,
418        guard: Option<&RawMode<'_>>,
419        interrupted: &mut bool,
420    ) -> Result<(), RelayError> {
421        while let Some(signal) = signals.next()? {
422            match signal {
423                // The caller's terminal changed size; the sandbox's follows.
424                Signal::WINCH => self.follow_size(pty)?,
425                // These arrive only from outside: in raw mode a typed `^C` is a
426                // byte the relay forwards, which the sandbox's own line
427                // discipline turns into the sandbox's own SIGINT.
428                Signal::INT | Signal::TERM | Signal::HUP | Signal::QUIT => match interrupted {
429                    true => running.kill()?,
430                    false => {
431                        running.terminate()?;
432                        *interrupted = true;
433                    }
434                },
435                // Restore before stopping, so the shell that resumes finds its
436                // terminal as it left it. The self-directed SIGSTOP returns once
437                // something continues this process, and the SIGCONT that did so
438                // is pending on the descriptor.
439                Signal::TSTP => {
440                    if let Some(guard) = guard {
441                        guard.restore_for_stop();
442                    }
443                    rustix::process::kill_process(rustix::process::getpid(), Signal::STOP)
444                        .map_err(RelayError::at("stopping the relaying process"))?;
445                }
446                // Continued: re-enter raw mode and re-read the size, which may
447                // have changed while stopped. The discipline `less` follows.
448                Signal::CONT => {
449                    if let Some(guard) = guard {
450                        guard.reenter()?;
451                        self.follow_size(pty)?;
452                    }
453                }
454                _ => {}
455            }
456        }
457        Ok(())
458    }
459
460    /// Sets the sandbox's terminal to the size the caller's input reports, where
461    /// it reports one.
462    fn follow_size(&self, pty: &Pty) -> Result<(), RelayError> {
463        if let Some((rows, cols)) = raw::window_size(self.input) {
464            pty.resize(rows, cols)?;
465        }
466        Ok(())
467    }
468}
469
470/// Writes as much of `pending` to the primary as it will take, keeping the rest.
471fn flush_pending(pty: &Pty, pending: &mut Vec<u8>) -> Result<(), RelayError> {
472    while !pending.is_empty() {
473        match rustix::io::write(pty, pending) {
474            Ok(0) => return Ok(()),
475            Ok(written) => {
476                pending.drain(..written);
477            }
478            Err(Errno::AGAIN | Errno::INTR) => return Ok(()),
479            // The session is over; what is left has nowhere to go, and the
480            // primary's own read reports the end.
481            Err(Errno::IO) => {
482                pending.clear();
483                return Ok(());
484            }
485            Err(errno) => return Err(RelayError::at("writing to the sandbox's terminal")(errno)),
486        }
487    }
488    Ok(())
489}
490
491/// Writes the whole of `bytes` to the caller-bound descriptor.
492///
493/// Blocking, and outside the poll. A descriptor the caller has made
494/// non-blocking fails the session with `EAGAIN` rather than being spun on: the
495/// relay states that this direction blocks, and a caller that arranged otherwise
496/// has arranged something the loop cannot honour.
497///
498/// The loop over short writes is [`frame::write_full`]'s, which is where this
499/// crate answers "write all of these bytes to a descriptor, retrying `EINTR`".
500/// All that is left here is the step this failure names.
501fn write_all(output: BorrowedFd<'_>, bytes: &[u8]) -> Result<(), RelayError> {
502    frame::write_full(output, bytes).map_err(RelayError::at("writing to the caller's terminal"))
503}
504
505/// The replica's current end-of-file character.
506///
507/// Read rather than assumed: Linux redirects a termios query on a
508/// pseudoterminal primary to the replica, so this is what the sandbox's line
509/// discipline calls end-of-file *now*, which a program inside is free to have
510/// changed. The baseline's `^D` is the fallback for a query that fails.
511fn replica_veof(pty: &Pty) -> u8 {
512    termios::tcgetattr(pty)
513        .map(|settings| settings.special_codes[SpecialCodeIndex::VEOF])
514        .unwrap_or(0o004)
515}
516
517/// A relay failure: the sandbox's, or the relay's own plumbing.
518#[derive(Debug)]
519#[non_exhaustive]
520pub enum RelayError {
521    /// The sandbox failed: signalling it, resizing its terminal, or collecting
522    /// its outcome.
523    Cage(Error),
524    /// The relay's own plumbing failed: the signal descriptor, the poll, the
525    /// caller's terminal settings, or a read or write of one of its channels.
526    #[non_exhaustive]
527    Io {
528        /// What the relay was doing.
529        op: &'static str,
530        /// The underlying error.
531        source: io::Error,
532    },
533}
534
535impl RelayError {
536    /// A plumbing failure, naming what the relay was doing.
537    ///
538    /// Arguments run operation, then cause, which is the order every constructor
539    /// in the crate takes them in; the relay's channels are the caller's own
540    /// descriptors, so there is no locator between them to name.
541    pub(crate) fn io(op: &'static str, source: io::Error) -> RelayError {
542        RelayError::Io { op, source }
543    }
544
545    /// The same failure as a [`map_err`](Result::map_err) argument.
546    ///
547    /// Takes the `Errno` a rustix call fails with rather than the `io::Error`
548    /// the crate's other `at` constructors take: every syscall this module makes
549    /// goes through rustix, so folding the conversion in here states it once
550    /// instead of at each of the loop's call sites.
551    pub(crate) fn at(op: &'static str) -> impl FnOnce(Errno) -> RelayError {
552        move |errno| RelayError::Io {
553            op,
554            source: errno.into(),
555        }
556    }
557}
558
559impl std::fmt::Display for RelayError {
560    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
561        match self {
562            RelayError::Cage(error) => write!(f, "the relayed sandbox failed: {error}"),
563            RelayError::Io { op, source } => {
564                write!(f, "the terminal relay failed while {op}: {source}")
565            }
566        }
567    }
568}
569
570impl std::error::Error for RelayError {
571    /// The failure underneath: the library's own error, or the OS error the
572    /// relay's plumbing reported.
573    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
574        match self {
575            RelayError::Cage(error) => Some(error),
576            RelayError::Io { source, .. } => Some(source),
577        }
578    }
579}
580
581impl From<Error> for RelayError {
582    fn from(error: Error) -> RelayError {
583        RelayError::Cage(error)
584    }
585}