Skip to main content

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:1975), 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//! ```text
12//!     fd 3 -> ~/.zshrs/zshrs.log
13//!     fd 4 -> ~/.zshrs/zshrs_history.db          (sqlite)
14//!     fd 5,6,7 -> ~/.zshrs/compsys.db + -wal + -shm
15//! ```
16//!
17//! so `print -u 3 -r -- x` appended `x` to the shell's own LOG and reported
18//! success (zsh: `bad file number: 3`, status 1), and `exec 3>myfile` would
19//! dup2 over the log handle — `exec 4>myfile` over the live sqlite handle.
20//!
21//! `movefd` only works for a descriptor we own and can close. sqlite caches the
22//! fd number inside the connection, so it cannot be relocated after the fact.
23//! The fix therefore has to make the open LAND high: hold every low fd for the
24//! duration of the open, so the kernel's "lowest free descriptor" rule hands out
25//! >= 10, then release. That covers lazily-opened descriptors (sqlite's WAL and
26//! SHM appear on first use) as long as the open happens inside the guard.
27//!
28//! # Concurrency and fork
29//!
30//! Unlike C zsh, zshrs runs the guard off the main thread: `compinit` ships its
31//! cache rebuild to the worker pool, and that task calls
32//! [`crate::compsys::cache::CompsysCache::open`] while the main thread is still
33//! sourcing the user's rc files. So the guard MUST be safe against concurrent
34//! opens in other threads.
35//!
36//! It is made safe without any lock. Every descriptor the guard owns is obtained
37//! from `fcntl(F_DUPFD_CLOEXEC)`, which allocates the lowest free descriptor as a
38//! single atomic kernel operation — two threads racing get two distinct fds, and
39//! the guard never names, and therefore never clobbers, a descriptor somebody
40//! else owns.
41//!
42//! Lock-freedom is not an optimization here, it is a correctness requirement:
43//! `fork(2)` is called directly in `fusevm_bridge.rs` and `ported/exec.rs`, and
44//! only the forking thread survives into the child. A mutex held by any other
45//! thread at that moment is inherited permanently locked, so a guard that
46//! serialized on one would deadlock the first `LowFdGuard::new()` in the child.
47//! Raw fd syscalls have no such hazard: the child inherits the fd table and the
48//! guard behaves identically there.
49//!
50//! `F_DUPFD_CLOEXEC` also means the reserved slots do not leak through an
51//! `exec(2)` that races the guard — the old `dup2` path cleared `FD_CLOEXEC` on
52//! its targets, so a child could start life with `/dev/null` parked on fds 3-9.
53
54use std::os::unix::io::RawFd;
55
56/// The first descriptor the shell may use for itself — everything below belongs
57/// to the script. Matches C's `movefd` threshold (Src/utils.c:1980, `F_DUPFD, 10`).
58const FIRST_INTERNAL_FD: RawFd = 10;
59
60/// The first descriptor the script can name. 0/1/2 are stdin/stdout/stderr and
61/// are never part of the reservation — `exec 3>out` starts at 3.
62const FIRST_SCRIPT_FD: RawFd = 3;
63
64/// Where the shell's EXTENSION descriptors (the log, the SQLite databases)
65/// land. C zsh has none of these; the descriptors it does keep for itself
66/// (SHIN, c:Src/init.c:1566, and each `movefd`) sit just above
67/// [`FIRST_INTERNAL_FD`], and `{var}` redirections take the lowest free
68/// descriptor from 10 up (c:Src/exec.c:2404 `movefd`). With the extensions
69/// parked at 10-13, `exec {fd}>file` got 14 where zsh gives 11.
70///
71/// !!! WARNING: RUST-ONLY CONSTANT — NO C COUNTERPART !!! The guard holds
72/// everything below this floor during an extension open, so the kernel's
73/// lowest-free-descriptor rule hands out >= 50.
74pub const EXTENSION_FD_FLOOR: RawFd = 50;
75
76/// How many slots the guard can hold at most: everything from the first
77/// script descriptor up to [`EXTENSION_FD_FLOOR`].
78const SCRIPT_FD_SLOTS: usize = (EXTENSION_FD_FLOOR - FIRST_SCRIPT_FD) as usize;
79
80/// Upper bound of the sweep that registers freshly-landed internal
81/// descriptors in the fdtable.
82///
83/// An internal open lands on the lowest descriptor free at the time, so
84/// with the script range held it lands just above [`FIRST_INTERNAL_FD`];
85/// SQLite adds at most two more per database (`-wal`, `-shm`). 64 is far
86/// past anything the shell opens for itself. A descriptor that somehow
87/// lands above it simply stays unregistered — the behaviour before this
88/// sweep existed — rather than costing a longer scan on every open.
89const FD_SCAN_LIMIT: RawFd = 96;
90
91/// Is `fd` open in this process?
92fn fd_is_open(fd: RawFd) -> bool {
93    // SAFETY: F_GETFD only interrogates the descriptor table.
94    unsafe { libc::fcntl(fd, libc::F_GETFD) != -1 }
95}
96
97/// The descriptors at or above [`FIRST_INTERNAL_FD`] that this process
98/// was STARTED with.
99///
100/// They belong to whoever spawned us — `zshrs 11>&1 -c …` is legal — and
101/// c:Src/exec.c:3886-3891 is explicit that the shell must keep its hands
102/// off a descriptor it did not open: "If the requested fd is >
103/// max_zsh_fd, the shell doesn't know about it. Just assume the user
104/// knows what they're doing." Recording them once lets the sweep below
105/// register only descriptors that appeared afterwards, which are the
106/// shell's own.
107///
108/// Captured on the first [`LowFdGuard::new`], which by construction runs
109/// before the first internal open (that open is what the guard exists to
110/// push upward).
111fn inherited_fds() -> &'static [bool] {
112    static INHERITED: std::sync::OnceLock<Vec<bool>> = std::sync::OnceLock::new();
113    INHERITED.get_or_init(|| {
114        (0..FD_SCAN_LIMIT)
115            .map(|fd| fd >= FIRST_INTERNAL_FD && fd_is_open(fd))
116            .collect()
117    })
118}
119
120/// Occupies every currently-free descriptor below [`FIRST_INTERNAL_FD`] so that
121/// opens performed while it is alive are pushed above the user's fd range.
122/// Releases them on drop.
123///
124/// Descriptors that are ALREADY open are left alone — they may be the user's
125/// (`zshrs 3>&1 -c '…'` is legal), and stealing them would be the very bug this
126/// exists to prevent.
127///
128/// # Thread safety
129///
130/// Claiming is done exclusively through `fcntl(F_DUPFD_CLOEXEC)`, which asks the
131/// kernel for the *lowest free* descriptor and allocates it in one atomic step.
132/// The guard therefore only ever owns descriptors the kernel just handed it, and
133/// never names a target descriptor.
134///
135/// The previous implementation probed each slot with `fcntl(fd, F_GETFD)` and
136/// then `dup2`'d onto it. Those are two syscalls with a gap in between, and
137/// `dup2` *silently closes* whatever occupies its target. Under the interactive
138/// boot — where `compinit` ships its cache rebuild to a `zshrs-worker-N` thread
139/// (`ext_builtins.rs`, `worker_pool.submit`) while the main thread is still
140/// sourcing the user's rc files — a concurrent `File::open` could land on a slot
141/// this guard had already probed as free. The `dup2` then closed a live
142/// `OwnedFd` out from under its owner, and `drop` on that owner tripped std's
143/// `debug_assert_fd_is_open`, aborting with
144/// `IO Safety violation: owned file descriptor already closed`.
145pub struct LowFdGuard {
146    held: Vec<RawFd>,
147}
148
149impl LowFdGuard {
150    /// Reserve the free script descriptors. Cheap: one `open` of `/dev/null`
151    /// plus at most [`SCRIPT_FD_SLOTS`] `fcntl` calls, and only for slots that
152    /// are actually free.
153    pub fn new() -> Self {
154        // Before anything is opened, so the snapshot is of the parent's
155        // descriptors only. Cheap after the first call (a `OnceLock` read).
156        let _ = inherited_fds();
157        let mut held = Vec::with_capacity(SCRIPT_FD_SLOTS);
158        // SAFETY: plain fd syscalls; every descriptor here came from this
159        // thread's own `open`/`fcntl` and is closed exactly once — either at the
160        // end of this function or in `drop`, never both.
161        unsafe {
162            let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_RDWR | libc::O_CLOEXEC);
163            if devnull < 0 {
164                return Self { held };
165            }
166            // `open` also allocates the lowest free descriptor, so the /dev/null
167            // handle may itself land inside the range being reserved. When it
168            // does it counts as one of the reserved slots and is released with
169            // the rest; otherwise it is a plain temporary closed below.
170            let devnull_is_held = (FIRST_SCRIPT_FD..EXTENSION_FD_FLOOR).contains(&devnull);
171            if devnull_is_held {
172                held.push(devnull);
173            }
174            // Duplicate repeatedly. Each `F_DUPFD_CLOEXEC` returns the lowest
175            // free descriptor at or above `FIRST_SCRIPT_FD`, so successive calls
176            // walk up through the free slots; the first result at or above
177            // `FIRST_INTERNAL_FD` proves the whole script range is now spoken for
178            // — by this guard or by a pre-existing owner — and ends the loop.
179            // `held.len()` bounds the iteration count even if another thread
180            // frees a low slot mid-walk.
181            //
182            // This is `movefd` used as a claim rather than a move
183            // (c:Src/utils.c:1980 `fcntl(fd, F_DUPFD, 10)`).
184            while held.len() < SCRIPT_FD_SLOTS {
185                let fd = libc::fcntl(devnull, libc::F_DUPFD_CLOEXEC, FIRST_SCRIPT_FD);
186                if fd < 0 {
187                    break;
188                }
189                if fd >= EXTENSION_FD_FLOOR {
190                    libc::close(fd);
191                    break;
192                }
193                held.push(fd);
194            }
195            if !devnull_is_held {
196                libc::close(devnull);
197            }
198        }
199        // Off by default (`trace`), and the one call this module cares about —
200        // "which thread reserved which slots" — is the only way to tell a
201        // main-thread reservation from a worker-pool one after the fact.
202        tracing::trace!(?held, "lowfd: reserved script descriptors");
203        Self { held }
204    }
205}
206
207impl Drop for LowFdGuard {
208    fn drop(&mut self) {
209        for &fd in &self.held {
210            // SAFETY: only descriptors this guard installed are closed.
211            unsafe {
212                libc::close(fd);
213            }
214        }
215    }
216}
217
218/// Mark every descriptor the shell has just opened for itself as
219/// `FDT_INTERNAL`.
220///
221/// c:Src/utils.c:2007-2010 — `movefd` ends with `check_fd_table(fd);
222/// fdtable[fd] = FDT_INTERNAL;`, so in C every descriptor the shell
223/// relocates for its own use is registered at the moment it is
224/// relocated. That registration is what the rest of the shell reads:
225/// c:Src/exec.c:3830-3835 refuses `exec N>&-` on an internal descriptor
226/// ("file descriptor %d used by shell, not closed"), and
227/// c:Src/exec.c:3884-3897 fails `>&N` / `<&N` with `EBADF` when
228/// `fdtable[N]` is neither `FDT_UNUSED` nor `FDT_EXTERNAL`. Both of
229/// those gates are ported; both were dead for the shell's OWN
230/// descriptors.
231///
232/// zshrs does not reach its internal descriptors through `movefd`. The
233/// log file, the history database and the compsys database are opened by
234/// `tracing-appender` and by SQLite, which hand back a `File` / a
235/// `Connection` and never expose the descriptor — so there is no
236/// `movefd` call site at which to register them, and they landed with
237/// `fdtable` still reading `FDT_UNUSED`. The shell's own log sat at
238/// fd 10 and its history database at fd 11, unregistered, and every gate
239/// above waved them through: `exec 11>&-` closed the live SQLite handle
240/// and `exec 3>&11` duplicated it.
241///
242/// The guard is the choke point every one of those opens passes through,
243/// so the registration happens here instead: whatever is open above
244/// [`FIRST_INTERNAL_FD`] that we did not inherit and that nothing has
245/// claimed is, by elimination, a descriptor the shell opened for itself.
246///
247/// A slot that is already claimed is never overwritten — `FDT_EXTERNAL`
248/// (a `{varid}>` descriptor, c:Src/exec.c:2409), `FDT_MODULE`,
249/// `FDT_PROC_SUBST` and the rest keep their meaning. And a user
250/// redirection racing this sweep from another thread self-corrects: the
251/// redirection sets its slot to `FDT_EXTERNAL` unconditionally after the
252/// open, so a momentary `FDT_INTERNAL` here is overwritten by the
253/// classification that matters.
254/// # Why this is not in `LowFdGuard::drop`
255///
256/// `fdtable_get`/`fdtable_set` take a `Mutex` (utils.rs:11483). This
257/// module's header spells out why a lock must never be on the guard's
258/// path: `fork(2)` is called directly in `fusevm_bridge.rs` and
259/// `ported/exec.rs`, only the forking thread survives into the child,
260/// and a mutex held by any other thread at that moment is inherited
261/// permanently locked — so the child's first guard would deadlock. Doing
262/// the sweep in `drop` did exactly that, and
263/// `guard_still_works_in_a_fork_child_while_other_threads_hold_it`
264/// caught it.
265///
266/// So the sweep is an explicit step on the open path instead
267/// ([`with_high_fds`]), which only the shell's own database and log
268/// opens take, and which no fork child runs. `LowFdGuard` itself stays
269/// pure raw-syscall and fork-safe.
270pub fn register_internal_fds() {
271    let inherited = inherited_fds();
272    for fd in FIRST_INTERNAL_FD..FD_SCAN_LIMIT {
273        if inherited[fd as usize] || !fd_is_open(fd) {
274            continue;
275        }
276        if crate::ported::utils::fdtable_get(fd) != crate::ported::zsh_h::FDT_UNUSED {
277            continue; // already classified — EXTERNAL, MODULE, PROC_SUBST, …
278        }
279        crate::ported::utils::check_fd_table(fd); // c:2008
280        crate::ported::utils::fdtable_set(fd, crate::ported::zsh_h::FDT_INTERNAL); // c:2009
281        tracing::debug!(fd, "lowfd: registered shell-internal descriptor");
282    }
283}
284
285/// Give every descriptor the fdtable records as the shell's own
286/// (`FDT_INTERNAL`, `FDT_XTRACE`) close-on-exec, right before an external
287/// command is spawned.
288///
289/// !!! WARNING: RUST-ONLY HELPER !!! C closes them in the forked child just
290/// before execve: c:Src/exec.c:4352 `closem(FDT_INTERNAL, 0)` and c:786
291/// `closem(FDT_XTRACE, 0)` (closem, c:4601-4618, spares FDT_EXTERNAL and
292/// FDT_PROC_SUBST, the script's own). zshrs spawns without a child-side hook
293/// (posix_spawn), so the flag does the closing. An internal descriptor is
294/// never meant to reach an exec'd program, so the flag is never cleared;
295/// SHIN (fd 10) and a redirection's saved copies used to show up in `ls
296/// /dev/fd`.
297pub fn cloexec_internal_fds() {
298    let max = crate::ported::utils::MAX_ZSH_FD.load(std::sync::atomic::Ordering::Relaxed);
299    for fd in FIRST_INTERNAL_FD..=max {
300        let kind = crate::ported::utils::fdtable_get(fd) & crate::ported::zsh_h::FDT_TYPE_MASK;
301        if kind != crate::ported::zsh_h::FDT_INTERNAL && kind != crate::ported::zsh_h::FDT_XTRACE {
302            continue;
303        }
304        let fl = unsafe { libc::fcntl(fd, libc::F_GETFD) };
305        if fl >= 0 && fl & libc::FD_CLOEXEC == 0 {
306            unsafe { libc::fcntl(fd, libc::F_SETFD, fl | libc::FD_CLOEXEC) };
307        }
308    }
309}
310
311/// Move a descriptor that exists only because zshrs runs a subshell body in
312/// process — the `$( … )` capture pipe's read end and the parent's saved
313/// stdout/stderr, `( … )`'s entry-directory handle, SubshFdFrame's copies —
314/// to [`EXTENSION_FD_FLOOR`] and up, close-on-exec, and record it
315/// `FDT_INTERNAL`. Returns the new descriptor (or `fd` unchanged if it is
316/// negative or the move fails).
317///
318/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!! C forks the body:
319/// getoutput's child closes the pipe's read end (c:Src/exec.c:4839) and
320/// saves nothing, so from 10 up the body sees only the parent's own
321/// descriptors, and `{var}` (movefd, c:Src/exec.c:2404) takes the first free
322/// one: `$(exec {v}>/dev/null; print $v)` prints 11. Parked at 10+ these
323/// in-process stand-ins pushed it to 15.
324pub fn movefd_past_script(fd: RawFd) -> RawFd {
325    if fd < 0 {
326        return fd;
327    }
328    let moved = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, EXTENSION_FD_FLOOR) };
329    if moved < 0 {
330        return fd;
331    }
332    if crate::ported::utils::fdtable_get(fd) != crate::ported::zsh_h::FDT_UNUSED {
333        crate::ported::utils::zclose(fd); // clears the old slot's mark
334    } else {
335        unsafe { libc::close(fd) };
336    }
337    crate::ported::utils::check_fd_table(moved);
338    crate::ported::utils::fdtable_set(moved, crate::ported::zsh_h::FDT_INTERNAL);
339    moved
340}
341
342impl Default for LowFdGuard {
343    fn default() -> Self {
344        Self::new()
345    }
346}
347
348/// Run `f` with the low descriptors reserved, so anything it opens lands at or
349/// above [`FIRST_INTERNAL_FD`].
350pub fn with_high_fds<T>(f: impl FnOnce() -> T) -> T {
351    let out = {
352        let _guard = LowFdGuard::new();
353        f()
354    };
355    // The guard is released above, so anything `f` opened is now at its
356    // final descriptor and can be registered as the shell's own.
357    register_internal_fds();
358    out
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    /// A descriptor the shell opens for itself is registered as
366    /// `FDT_INTERNAL`, which is what makes c:Src/exec.c:3830-3835 refuse
367    /// `exec N>&-` on it and c:Src/exec.c:3884-3897 fail `>&N` with
368    /// `EBADF`. Both gates are ported; both read the fdtable, and the
369    /// shell's own descriptors were never in it.
370    #[test]
371    fn an_internal_open_is_registered_in_the_fdtable() {
372        // Same serialisation the other guard tests take: these tests share
373        // one process, and a guard in flight moves descriptors 3-9 under
374        // any test that is sampling them. `fd_test_lock` additionally keeps
375        // out the rest of the binary's fd-moving tests — this assertion
376        // reads the descriptor number the kernel handed back, so a
377        // concurrently-freed low slot would land the open there.
378        let _g = fd_test_lock();
379        let _s = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
380        use std::os::unix::io::AsRawFd;
381        let dir = tempfile::tempdir().expect("tempdir");
382        let path = dir.path().join("internal");
383        let _file = with_high_fds(|| std::fs::File::create(&path).expect("create"));
384        let fd = _file.as_raw_fd();
385        assert!(
386            fd >= FIRST_INTERNAL_FD,
387            "the guard must push a shell-internal open above the script range; landed on {fd}"
388        );
389        assert!(
390            fd < FD_SCAN_LIMIT,
391            "test descriptor {fd} is past the sweep bound, the assertion below would be vacuous"
392        );
393        assert_eq!(
394            crate::ported::utils::fdtable_get(fd),
395            crate::ported::zsh_h::FDT_INTERNAL,
396            "fd {fd} is the shell's own and must be marked FDT_INTERNAL (c:Src/utils.c:2009)"
397        );
398    }
399    use std::os::unix::fs::MetadataExt as _;
400    use std::os::unix::io::AsRawFd;
401    use std::sync::atomic::{AtomicBool, Ordering};
402    use std::sync::{Arc, Mutex};
403
404    /// The guard is process-global state. Two of these tests running on
405    /// different harness threads would observe each other's reservations, so
406    /// they take turns.
407    static SERIAL: Mutex<()> = Mutex::new(());
408
409    /// The lock the rest of the test binary uses to serialise work that
410    /// touches process-global state, taken here for the descriptor TABLE.
411    ///
412    /// `SERIAL` only keeps the guard tests apart from each other; the fd table
413    /// is shared with all ~10k tests, and several move low descriptors on
414    /// purpose — `bin_sysread_no_args_returns_nonzero`
415    /// (`ported/modules/system.rs:2595-2605`) dups a pipe onto fd 0 and closes
416    /// pipe ends that can sit inside the script range. Those tests hold this
417    /// lock, so taking it keeps them out of the window rather than racing
418    /// them. It is not a complete fence — a test that touches fds without
419    /// taking it still can interleave — which is why the assertions below pin
420    /// the guard's own reservation rather than the descriptor number the
421    /// kernel happens to return.
422    ///
423    /// Order is always this lock first, then `SERIAL`.
424    fn fd_test_lock() -> std::sync::MutexGuard<'static, ()> {
425        crate::test_util::global_state_lock()
426    }
427
428    /// Which of the script descriptors are currently open.
429    fn open_script_fds() -> Vec<RawFd> {
430        (FIRST_SCRIPT_FD..FIRST_INTERNAL_FD)
431            .filter(|&fd| unsafe { libc::fcntl(fd, libc::F_GETFD) } != -1)
432            .collect()
433    }
434
435    /// The guard reserves the SCRIPT range, and that is what is pinned here:
436    /// while it is alive every descriptor in `FIRST_SCRIPT_FD..FIRST_INTERNAL_FD`
437    /// is occupied, so a fresh open cannot land on one.
438    ///
439    /// It does NOT, and must not, reserve 0/1/2 — those are stdin/stdout/stderr
440    /// and belong to nobody the shell may park `/dev/null` on (see
441    /// `FIRST_SCRIPT_FD`: the script's own descriptors start at 3, `exec 3>out`).
442    /// Asserting the *consequence* (`landed >= FIRST_INTERNAL_FD`) instead of the
443    /// reservation therefore made this test depend on a precondition the guard
444    /// neither controls nor claims: that stdio is open. In a 10k-test binary it
445    /// intermittently is not — another test has a low descriptor in flight — and
446    /// the kernel's lowest-free rule then hands `open` fd 0 before the guard's
447    /// range is ever reached. Observed in a full `cargo test --lib`:
448    /// "open inside the guard landed on fd 0, inside the script range".
449    #[test]
450    fn guard_pushes_opens_above_the_script_range() {
451        let _g = fd_test_lock();
452        let _s = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
453        let guard = LowFdGuard::new();
454
455        // The reservation itself — the guard's own invariant, independent of
456        // which number the kernel hands back next. Every script slot is spoken
457        // for: by this guard, or by whoever already owned it.
458        for fd in FIRST_SCRIPT_FD..FIRST_INTERNAL_FD {
459            assert!(
460                fd_is_open(fd),
461                "guard left script fd {fd} free; it reserved {:?}",
462                guard.held
463            );
464        }
465
466        let f = std::fs::File::open("/dev/null").expect("open /dev/null");
467        let landed = f.as_raw_fd();
468        // With every script slot proven occupied above, a landing anywhere in
469        // that range is the guard's fault and nothing else's.
470        assert!(
471            !(FIRST_SCRIPT_FD..FIRST_INTERNAL_FD).contains(&landed),
472            "open inside the guard landed on fd {landed}, inside the script range"
473        );
474        drop(f);
475        drop(guard);
476    }
477
478    #[test]
479    fn guard_restores_the_script_range_exactly() {
480        // Samples the script range before and after, so any other test that
481        // opens or closes a descriptor down there in between is a false
482        // failure — see `fd_test_lock`.
483        let _g = fd_test_lock();
484        let _s = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
485        let before = open_script_fds();
486        {
487            let _g = LowFdGuard::new();
488        }
489        assert_eq!(
490            before,
491            open_script_fds(),
492            "guard leaked or freed descriptors it did not take"
493        );
494    }
495
496    /// The bug this module was rewritten for: the old probe-then-`dup2` claim
497    /// could land on a descriptor another thread had just been handed, and
498    /// `dup2` closes its target silently. Detect it by identity, not by
499    /// liveness — a clobbered descriptor is still *open*, it just points at
500    /// `/dev/null` instead of the file its owner opened.
501    ///
502    /// Deliberately uses raw `libc` descriptors rather than `std::fs::File`:
503    /// under the old code the theft also tripped std's IO-safety assert, which
504    /// aborts the whole test binary instead of failing one test.
505    #[test]
506    fn concurrent_guards_never_steal_a_descriptor_from_another_thread() {
507        let _g = fd_test_lock();
508        let _s = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
509
510        let path = std::env::temp_dir().join(format!("zshrs-lowfd-{}", std::process::id()));
511        std::fs::write(&path, b"sentinel").expect("write probe file");
512        let want_ino = std::fs::metadata(&path).expect("stat probe file").ino();
513        let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
514
515        let stop = Arc::new(AtomicBool::new(false));
516        let claimers: Vec<_> = (0..3)
517            .map(|_| {
518                let stop = Arc::clone(&stop);
519                std::thread::spawn(move || {
520                    while !stop.load(Ordering::Relaxed) {
521                        drop(LowFdGuard::new());
522                    }
523                })
524            })
525            .collect();
526
527        // Meanwhile: open the probe file over and over. Whenever the kernel
528        // hands back a descriptor in the script range, that descriptor is ours
529        // and must still refer to the probe file.
530        let mut stolen = None;
531        for _ in 0..200_000 {
532            let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) };
533            if fd < 0 {
534                continue;
535            }
536            // Only the SCRIPT range is watched. The guard can never affect
537            // fds 0/1/2: it does not reserve them (`FIRST_SCRIPT_FD`), and the
538            // only syscall it aims at them is `open`, which cannot return a
539            // descriptor somebody else holds. A change of identity down there
540            // is some other test re-pointing stdio — e.g.
541            // `bin_sysread_no_args_returns_nonzero`
542            // (`ported/modules/system.rs:2598-2604`) dups a pipe onto fd 0 and
543            // back — which is exactly what this assertion used to report as
544            // "a LowFdGuard on another thread took over live fd Some(0)".
545            if (FIRST_SCRIPT_FD..FIRST_INTERNAL_FD).contains(&fd) {
546                std::thread::yield_now();
547                let mut st: libc::stat = unsafe { std::mem::zeroed() };
548                if unsafe { libc::fstat(fd, &mut st) } == 0 && st.st_ino != want_ino {
549                    stolen = Some(fd);
550                }
551            }
552            unsafe { libc::close(fd) };
553            if stolen.is_some() {
554                break;
555            }
556        }
557
558        stop.store(true, Ordering::Relaxed);
559        for t in claimers {
560            let _ = t.join();
561        }
562        let _ = std::fs::remove_file(&path);
563
564        assert!(
565            stolen.is_none(),
566            "a LowFdGuard on another thread took over live fd {:?}",
567            stolen
568        );
569    }
570
571    /// The guard must stay usable in a `fork(2)` child. zshrs forks raw in a
572    /// dozen places (`fusevm_bridge.rs`, `ported/exec.rs`, `modules/zpty.rs`,
573    /// `modules/clone.rs`, …) and only the forking thread survives, so any lock
574    /// another thread happened to hold is inherited permanently locked. An
575    /// earlier attempt to fix the descriptor race by serializing `new()` on a
576    /// `Mutex` was reverted for exactly this reason; this test fails (by
577    /// timeout) if anyone reintroduces one.
578    #[test]
579    fn guard_still_works_in_a_fork_child_while_other_threads_hold_it() {
580        let _g = fd_test_lock();
581        let _s = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
582
583        let stop = Arc::new(AtomicBool::new(false));
584        let hammers: Vec<_> = (0..4)
585            .map(|_| {
586                let stop = Arc::clone(&stop);
587                std::thread::spawn(move || {
588                    while !stop.load(Ordering::Relaxed) {
589                        drop(LowFdGuard::new());
590                    }
591                })
592            })
593            .collect();
594
595        let mut stuck = 0;
596        // The descriptor each failing child landed on, so a real regression
597        // names the slot instead of only counting.
598        let mut failed: Vec<i32> = Vec::new();
599        for _ in 0..20 {
600            // SAFETY: the child does nothing but build a guard, open once, and
601            // `_exit` — no unwinding, no atexit handlers, no stdio.
602            let pid = unsafe { libc::fork() };
603            if pid == 0 {
604                let g = LowFdGuard::new();
605                // The contract is "opens land high", not "we claimed
606                // something": a child forked while another thread held the
607                // whole range inherits those descriptors and correctly
608                // reserves nothing.
609                let fd = unsafe { libc::open(c"/dev/null".as_ptr(), libc::O_RDONLY) };
610                // The contract is the SCRIPT range. 0/1/2 are stdio and are
611                // never reserved (`FIRST_SCRIPT_FD`), so a child forked while
612                // the parent happened to have one of them closed legitimately
613                // gets it back from the kernel's lowest-free rule — not a
614                // guard failure. Report the offending descriptor as the exit
615                // status so a real one is diagnosable.
616                let bad = (FIRST_SCRIPT_FD..FIRST_INTERNAL_FD).contains(&fd);
617                unsafe { libc::close(fd) };
618                drop(g);
619                unsafe { libc::_exit(if bad { fd } else { 0 }) };
620            }
621            assert!(pid > 0, "fork failed");
622
623            // A guard that deadlocked in the child never reaches `_exit`.
624            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
625            let mut status = 0;
626            loop {
627                let r = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
628                if r == pid {
629                    let code = libc::WEXITSTATUS(status);
630                    if code != 0 {
631                        failed.push(code);
632                    }
633                    break;
634                }
635                if std::time::Instant::now() > deadline {
636                    stuck += 1;
637                    unsafe { libc::kill(pid, libc::SIGKILL) };
638                    unsafe { libc::waitpid(pid, &mut status, 0) };
639                    break;
640                }
641                std::thread::sleep(std::time::Duration::from_millis(2));
642            }
643        }
644
645        stop.store(true, Ordering::Relaxed);
646        for t in hammers {
647            let _ = t.join();
648        }
649        assert_eq!(stuck, 0, "LowFdGuard::new() hung in a fork child");
650        assert!(
651            failed.is_empty(),
652            "an open inside a fork child's guard landed in the script fd range: {failed:?}"
653        );
654    }
655
656    /// The reservation must be `FD_CLOEXEC`. The old `dup2` claim cleared it, so
657    /// an `exec` racing the guard started the child with `/dev/null` parked on
658    /// the script's descriptors.
659    #[test]
660    fn reserved_descriptors_are_close_on_exec() {
661        let _g = fd_test_lock();
662        let _s = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
663        let before = open_script_fds();
664        let guard = LowFdGuard::new();
665        for &fd in &guard.held {
666            let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
667            assert!(flags != -1, "reserved fd {fd} is not open");
668            assert!(
669                flags & libc::FD_CLOEXEC != 0,
670                "reserved fd {fd} would leak through exec"
671            );
672        }
673        // Only free slots may be claimed — never one that was already live.
674        for fd in &before {
675            assert!(
676                !guard.held.contains(fd),
677                "guard claimed fd {fd}, which was already open"
678            );
679        }
680        drop(guard);
681    }
682}