shrub_core/sys/unix/commands.rs
1//! Command execution utilities.
2//!
3//! # Why this module is unsafe
4//!
5//! 1. Safe code cannot do it. Each step here runs in a command's child after
6//! the fork and before `exec`: taking the terminal's foreground, leading a
7//! session and taking its controlling terminal, joining a cgroup, setting
8//! the open-file and process limits and checking process counts. The
9//! standard library's only hook there is `CommandExt::pre_exec`, which is
10//! unsafe; its safe `process_group` sets a process group only, and its
11//! `setsid` is unstable. `TIOCSCTTY` has no safe wrapper, and nix's
12//! `tcsetpgrp` needs a borrowed descriptor, which for descriptor 0 means the
13//! unsafe `BorrowedFd::borrow_raw` or `std::io::stdin()`, whose first call
14//! allocates.
15//! 2. Speed is not the reason.
16//! 3. The unsafe operations are the `pre_exec` registrations in the `Command`
17//! extension traits, and the `tcsetpgrp` and `ioctl` calls in the child.
18//!
19//! Each function a child runs before `exec` allocates nothing, error paths
20//! included: its errors come from an errno. `std::process::Command` runs it
21//! after the fork, with the command's descriptors in place.
22
23use std::ffi::{CStr, CString};
24pub use std::os::unix::process::{CommandExt, ExitStatusExt};
25use std::sync::Arc;
26
27use command_fds::{CommandFdExt, FdMapping};
28
29use crate::options::PidsLimit;
30use crate::{ShellFd, error, openfiles};
31
32/// The bytes a child reads from a `pids.current` file: more than the longest
33/// count, a `u64` of 20 digits and a newline, so a full buffer means the file
34/// holds something else.
35const PIDS_CURRENT_BUFFER: usize = 32;
36
37/// Extension trait for injecting file descriptors into commands.
38pub trait CommandFdInjectionExt {
39 /// Injects the given open files as file descriptors into the command.
40 ///
41 /// # Arguments
42 ///
43 /// * `open_files` - A mapping of child file descriptors to open files.
44 ///
45 /// # Errors
46 ///
47 /// - An error if a file's descriptor cannot be duplicated.
48 /// - [`crate::error::ErrorKind::ChildCreationFailure`] if the descriptor
49 /// mappings cannot be applied to the command, for example because two
50 /// files map to the same child descriptor.
51 fn inject_fds(
52 &mut self,
53 open_files: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
54 ) -> Result<(), error::Error>;
55}
56
57impl CommandFdInjectionExt for std::process::Command {
58 fn inject_fds(
59 &mut self,
60 open_files: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
61 ) -> Result<(), error::Error> {
62 let fd_mappings: Vec<FdMapping> = open_files
63 .map(|(child_fd, open_file)| -> Result<FdMapping, error::Error> {
64 let parent_fd = open_file.try_clone_to_owned()?;
65 Ok(FdMapping {
66 child_fd,
67 parent_fd,
68 })
69 })
70 .collect::<Result<Vec<_>, _>>()?;
71
72 self.fd_mappings(fd_mappings)
73 .map_err(|_e| error::ErrorKind::ChildCreationFailure)?;
74
75 Ok(())
76 }
77}
78
79/// Extension trait for arranging for commands to take the foreground.
80pub trait CommandFgControlExt {
81 /// Arranges for the command to take the foreground when it is executed.
82 fn take_foreground(&mut self);
83 /// Arranges for the command to become a session leader when it is executed.
84 fn lead_session(&mut self);
85}
86
87impl CommandFgControlExt for std::process::Command {
88 fn take_foreground(&mut self) {
89 // SAFETY: `pre_exec` requires a closure that is safe to run in a
90 // forked child of a process that may have other threads: no
91 // allocation, no lock, no `std::env`. `pre_exec_take_foreground`
92 // calls only `getpgrp` and `tcsetpgrp`, both async-signal-safe
93 // (signal-safety(7)), and allocates nothing.
94 unsafe {
95 self.pre_exec(|| {
96 pre_exec_take_foreground();
97 Ok(())
98 });
99 }
100 }
101
102 fn lead_session(&mut self) {
103 // SAFETY: `pre_exec` requires a closure that is safe to run in a
104 // forked child of a process that may have other threads: no
105 // allocation, no lock, no `std::env`. `pre_exec_lead_session` calls
106 // only `setsid`, which POSIX lists as async-signal-safe
107 // (signal-safety(7)), and `ioctl`, which it does not list. Each
108 // target libc makes `ioctl` one system call with no lock or
109 // allocation:
110 // - glibc: https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/unix/sysv/linux/ioctl.c;hb=9e77a668c94f892f2ce0ad9ef7f6e88525852af3
111 // - musl: https://git.musl-libc.org/cgit/musl/tree/src/misc/ioctl.c?id=7813e9119eb5fe681b6750b8a033a0490bca0941
112 // - macOS: https://github.com/apple-oss-distributions/xnu/blob/xnu-12377.1.9/libsyscall/wrappers/ioctl.c
113 // The function allocates nothing, and its errors come from the
114 // errno.
115 unsafe {
116 self.pre_exec(pre_exec_lead_session);
117 }
118 }
119}
120
121/// Moves the child's process group to the foreground of the terminal on its
122/// standard input. It uses descriptor 0 directly, because the first call to
123/// `std::io::stdin()` allocates its buffer.
124fn pre_exec_take_foreground() {
125 let pgrp = nix::unistd::getpgrp();
126 // SAFETY: `tcsetpgrp` takes a descriptor number and a process group by
127 // value and reads no memory of this process. When descriptor 0 is closed
128 // or is not this session's terminal, it fails with `EBADF` or `ENOTTY`,
129 // which is ignored, as `move_self_to_foreground` ignores it.
130 let _ = unsafe { libc::tcsetpgrp(0, pgrp.as_raw()) };
131}
132
133fn pre_exec_lead_session() -> Result<(), std::io::Error> {
134 nix::unistd::setsid()?;
135
136 #[cfg(not(target_os = "macos"))]
137 let control = libc::TIOCSCTTY;
138 #[cfg(target_os = "macos")]
139 let control: u64 = libc::TIOCSCTTY.into();
140
141 // SAFETY: `TIOCSCTTY` takes its argument by value, not through a
142 // pointer, so the call reads and writes no memory of this process. On
143 // a descriptor 0 that is not a terminal it fails with an errno.
144 let result = unsafe { libc::ioctl(0, control, 0) };
145 if result != 0 {
146 return Err(std::io::Error::last_os_error());
147 }
148
149 Ok(())
150}
151
152/// Extension trait for moving a command into a cgroup before it runs.
153pub trait CommandCgroupExt {
154 /// Arranges for the command to write `0` to `cgroup_procs`, a cgroup v2's
155 /// `cgroup.procs` file, after it is forked and before it runs its program,
156 /// which moves it into that cgroup.
157 ///
158 /// # Errors
159 ///
160 /// None here: a child that cannot open or write the file fails its spawn.
161 fn join_cgroup(&mut self, cgroup_procs: Arc<CString>) -> Result<(), error::Error>;
162}
163
164impl CommandCgroupExt for std::process::Command {
165 fn join_cgroup(&mut self, cgroup_procs: Arc<CString>) -> Result<(), error::Error> {
166 // SAFETY: `pre_exec` requires a closure that is safe to run in a
167 // forked child of a process that may have other threads: no
168 // allocation, no lock, no `std::env`. The closure reads the captured
169 // path without cloning its `Arc`, and `pre_exec_join_cgroup` calls
170 // only `open`, `write` and `close`, all async-signal-safe
171 // (signal-safety(7)); it allocates nothing, and its errors come from
172 // the errno.
173 unsafe {
174 self.pre_exec(move || pre_exec_join_cgroup(&cgroup_procs));
175 }
176 Ok(())
177 }
178}
179
180fn pre_exec_join_cgroup(cgroup_procs: &CStr) -> Result<(), std::io::Error> {
181 use nix::fcntl::{OFlag, open};
182 use nix::sys::stat::Mode;
183
184 let file = open(
185 cgroup_procs,
186 OFlag::O_WRONLY | OFlag::O_CLOEXEC,
187 Mode::empty(),
188 )?;
189 nix::unistd::write(&file, b"0")?;
190 Ok(())
191}
192
193/// Extension trait for setting a command's open-file limit before it runs.
194pub trait CommandOpenFileLimitExt {
195 /// Arranges for the command to set its soft `RLIMIT_NOFILE` to `limit`,
196 /// or to its hard limit if that is lower, after it is forked and before
197 /// it runs its program.
198 ///
199 /// # Errors
200 ///
201 /// None here: a child that cannot set the limit fails its spawn.
202 fn set_open_file_limit(&mut self, limit: u64) -> Result<(), error::Error>;
203}
204
205impl CommandOpenFileLimitExt for std::process::Command {
206 fn set_open_file_limit(&mut self, limit: u64) -> Result<(), error::Error> {
207 // SAFETY: `pre_exec` requires a closure that is safe to run in a
208 // forked child of a process that may have other threads: no
209 // allocation, no lock, no `std::env`. `pre_exec_set_open_file_limit`
210 // calls only `getrlimit` and `setrlimit`, which POSIX does not list
211 // as async-signal-safe (signal-safety(7)). Each target libc makes
212 // each one system call with no lock or allocation:
213 // - glibc: `prlimit64`, https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/unix/sysv/linux/getrlimit64.c;hb=9e77a668c94f892f2ce0ad9ef7f6e88525852af3
214 // and https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/unix/sysv/linux/setrlimit64.c;hb=9e77a668c94f892f2ce0ad9ef7f6e88525852af3
215 // - musl: `prlimit64`, https://git.musl-libc.org/cgit/musl/tree/src/misc/getrlimit.c?id=7813e9119eb5fe681b6750b8a033a0490bca0941
216 // and https://git.musl-libc.org/cgit/musl/tree/src/misc/setrlimit.c?id=7813e9119eb5fe681b6750b8a033a0490bca0941;
217 // its locking `__synccall` fallback runs only on `ENOSYS`, before
218 // Linux 2.6.36, older than any kernel Rust supports.
219 // - macOS: the `__getrlimit` and `__setrlimit` system call stubs, after
220 // setting `_RLIMIT_POSIX_FLAG`, https://github.com/apple-oss-distributions/xnu/blob/xnu-12377.1.9/libsyscall/wrappers/unix03/getrlimit.c
221 // and https://github.com/apple-oss-distributions/xnu/blob/xnu-12377.1.9/libsyscall/wrappers/unix03/setrlimit.c
222 // The function allocates nothing, and its errors come from the
223 // errno.
224 unsafe {
225 self.pre_exec(move || pre_exec_set_open_file_limit(limit));
226 }
227 Ok(())
228 }
229}
230
231/// The highest soft open-file limit macOS always accepts: `OPEN_MAX` in
232/// `<sys/syslimits.h>`. Its `setrlimit(2)` page says to use
233/// `min(OPEN_MAX, rlim_max)` where a higher soft limit fails with `EINVAL`.
234#[cfg(target_os = "macos")]
235const MACOS_OPEN_MAX: libc::rlim_t = 10240;
236
237fn pre_exec_set_open_file_limit(limit: u64) -> Result<(), std::io::Error> {
238 use nix::sys::resource::{Resource, getrlimit, setrlimit};
239
240 let (_, hard) = getrlimit(Resource::RLIMIT_NOFILE)?;
241 // `rlim_t` is `u64` on Linux and macOS but `i64` on FreeBSD; a limit
242 // past its range sets the hard limit, as any limit above it does.
243 #[allow(
244 clippy::unnecessary_fallible_conversions,
245 reason = "rlim_t is not u64 on every Unix"
246 )]
247 let soft = libc::rlim_t::try_from(limit).unwrap_or(hard).min(hard);
248 let result = setrlimit(Resource::RLIMIT_NOFILE, soft, hard);
249 #[cfg(target_os = "macos")]
250 let result = match result {
251 Err(nix::errno::Errno::EINVAL) => {
252 setrlimit(Resource::RLIMIT_NOFILE, soft.min(MACOS_OPEN_MAX), hard)
253 }
254 other => other,
255 };
256 Ok(result?)
257}
258
259/// Extension trait for setting a command's process limit before it runs.
260pub trait CommandProcessLimitExt {
261 /// Arranges for the command to set its soft and hard `RLIMIT_NPROC` to
262 /// `limit`, or to its hard limit if that is lower, after it is forked
263 /// and before it runs its program. Only Linux sets it.
264 ///
265 /// # Errors
266 ///
267 /// [`error::ErrorKind::NotSupportedOnThisPlatform`] on a Unix other than
268 /// Linux, where `nix` names no `RLIMIT_NPROC` for every target; on
269 /// Linux, none here: a child that cannot set the limit fails its spawn.
270 fn set_process_limit(&mut self, limit: u64) -> Result<(), error::Error>;
271}
272
273impl CommandProcessLimitExt for std::process::Command {
274 #[cfg(target_os = "linux")]
275 fn set_process_limit(&mut self, limit: u64) -> Result<(), error::Error> {
276 // SAFETY: `pre_exec` requires a closure that is safe to run in a
277 // forked child of a process that may have other threads: no
278 // allocation, no lock, no `std::env`. `pre_exec_set_process_limit`
279 // calls only `getrlimit` and `setrlimit`, which POSIX does not list
280 // as async-signal-safe (signal-safety(7)). Each Linux libc makes
281 // each one system call with no lock or allocation:
282 // - glibc: `prlimit64`, https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/unix/sysv/linux/getrlimit64.c;hb=9e77a668c94f892f2ce0ad9ef7f6e88525852af3
283 // and https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/unix/sysv/linux/setrlimit64.c;hb=9e77a668c94f892f2ce0ad9ef7f6e88525852af3
284 // - musl: `prlimit64`, https://git.musl-libc.org/cgit/musl/tree/src/misc/getrlimit.c?id=7813e9119eb5fe681b6750b8a033a0490bca0941
285 // and https://git.musl-libc.org/cgit/musl/tree/src/misc/setrlimit.c?id=7813e9119eb5fe681b6750b8a033a0490bca0941;
286 // its locking `__synccall` fallback runs only on `ENOSYS`, before
287 // Linux 2.6.36, older than any kernel Rust supports.
288 // The function allocates nothing, and its errors come from the
289 // errno.
290 unsafe {
291 self.pre_exec(move || pre_exec_set_process_limit(limit));
292 }
293 Ok(())
294 }
295
296 #[cfg(not(target_os = "linux"))]
297 fn set_process_limit(&mut self, _limit: u64) -> Result<(), error::Error> {
298 Err(error::ErrorKind::NotSupportedOnThisPlatform("setting a process limit").into())
299 }
300}
301
302/// Sets this process's soft and hard `RLIMIT_NPROC` to `limit`, or to its
303/// hard limit if that is lower, in a child between the fork and `exec`.
304#[cfg(target_os = "linux")]
305fn pre_exec_set_process_limit(limit: u64) -> Result<(), std::io::Error> {
306 use nix::sys::resource::{Resource, getrlimit, setrlimit};
307
308 let (_, hard) = getrlimit(Resource::RLIMIT_NPROC)?;
309 // `rlim_t` is `u64` on 64-bit Linux and `u32` on some 32-bit targets; a
310 // limit past its range keeps the hard limit, as any limit above it does.
311 #[allow(
312 clippy::unnecessary_fallible_conversions,
313 reason = "rlim_t is not u64 on every Linux target"
314 )]
315 let limit = libc::rlim_t::try_from(limit).unwrap_or(hard).min(hard);
316 Ok(setrlimit(Resource::RLIMIT_NPROC, limit, limit)?)
317}
318
319/// Extension trait for checking process counts before a command runs.
320pub trait CommandPidsLimitsExt {
321 /// Arranges for the command, after it is forked and before it runs its
322 /// program, to read each limit's count and to fail its spawn when a count
323 /// is above its limit (`EAGAIN`, the error `fork` returns at `pids.max`)
324 /// or cannot be read (`EIO`).
325 ///
326 /// In the process that registered it, as on a path that runs the
327 /// command's steps with no fork, the check fails with `ENOTSUP` without
328 /// reading a count: there a count would include every thread of that
329 /// process, and the `exec` builtin refuses such a command before it
330 /// composes it.
331 ///
332 /// # Errors
333 ///
334 /// None here: a child whose check fails fails its spawn.
335 fn check_pids_limits(&mut self, limits: Arc<[PidsLimit]>) -> Result<(), error::Error>;
336}
337
338impl CommandPidsLimitsExt for std::process::Command {
339 fn check_pids_limits(&mut self, limits: Arc<[PidsLimit]>) -> Result<(), error::Error> {
340 // The process that registers the check: the function runs in it, not
341 // in a child, only on the exec builtin's path.
342 let shell_pid = nix::unistd::getpid();
343 // SAFETY: `pre_exec` requires a closure that is safe to run in a
344 // forked child of a process that may have other threads: no
345 // allocation, no lock, no `std::env`. The closure reads the captured
346 // limits without cloning their `Arc`, and `pre_exec_check_pids_limits`
347 // calls only `getpid`, which POSIX lists as async-signal-safe
348 // (signal-safety(7)), and `pread`, which it does not list. Each
349 // target libc makes `pread` one system call with no lock or
350 // allocation:
351 // - glibc: `pread64`, https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/unix/sysv/linux/pread64.c;hb=9e77a668c94f892f2ce0ad9ef7f6e88525852af3
352 // - musl: https://git.musl-libc.org/cgit/musl/tree/src/unistd/pread.c?id=7813e9119eb5fe681b6750b8a033a0490bca0941
353 // - macOS: a generated stub that makes system call 153, https://github.com/apple-oss-distributions/xnu/blob/xnu-12377.1.9/bsd/kern/syscalls.master
354 // The function allocates nothing, and its errors come from an
355 // errno. A failure is returned rather than ending the process,
356 // because a path with no fork runs it in the shell's own process.
357 unsafe {
358 self.pre_exec(move || pre_exec_check_pids_limits(&limits, shell_pid));
359 }
360 Ok(())
361 }
362}
363
364fn pre_exec_check_pids_limits(
365 limits: &[PidsLimit],
366 shell_pid: nix::unistd::Pid,
367) -> Result<(), std::io::Error> {
368 if nix::unistd::getpid() == shell_pid {
369 return Err(std::io::Error::from_raw_os_error(libc::ENOTSUP));
370 }
371 limits.iter().try_for_each(check_pids_limit)
372}
373
374/// Fails with `EAGAIN` when `limit`'s count is above its maximum, and with
375/// `EIO` when the count cannot be read. Both errors are built from an errno,
376/// which allocates nothing.
377fn check_pids_limit(limit: &PidsLimit) -> Result<(), std::io::Error> {
378 use std::os::unix::fs::FileExt as _;
379
380 let mut buffer = [0u8; PIDS_CURRENT_BUFFER];
381 let count = match limit.current.read_at(&mut buffer, 0) {
382 Ok(len) if len < buffer.len() => buffer.get(..len).and_then(parse_count),
383 _ => None,
384 };
385 match count {
386 Some(count) if count <= limit.max => Ok(()),
387 Some(_) => Err(std::io::Error::from_raw_os_error(libc::EAGAIN)),
388 None => Err(std::io::Error::from_raw_os_error(libc::EIO)),
389 }
390}
391
392/// Parses a cgroup count file: ASCII digits and an optional newline, with no
393/// allocation, so a forked child can call it.
394fn parse_count(bytes: &[u8]) -> Option<u64> {
395 let digits = bytes.strip_suffix(b"\n").unwrap_or(bytes);
396 if digits.is_empty() {
397 return None;
398 }
399 digits.iter().try_fold(0u64, |count, byte| {
400 let digit = byte.checked_sub(b'0').filter(|digit| *digit <= 9)?;
401 count.checked_mul(10)?.checked_add(u64::from(digit))
402 })
403}
404
405/// Whether nothing exists at `path`, such as a `cgroup.procs` file whose
406/// cgroup was removed.
407pub(crate) fn is_missing(path: &CStr) -> bool {
408 use std::os::unix::ffi::OsStrExt as _;
409
410 !std::path::Path::new(std::ffi::OsStr::from_bytes(path.to_bytes())).exists()
411}
412
413#[cfg(test)]
414mod tests {
415 use proptest::prelude::*;
416
417 use super::parse_count;
418
419 proptest! {
420 #[test]
421 fn a_count_parses_with_or_without_its_newline(count: u64) {
422 prop_assert_eq!(parse_count(count.to_string().as_bytes()), Some(count));
423 prop_assert_eq!(parse_count(format!("{count}\n").as_bytes()), Some(count));
424 }
425
426 #[test]
427 fn anything_parsed_is_the_decimal_it_reads(bytes: Vec<u8>) {
428 if let Some(count) = parse_count(&bytes) {
429 let digits = bytes.strip_suffix(b"\n").unwrap_or(&bytes);
430 prop_assert!(digits.iter().all(u8::is_ascii_digit));
431 let text = std::str::from_utf8(digits).map_err(|e| TestCaseError::fail(e.to_string()))?;
432 prop_assert_eq!(text.parse::<u64>().ok(), Some(count));
433 }
434 }
435
436 #[test]
437 fn a_count_with_anything_else_does_not_parse(
438 count: u64,
439 junk in "[^0-9]{1,4}",
440 ) {
441 prop_assume!(junk != "\n");
442 let text = format!("{count}{junk}");
443 prop_assert_eq!(parse_count(text.as_bytes()), None);
444 }
445 }
446
447 #[test]
448 fn empty_and_overflowing_counts_do_not_parse() {
449 assert_eq!(parse_count(b""), None);
450 assert_eq!(parse_count(b"\n"), None);
451 assert_eq!(parse_count(b"18446744073709551616"), None);
452 assert_eq!(parse_count(b"max\n"), None);
453 }
454}