Skip to main content

podbox/
process.rs

1use std::ffi::OsString;
2use std::io::{self, IoSlice, IoSliceMut};
3use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
4use std::os::unix::net::UnixStream;
5use std::os::unix::process::CommandExt;
6use std::process::{Command, ExitStatus, Output};
7use std::time::Duration;
8
9use nix::sys::signal::{Signal, kill};
10use nix::sys::socket::{ControlMessage, ControlMessageOwned, MsgFlags, recvmsg, sendmsg};
11use nix::unistd::Pid;
12
13/// Build a `Vec<OsString>` from a slice of `&str`/`&String` literals.
14pub fn args<S: AsRef<str>>(items: &[S]) -> Vec<OsString> {
15    items.iter().map(|s| OsString::from(s.as_ref())).collect()
16}
17
18/// Replace the current process with the given binary and arguments.
19///
20/// Uses `CommandExt::exec()` so the shell gets a real TTY.
21/// On success this function never returns; on failure it returns an error.
22pub fn exec_replace(bin: &str, args: &[OsString]) -> anyhow::Error {
23    let mut cmd = Command::new(bin);
24    cmd.args(args);
25    let err = cmd.exec();
26    anyhow::Error::from(err).context(format!("failed to exec {bin}"))
27}
28
29/// Run a command, capturing stdout and stderr.
30pub fn run_piped(bin: &str, args: &[OsString]) -> anyhow::Result<Output> {
31    let output = Command::new(bin)
32        .args(args)
33        .stdout(std::process::Stdio::piped())
34        .stderr(std::process::Stdio::piped())
35        .spawn()?
36        .wait_with_output()?;
37    Ok(output)
38}
39
40/// Spawn a command attached to the current terminal.
41pub fn spawn_interactive(bin: &str, args: &[OsString]) -> anyhow::Result<ExitStatus> {
42    let status = Command::new(bin).args(args).status()?;
43    Ok(status)
44}
45
46/// Run a command with a timeout, capturing stdout and stderr.
47///
48/// The child process receives SIGKILL after `timeout` if it has not exited.
49pub fn run_piped_timeout(
50    bin: &str,
51    args: &[OsString],
52    timeout: Duration,
53) -> anyhow::Result<Output> {
54    let child = Command::new(bin)
55        .args(args)
56        .stdout(std::process::Stdio::piped())
57        .stderr(std::process::Stdio::piped())
58        .spawn()?;
59    wait_child_timeout(child, timeout)
60}
61
62/// Run an interactive command with a timeout (SIGTERM, then SIGKILL).
63///
64/// Unlike `run_piped_timeout`, this sends SIGTERM first with a 5-second
65/// grace period before SIGKILL, giving well-behaved processes a chance
66/// to clean up.
67pub fn spawn_interactive_timeout(
68    bin: &str,
69    args: &[OsString],
70    timeout: Duration,
71) -> anyhow::Result<ExitStatus> {
72    let mut child = Command::new(bin).args(args).spawn()?;
73    let pid = Pid::from_raw(child.id().cast_signed());
74    let (tx, rx) = std::sync::mpsc::channel();
75    std::thread::spawn(move || {
76        if rx.recv_timeout(timeout).is_err() {
77            let _ = kill(pid, Signal::SIGTERM);
78            std::thread::sleep(Duration::from_secs(5));
79            let _ = kill(pid, Signal::SIGKILL);
80        }
81    });
82    let status = child.wait()?;
83    let _ = tx.send(());
84    Ok(status)
85}
86
87/// Wait for a child process to complete, enforcing a timeout via SIGKILL.
88pub fn wait_child_timeout(child: std::process::Child, timeout: Duration) -> anyhow::Result<Output> {
89    let pid = Pid::from_raw(child.id().cast_signed());
90    let (tx, rx) = std::sync::mpsc::channel();
91    std::thread::spawn(move || {
92        if rx.recv_timeout(timeout).is_err() {
93            let _ = kill(pid, Signal::SIGKILL);
94        }
95    });
96    let output = child.wait_with_output()?;
97    let _ = tx.send(());
98    Ok(output)
99}
100
101/// Open a pidfd for a given PID (Linux 5.3+).
102///
103/// Returns `Err` on old kernels or when the PID does not exist.
104pub fn open_pidfd(pid: i32) -> io::Result<OwnedFd> {
105    let pid = rustix::process::Pid::from_raw(pid)
106        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid PID"))?;
107    rustix::process::pidfd_open(pid, rustix::process::PidfdFlags::empty()).map_err(io::Error::from)
108}
109
110/// Send a raw file descriptor over a connected Unix stream via `SCM_RIGHTS`.
111///
112/// Sends one dummy byte alongside the descriptor so the receiver can detect EOF.
113/// Retries on `EINTR` to prevent spurious session drops.
114pub fn send_fd(stream: &UnixStream, fd: RawFd) -> io::Result<()> {
115    let raw_fd = stream.as_raw_fd();
116    let cmsg = ControlMessage::ScmRights(&[fd]);
117    let iov = [IoSlice::new(&[0u8])];
118    loop {
119        match sendmsg::<()>(raw_fd, &iov, &[cmsg], MsgFlags::empty(), None) {
120            Ok(_) => return Ok(()),
121            Err(nix::errno::Errno::EINTR) => {}
122            Err(e) => return Err(io::Error::from(e)),
123        }
124    }
125}
126
127/// Adopt a raw descriptor received via `SCM_RIGHTS` into an owned handle.
128///
129/// The kernel duplicates ancillary-data descriptors into the receiving
130/// process on delivery, transferring their single reference — whoever
131/// calls this must adopt them (here) or close them (leak). This is the
132/// single audit point for that ownership transfer in this crate.
133pub fn adopt_scm_fd(raw: RawFd) -> OwnedFd {
134    // SAFETY: `raw` arrived via SCM_RIGHTS over a Unix socket; the kernel
135    // duplicated it into this process on delivery and we now hold exactly
136    // one reference to a valid open descriptor. No safe wrapper exists for
137    // adopting externally-sourced fds.
138    #[allow(unsafe_code)]
139    unsafe {
140        OwnedFd::from_raw_fd(raw)
141    }
142}
143
144/// Receive a raw file descriptor from a connected Unix stream via `SCM_RIGHTS`.
145///
146/// Returns `None` when the sender has closed the connection (EOF).
147/// Retries on `EINTR` to prevent spurious session drops.
148pub fn recv_fd(stream: &UnixStream) -> io::Result<Option<RawFd>> {
149    let raw_fd = stream.as_raw_fd();
150    let mut buf = [0u8; 1];
151    let mut iov = [IoSliceMut::new(&mut buf)];
152    let mut cmsg_buf = vec![0u8; 256];
153    let msg = loop {
154        match recvmsg::<()>(raw_fd, &mut iov, Some(&mut cmsg_buf), MsgFlags::empty()) {
155            Ok(m) => break m,
156            Err(nix::errno::Errno::EINTR) => {}
157            Err(e) => return Err(io::Error::from(e)),
158        }
159    };
160
161    if msg.bytes == 0 {
162        return Ok(None);
163    }
164
165    if let Ok(cmsgs) = msg.cmsgs() {
166        for cmsg in cmsgs {
167            if let ControlMessageOwned::ScmRights(fds) = cmsg {
168                if let Some(&fd) = fds.first() {
169                    return Ok(Some(fd));
170                }
171            }
172        }
173    }
174    Ok(None)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn args_builds_osstring_vec() {
183        let v = args(&["foo", "bar", "baz"]);
184        assert_eq!(v.len(), 3);
185        assert_eq!(v[0], "foo");
186        assert_eq!(v[1], "bar");
187        assert_eq!(v[2], "baz");
188    }
189
190    #[test]
191    fn args_accepts_mixed_types() {
192        let s = String::from("hello");
193        let v = args(&["a", &s, "c"]);
194        assert_eq!(v[1], "hello");
195    }
196}