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};