Skip to main content

shrub_core/sys/unix/
commands.rs

1//! Command execution utilities.
2
3use std::ffi::{CStr, CString};
4pub use std::os::unix::process::{CommandExt, ExitStatusExt};
5use std::sync::Arc;
6
7use command_fds::{CommandFdExt, FdMapping};
8
9use crate::options::PidsLimit;
10use crate::{ShellFd, error, openfiles};
11
12/// The bytes a child reads from a `pids.current` file: more than the longest
13/// count, a `u64` of 20 digits and a newline, so a full buffer means the file
14/// holds something else.
15const PIDS_CURRENT_BUFFER: usize = 32;
16
17/// Extension trait for injecting file descriptors into commands.
18pub trait CommandFdInjectionExt {
19    /// Injects the given open files as file descriptors into the command.
20    ///
21    /// # Arguments
22    ///
23    /// * `open_files` - A mapping of child file descriptors to open files.
24    ///
25    /// # Errors
26    ///
27    /// - An error if a file's descriptor cannot be duplicated.
28    /// - [`crate::error::ErrorKind::ChildCreationFailure`] if the descriptor
29    ///   mappings cannot be applied to the command, for example because two
30    ///   files map to the same child descriptor.
31    fn inject_fds(
32        &mut self,
33        open_files: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
34    ) -> Result<(), error::Error>;
35}
36
37impl CommandFdInjectionExt for std::process::Command {
38    fn inject_fds(
39        &mut self,
40        open_files: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
41    ) -> Result<(), error::Error> {
42        let fd_mappings: Vec<FdMapping> = open_files
43            .map(|(child_fd, open_file)| -> Result<FdMapping, error::Error> {
44                let parent_fd = open_file.try_clone_to_owned()?;
45                Ok(FdMapping {
46                    child_fd,
47                    parent_fd,
48                })
49            })
50            .collect::<Result<Vec<_>, _>>()?;
51
52        self.fd_mappings(fd_mappings)
53            .map_err(|_e| error::ErrorKind::ChildCreationFailure)?;
54
55        Ok(())
56    }
57}
58
59/// Extension trait for arranging for commands to take the foreground.
60pub trait CommandFgControlExt {
61    /// Arranges for the command to take the foreground when it is executed.
62    fn take_foreground(&mut self);
63    /// Arranges for the command to become a session leader when it is executed.
64    fn lead_session(&mut self);
65}
66
67impl CommandFgControlExt for std::process::Command {
68    fn take_foreground(&mut self) {
69        // SAFETY:
70        // This arranges for a provided function to run in the context of
71        // the forked process before it exec's the target command. In general,
72        // rust can't guarantee safety of code running in such a context.
73        unsafe {
74            self.pre_exec(pre_exec_take_foreground);
75        }
76    }
77
78    fn lead_session(&mut self) {
79        // SAFETY:
80        // This arranges for a provided function to run in the context of
81        // the forked process before it exec's the target command. In general,
82        // rust can't guarantee safety of code running in such a context.
83        unsafe {
84            self.pre_exec(pre_exec_lead_session);
85        }
86    }
87}
88
89fn pre_exec_take_foreground() -> Result<(), std::io::Error> {
90    use crate::sys;
91
92    sys::terminal::move_self_to_foreground()?;
93    Ok(())
94}
95
96fn pre_exec_lead_session() -> Result<(), std::io::Error> {
97    if let Err(e) = nix::unistd::setsid() {
98        return Err(std::io::Error::other(format!(
99            "failed to become session leader: {e}"
100        )));
101    }
102
103    #[cfg(not(target_os = "macos"))]
104    let control = libc::TIOCSCTTY;
105    #[cfg(target_os = "macos")]
106    let control: u64 = libc::TIOCSCTTY.into();
107
108    // SAFETY:
109    // This is calling a libc function to set the controlling terminal.
110    let result = unsafe { libc::ioctl(0, control, 0) };
111    if result != 0 {
112        return Err(std::io::Error::other("failed to set controlling terminal"));
113    }
114
115    Ok(())
116}
117
118/// Extension trait for moving a command into a cgroup before it runs.
119pub trait CommandCgroupExt {
120    /// Arranges for the command to write `0` to `cgroup_procs`, a cgroup v2's
121    /// `cgroup.procs` file, after it is forked and before it runs its program,
122    /// which moves it into that cgroup.
123    ///
124    /// # Errors
125    ///
126    /// None here: a child that cannot open or write the file fails its spawn.
127    fn join_cgroup(&mut self, cgroup_procs: Arc<CString>) -> Result<(), error::Error>;
128}
129
130impl CommandCgroupExt for std::process::Command {
131    fn join_cgroup(&mut self, cgroup_procs: Arc<CString>) -> Result<(), error::Error> {
132        // SAFETY:
133        // This arranges for a provided function to run in the context of
134        // the forked process before it exec's the target command. The
135        // function allocates nothing and only calls open, write and close,
136        // which are async-signal-safe.
137        unsafe {
138            self.pre_exec(move || pre_exec_join_cgroup(&cgroup_procs));
139        }
140        Ok(())
141    }
142}
143
144fn pre_exec_join_cgroup(cgroup_procs: &CStr) -> Result<(), std::io::Error> {
145    use nix::fcntl::{OFlag, open};
146    use nix::sys::stat::Mode;
147
148    let file = open(
149        cgroup_procs,
150        OFlag::O_WRONLY | OFlag::O_CLOEXEC,
151        Mode::empty(),
152    )?;
153    nix::unistd::write(&file, b"0")?;
154    Ok(())
155}
156
157/// Extension trait for setting a command's open-file limit before it runs.
158pub trait CommandOpenFileLimitExt {
159    /// Arranges for the command to set its soft `RLIMIT_NOFILE` to `limit`,
160    /// or to its hard limit if that is lower, after it is forked and before
161    /// it runs its program.
162    ///
163    /// # Errors
164    ///
165    /// None here: a child that cannot set the limit fails its spawn.
166    fn set_open_file_limit(&mut self, limit: u64) -> Result<(), error::Error>;
167}
168
169impl CommandOpenFileLimitExt for std::process::Command {
170    fn set_open_file_limit(&mut self, limit: u64) -> Result<(), error::Error> {
171        // SAFETY:
172        // This arranges for a provided function to run in the context of
173        // the forked process before it exec's the target command. The
174        // function allocates nothing and only calls getrlimit and
175        // setrlimit, which are async-signal-safe.
176        unsafe {
177            self.pre_exec(move || pre_exec_set_open_file_limit(limit));
178        }
179        Ok(())
180    }
181}
182
183/// The highest soft open-file limit macOS always accepts: `OPEN_MAX` in
184/// `<sys/syslimits.h>`. Its `setrlimit(2)` page says to use
185/// `min(OPEN_MAX, rlim_max)` where a higher soft limit fails with `EINVAL`.
186#[cfg(target_os = "macos")]
187const MACOS_OPEN_MAX: libc::rlim_t = 10240;
188
189fn pre_exec_set_open_file_limit(limit: u64) -> Result<(), std::io::Error> {
190    use nix::sys::resource::{Resource, getrlimit, setrlimit};
191
192    let (_, hard) = getrlimit(Resource::RLIMIT_NOFILE)?;
193    // `rlim_t` is `u64` on Linux and macOS but `i64` on FreeBSD; a limit
194    // past its range sets the hard limit, as any limit above it does.
195    #[allow(
196        clippy::unnecessary_fallible_conversions,
197        reason = "rlim_t is not u64 on every Unix"
198    )]
199    let soft = libc::rlim_t::try_from(limit).unwrap_or(hard).min(hard);
200    let result = setrlimit(Resource::RLIMIT_NOFILE, soft, hard);
201    #[cfg(target_os = "macos")]
202    let result = match result {
203        Err(nix::errno::Errno::EINVAL) => {
204            setrlimit(Resource::RLIMIT_NOFILE, soft.min(MACOS_OPEN_MAX), hard)
205        }
206        other => other,
207    };
208    Ok(result?)
209}
210
211/// Extension trait for checking process counts before a command runs.
212pub trait CommandPidsLimitsExt {
213    /// Arranges for the command, after it is forked and before it runs its
214    /// program, to read each limit's count and to fail its spawn when a count
215    /// is above its limit (`EAGAIN`, the error `fork` returns at `pids.max`)
216    /// or cannot be read (`EIO`).
217    ///
218    /// In the process that registered it, as on a path that runs the
219    /// command's steps with no fork, the check fails with `ENOTSUP` without
220    /// reading a count: there a count would include every thread of that
221    /// process, and the `exec` builtin refuses such a command before it
222    /// composes it.
223    ///
224    /// # Errors
225    ///
226    /// None here: a child whose check fails fails its spawn.
227    fn check_pids_limits(&mut self, limits: Arc<[PidsLimit]>) -> Result<(), error::Error>;
228}
229
230impl CommandPidsLimitsExt for std::process::Command {
231    fn check_pids_limits(&mut self, limits: Arc<[PidsLimit]>) -> Result<(), error::Error> {
232        // The process that registers the check: the function runs in it, not
233        // in a child, only on the exec builtin's path.
234        let shell_pid = nix::unistd::getpid();
235        // SAFETY:
236        // This arranges for a provided function to run in the context of
237        // the forked process before it exec's the target command. The
238        // function allocates nothing and only calls getpid and pread, which
239        // are async-signal-safe. A failure is returned rather than ending the
240        // process, because a path with no fork runs it in the shell's own
241        // process.
242        unsafe {
243            self.pre_exec(move || pre_exec_check_pids_limits(&limits, shell_pid));
244        }
245        Ok(())
246    }
247}
248
249fn pre_exec_check_pids_limits(
250    limits: &[PidsLimit],
251    shell_pid: nix::unistd::Pid,
252) -> Result<(), std::io::Error> {
253    if nix::unistd::getpid() == shell_pid {
254        return Err(std::io::Error::from_raw_os_error(libc::ENOTSUP));
255    }
256    limits.iter().try_for_each(check_pids_limit)
257}
258
259/// Fails with `EAGAIN` when `limit`'s count is above its maximum, and with
260/// `EIO` when the count cannot be read. Both errors are built from an errno,
261/// which allocates nothing.
262fn check_pids_limit(limit: &PidsLimit) -> Result<(), std::io::Error> {
263    use std::os::unix::fs::FileExt as _;
264
265    let mut buffer = [0u8; PIDS_CURRENT_BUFFER];
266    let count = match limit.current.read_at(&mut buffer, 0) {
267        Ok(len) if len < buffer.len() => buffer.get(..len).and_then(parse_count),
268        _ => None,
269    };
270    match count {
271        Some(count) if count <= limit.max => Ok(()),
272        Some(_) => Err(std::io::Error::from_raw_os_error(libc::EAGAIN)),
273        None => Err(std::io::Error::from_raw_os_error(libc::EIO)),
274    }
275}
276
277/// Parses a cgroup count file: ASCII digits and an optional newline, with no
278/// allocation, so a forked child can call it.
279fn parse_count(bytes: &[u8]) -> Option<u64> {
280    let digits = bytes.strip_suffix(b"\n").unwrap_or(bytes);
281    if digits.is_empty() {
282        return None;
283    }
284    digits.iter().try_fold(0u64, |count, byte| {
285        let digit = byte.checked_sub(b'0').filter(|digit| *digit <= 9)?;
286        count.checked_mul(10)?.checked_add(u64::from(digit))
287    })
288}
289
290/// Whether nothing exists at `path`, such as a `cgroup.procs` file whose
291/// cgroup was removed.
292pub(crate) fn is_missing(path: &CStr) -> bool {
293    use std::os::unix::ffi::OsStrExt as _;
294
295    !std::path::Path::new(std::ffi::OsStr::from_bytes(path.to_bytes())).exists()
296}
297
298#[cfg(test)]
299mod tests {
300    use proptest::prelude::*;
301
302    use super::parse_count;
303
304    proptest! {
305        #[test]
306        fn a_count_parses_with_or_without_its_newline(count: u64) {
307            prop_assert_eq!(parse_count(count.to_string().as_bytes()), Some(count));
308            prop_assert_eq!(parse_count(format!("{count}\n").as_bytes()), Some(count));
309        }
310
311        #[test]
312        fn anything_parsed_is_the_decimal_it_reads(bytes: Vec<u8>) {
313            if let Some(count) = parse_count(&bytes) {
314                let digits = bytes.strip_suffix(b"\n").unwrap_or(&bytes);
315                prop_assert!(digits.iter().all(u8::is_ascii_digit));
316                let text = std::str::from_utf8(digits).map_err(|e| TestCaseError::fail(e.to_string()))?;
317                prop_assert_eq!(text.parse::<u64>().ok(), Some(count));
318            }
319        }
320
321        #[test]
322        fn a_count_with_anything_else_does_not_parse(
323            count: u64,
324            junk in "[^0-9]{1,4}",
325        ) {
326            prop_assume!(junk != "\n");
327            let text = format!("{count}{junk}");
328            prop_assert_eq!(parse_count(text.as_bytes()), None);
329        }
330    }
331
332    #[test]
333    fn empty_and_overflowing_counts_do_not_parse() {
334        assert_eq!(parse_count(b""), None);
335        assert_eq!(parse_count(b"\n"), None);
336        assert_eq!(parse_count(b"18446744073709551616"), None);
337        assert_eq!(parse_count(b"max\n"), None);
338    }
339}