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}