zsh/extensions/lowfd.rs
1//! Keep the shell's own file descriptors out of the user's fd space.
2//!
3//! zsh routes every internal open through `movefd()` (Src/utils.c:1990), which
4//! relocates the descriptor to >= 10 with `fcntl(fd, F_DUPFD, 10)` and records
5//! it as `FDT_INTERNAL`. The point is that fds 0-9 belong to the SCRIPT: `exec
6//! 3>out`, `read -u 4`, `print -u 3` and friends address them by number, and a
7//! shell that parks its own state there is squatting on the user's namespace.
8//!
9//! zshrs was squatting. A `-c` run held:
10//!
11//! fd 3 -> ~/.zshrs/zshrs.log
12//! fd 4 -> ~/.zshrs/zshrs_history.db (sqlite)
13//! fd 5,6,7 -> ~/.zshrs/compsys.db + -wal + -shm
14//!
15//! so `print -u 3 -r -- x` appended `x` to the shell's own LOG and reported
16//! success (zsh: `bad file number: 3`, status 1), and `exec 3>myfile` would
17//! dup2 over the log handle — `exec 4>myfile` over the live sqlite handle.
18//!
19//! `movefd` only works for a descriptor we own and can close. sqlite caches the
20//! fd number inside the connection, so it cannot be relocated after the fact.
21//! The fix therefore has to make the open LAND high: hold every low fd for the
22//! duration of the open, so the kernel's "lowest free descriptor" rule hands out
23//! >= 10, then release. That covers lazily-opened descriptors (sqlite's WAL and
24//! SHM appear on first use) as long as the open happens inside the guard.
25
26use std::os::unix::io::RawFd;
27
28/// The first descriptor the shell may use for itself — everything below belongs
29/// to the script. Matches C's `movefd` threshold (Src/utils.c:1994, `F_DUPFD, 10`).
30const FIRST_INTERNAL_FD: RawFd = 10;
31
32/// Occupies every currently-free descriptor below [`FIRST_INTERNAL_FD`] so that
33/// opens performed while it is alive are pushed above the user's fd range.
34/// Releases them on drop.
35///
36/// Descriptors that are ALREADY open are left alone — they may be the user's
37/// (`zshrs 3>&1 -c '…'` is legal), and stealing them would be the very bug this
38/// exists to prevent.
39pub struct LowFdGuard {
40 held: Vec<RawFd>,
41}
42
43impl LowFdGuard {
44 /// Reserve the free low descriptors. Cheap: at most seven `dup2` calls onto
45 /// `/dev/null`, and only for slots that are actually free.
46 pub fn new() -> Self {
47 let mut held = Vec::new();
48 // SAFETY: plain fd syscalls; every descriptor opened here is recorded and
49 // closed in `drop`.
50 unsafe {
51 let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_RDWR | libc::O_CLOEXEC);
52 if devnull < 0 {
53 return Self { held };
54 }
55 // The /dev/null handle itself lands on the LOWEST free descriptor —
56 // i.e. inside the very range being reserved. Left there, the loop
57 // below would see that slot as "already open", skip it, and then free
58 // it again on drop, so the next open would land right back on it.
59 // Move the handle above the range first (this is `movefd` in miniature,
60 // c:Src/utils.c:1994 `fcntl(fd, F_DUPFD, 10)`).
61 let devnull = {
62 let hi = libc::fcntl(devnull, libc::F_DUPFD, FIRST_INTERNAL_FD);
63 if hi >= 0 {
64 libc::close(devnull);
65 hi
66 } else {
67 devnull
68 }
69 };
70 for fd in 3..FIRST_INTERNAL_FD {
71 // Only claim descriptors that are closed. F_GETFD on a live fd
72 // succeeds, and that one is not ours to take.
73 if libc::fcntl(fd, libc::F_GETFD) != -1 {
74 continue;
75 }
76 if libc::dup2(devnull, fd) == fd {
77 held.push(fd);
78 }
79 }
80 libc::close(devnull);
81 }
82 Self { held }
83 }
84}
85
86impl Drop for LowFdGuard {
87 fn drop(&mut self) {
88 for &fd in &self.held {
89 // SAFETY: only descriptors this guard installed are closed.
90 unsafe {
91 libc::close(fd);
92 }
93 }
94 }
95}
96
97impl Default for LowFdGuard {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103/// Run `f` with the low descriptors reserved, so anything it opens lands at or
104/// above [`FIRST_INTERNAL_FD`].
105pub fn with_high_fds<T>(f: impl FnOnce() -> T) -> T {
106 let _guard = LowFdGuard::new();
107 f()
108}