Skip to main content

arcbox_pty/
lib.rs

1//! PTY session primitives shared by ArcBox guest agents.
2//!
3//! Both interactive-exec implementations — the sandbox microVM's `vm-agent`
4//! and the machine-level session in `arcbox-agent` — need the same subtle,
5//! security-relevant steps: allocating a sized PTY, wiring the slave as the
6//! child's controlling terminal, and dropping privileges in the one order
7//! that works. This crate is the single home for those steps; everything the
8//! consumers legitimately differ on (process reaping, sync vs async pumps,
9//! wire framing) stays with them.
10//!
11//! Linux-only: on other targets the crate compiles to nothing so host-side
12//! workspace builds stay unaffected.
13
14#[cfg(target_os = "linux")]
15mod linux {
16    use std::io;
17    use std::os::fd::{AsRawFd, OwnedFd};
18
19    /// Terminal dimensions in character cells.
20    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
21    pub struct WinSize {
22        pub cols: u16,
23        pub rows: u16,
24    }
25
26    impl WinSize {
27        const fn to_libc(self) -> libc::winsize {
28            libc::winsize {
29                ws_col: self.cols,
30                ws_row: self.rows,
31                ws_xpixel: 0,
32                ws_ypixel: 0,
33            }
34        }
35    }
36
37    /// An allocated PTY pair. The slave is handed to the child (via
38    /// [`child_terminal_setup`]); the parent keeps the master and must close
39    /// its slave copy after spawning.
40    #[derive(Debug)]
41    pub struct PtyPair {
42        pub master: OwnedFd,
43        pub slave: OwnedFd,
44    }
45
46    /// Allocates a PTY, optionally applying an initial window size.
47    ///
48    /// # Errors
49    /// Returns an error if the kernel refuses a PTY (e.g. devpts missing).
50    pub fn openpty_sized(size: Option<WinSize>) -> io::Result<PtyPair> {
51        let pty = nix::pty::openpty(None, None).map_err(io::Error::from)?;
52        if let Some(size) = size {
53            resize(&pty.master, size)?;
54        }
55        Ok(PtyPair {
56            master: pty.master,
57            slave: pty.slave,
58        })
59    }
60
61    /// Applies a window size to a PTY master (initial or mid-session).
62    ///
63    /// # Errors
64    /// Returns an error if the ioctl fails (master closed).
65    pub fn resize(master: &impl AsRawFd, size: WinSize) -> io::Result<()> {
66        let ws = size.to_libc();
67        // SAFETY: the fd is a live PTY master owned by the caller and `ws`
68        // is a valid winsize.
69        if unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &ws) } != 0 {
70            return Err(io::Error::last_os_error());
71        }
72        Ok(())
73    }
74
75    /// Credentials to drop to before exec, resolved via [`resolve_user`].
76    #[derive(Debug, Clone, Copy)]
77    pub struct RunAs {
78        pub uid: libc::uid_t,
79        pub gid: libc::gid_t,
80    }
81
82    /// Resolves a username or numeric UID to run-as credentials.
83    ///
84    /// A numeric string is taken as a UID with GID equal to it — matching the
85    /// behavior both agents shipped historically.
86    ///
87    /// # Errors
88    /// Returns an error for an unknown user name.
89    pub fn resolve_user(user: &str) -> io::Result<RunAs> {
90        if let Ok(uid) = user.parse::<libc::uid_t>() {
91            return Ok(RunAs { uid, gid: uid });
92        }
93        let entry = nix::unistd::User::from_name(user)
94            .map_err(io::Error::from)?
95            .ok_or_else(|| {
96                io::Error::new(io::ErrorKind::NotFound, format!("unknown user: {user}"))
97            })?;
98        Ok(RunAs {
99            uid: entry.uid.as_raw(),
100            gid: entry.gid.as_raw(),
101        })
102    }
103
104    /// Builds a `pre_exec`-compatible closure that turns the child into an
105    /// interactive session leader on `slave` and optionally drops privileges.
106    ///
107    /// Steps, in load-bearing order:
108    /// 1. `setsid` — new session, detached from the agent's controlling TTY.
109    /// 2. `TIOCSCTTY` — the PTY slave becomes the controlling terminal.
110    /// 3. `dup2` the slave over stdin/stdout/stderr (and close the original
111    ///    when it lies above fd 2).
112    /// 4. `setgroups → setgid → setuid` — the reverse order would drop the
113    ///    right to change groups before using it. Any failure aborts the
114    ///    exec rather than running the workload with the wrong identity.
115    ///
116    /// The returned closure is async-signal-safe: raw syscalls only.
117    ///
118    /// Must only be used with `Command::pre_exec` (it runs post-fork,
119    /// pre-exec); `slave` must remain open in the parent until after spawn.
120    #[must_use]
121    pub fn child_terminal_setup(
122        slave: libc::c_int,
123        run_as: Option<RunAs>,
124    ) -> impl FnMut() -> io::Result<()> + Send + 'static {
125        move || {
126            // SAFETY: post-fork child; every call below is async-signal-safe.
127            unsafe {
128                if libc::setsid() < 0 {
129                    return Err(io::Error::last_os_error());
130                }
131                if libc::ioctl(slave, libc::TIOCSCTTY, 0) != 0 {
132                    return Err(io::Error::last_os_error());
133                }
134                for fd in [libc::STDIN_FILENO, libc::STDOUT_FILENO, libc::STDERR_FILENO] {
135                    if libc::dup2(slave, fd) < 0 {
136                        return Err(io::Error::last_os_error());
137                    }
138                }
139                if slave > libc::STDERR_FILENO && libc::close(slave) != 0 {
140                    return Err(io::Error::last_os_error());
141                }
142                if let Some(RunAs { uid, gid }) = run_as {
143                    if libc::setgroups(1, &raw const gid) != 0
144                        || libc::setgid(gid) != 0
145                        || libc::setuid(uid) != 0
146                    {
147                        return Err(io::Error::last_os_error());
148                    }
149                }
150            }
151            Ok(())
152        }
153    }
154
155    #[cfg(test)]
156    mod tests {
157        use super::*;
158        use std::io::{Read, Write};
159
160        #[test]
161        fn openpty_applies_initial_size_and_resize() {
162            let pty = openpty_sized(Some(WinSize {
163                cols: 120,
164                rows: 40,
165            }))
166            .unwrap();
167            let mut ws = libc::winsize {
168                ws_col: 0,
169                ws_row: 0,
170                ws_xpixel: 0,
171                ws_ypixel: 0,
172            };
173            // SAFETY: live master fd; ws is a valid out-param.
174            unsafe { libc::ioctl(pty.master.as_raw_fd(), libc::TIOCGWINSZ, &mut ws) };
175            assert_eq!((ws.ws_col, ws.ws_row), (120, 40));
176
177            resize(&pty.master, WinSize { cols: 80, rows: 24 }).unwrap();
178            // SAFETY: as above.
179            unsafe { libc::ioctl(pty.master.as_raw_fd(), libc::TIOCGWINSZ, &mut ws) };
180            assert_eq!((ws.ws_col, ws.ws_row), (80, 24));
181        }
182
183        #[test]
184        fn pty_pair_round_trips_bytes() {
185            let pty = openpty_sized(None).unwrap();
186            let mut master = std::fs::File::from(pty.master);
187            let mut slave = std::fs::File::from(pty.slave);
188            master.write_all(b"ping\n").unwrap();
189            let mut buf = [0u8; 8];
190            let n = slave.read(&mut buf).unwrap();
191            assert_eq!(&buf[..n], b"ping\n");
192        }
193
194        #[test]
195        fn resolve_user_accepts_numeric_and_rejects_unknown() {
196            let run_as = resolve_user("1234").unwrap();
197            assert_eq!((run_as.uid, run_as.gid), (1234, 1234));
198            assert!(resolve_user("no-such-user-arcbox").is_err());
199        }
200    }
201}
202
203#[cfg(target_os = "linux")]
204pub use linux::{
205    PtyPair, RunAs, WinSize, child_terminal_setup, openpty_sized, resize, resolve_user,
206};