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}