Skip to main content

zsh/extensions/
fds.rs

1//! File descriptor utilities for zshrs.
2//!
3//! The top half (`AutoClosePipes`, `heightenize_fd`, `BorrowedFdFile`)
4//! is borrowed from fish-shell's `fds.rs` — these are zshrs-original
5//! safe-wrapper infrastructure with no direct C zsh counterpart.
6//!
7//! The bottom half (`movefd`, `redup`, `zclose`, etc.) is a direct
8//! port of the fd helpers C zsh keeps in `Src/utils.c` for redirection
9//! and pipe management. Each function below is annotated with its
10//! exact C origin.
11
12use std::fs::File;
13use std::io;
14use std::mem::ManuallyDrop;
15use std::ops::{Deref, DerefMut};
16use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd, RawFd};
17
18/// The first "high fd", outside the user-specifiable range (>&5).
19/// Mirrors the `FDT_HIGHFD` constant Src/exec.c uses when calling
20/// `movefd()` to keep low fds free for `>&N` user redirection.
21pub const FIRST_HIGH_FD: RawFd = 10;
22
23/// A pair of connected pipe file descriptors.
24/// zshrs-original RAII wrapper (borrowed from fish-shell). C zsh
25/// uses bare `int fds[2]` from `pipe(2)` and `mpipe()`
26/// (Src/exec.c) which gets manual `close(2)` calls on every error
27/// path; the OwnedFd pair makes that automatic.
28pub struct AutoClosePipes {
29    /// `read` field.
30    pub read: OwnedFd,
31    /// `write` field.
32    pub write: OwnedFd,
33}
34
35/// Create a pair of connected pipes with CLOEXEC set.
36/// zshrs-original wrapper around `pipe(2)` (borrowed from fish-
37/// shell). C zsh's equivalent is `mpipe()` in Src/exec.c which
38/// calls `pipe(2)` then `movefd(fd, 1)` on each end. The Rust path
39/// also sets `FD_CLOEXEC` so descriptors don't leak into child
40/// processes.
41pub fn make_autoclose_pipes() -> io::Result<AutoClosePipes> {
42    let (read_fd, write_fd) =
43        nix::unistd::pipe().map_err(|e| io::Error::from_raw_os_error(e as i32))?;
44
45    // Move fds to high range and set CLOEXEC
46    let read_fd = heightenize_fd(read_fd)?;
47    let write_fd = heightenize_fd(write_fd)?;
48
49    Ok(AutoClosePipes {
50        read: read_fd,
51        write: write_fd,
52    })
53}
54
55/// Move an fd to the high range (>= FIRST_HIGH_FD) and set CLOEXEC.
56/// Equivalent to `movefd()` from Src/utils.c plus an additional
57/// `FD_CLOEXEC` set step. The C source sets `FD_CLOEXEC` indirectly
58/// by registering the fd in `fdtable[]` with `FDT_INTERNAL`.
59fn heightenize_fd(fd: OwnedFd) -> io::Result<OwnedFd> {
60    let raw_fd = fd.as_raw_fd();
61
62    if raw_fd >= FIRST_HIGH_FD {
63        set_cloexec(raw_fd, true)?;
64        return Ok(fd);
65    }
66
67    // Dup to high range with CLOEXEC
68    let new_fd = nix::fcntl::fcntl(raw_fd, nix::fcntl::FcntlArg::F_DUPFD_CLOEXEC(FIRST_HIGH_FD))
69        .map_err(|e| io::Error::from_raw_os_error(e as i32))?;
70
71    Ok(unsafe { OwnedFd::from_raw_fd(new_fd) })
72}
73
74/// Set or clear CLOEXEC on a file descriptor.
75/// Port of the `fdtable_flocks` close-on-exec set/clear logic
76/// Src/utils.c uses on internal fds — `fcntl(F_GETFD)` + `F_SETFD`
77/// with the bit toggled. The C source doesn't expose this as a
78/// dedicated function; we factor it out for the RAII pipe path.
79pub fn set_cloexec(fd: RawFd, should_set: bool) -> io::Result<()> {
80    let flags = unsafe { libc::fcntl(fd, libc::F_GETFD, 0) };
81    if flags < 0 {
82        return Err(io::Error::last_os_error());
83    }
84
85    let new_flags = if should_set {
86        flags | libc::FD_CLOEXEC
87    } else {
88        flags & !libc::FD_CLOEXEC
89    };
90
91    if flags != new_flags {
92        let result = unsafe { libc::fcntl(fd, libc::F_SETFD, new_flags) };
93        if result < 0 {
94            return Err(io::Error::last_os_error());
95        }
96    }
97
98    Ok(())
99}
100
101/// Make an fd nonblocking.
102/// Port of the `fcntl(fd, F_SETFL, O_NONBLOCK)` idiom Src/utils.c
103/// uses for completion's coroutine fds and for `read -t`.
104pub fn make_fd_nonblocking(fd: RawFd) -> io::Result<()> {
105    let flags = unsafe { libc::fcntl(fd, libc::F_GETFL, 0) };
106    if flags < 0 {
107        return Err(io::Error::last_os_error());
108    }
109
110    if (flags & libc::O_NONBLOCK) == 0 {
111        let result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) };
112        if result < 0 {
113            return Err(io::Error::last_os_error());
114        }
115    }
116
117    Ok(())
118}
119
120/// Make an fd blocking.
121/// Counterpart of `make_fd_nonblocking`. C zsh restores blocking
122/// mode after `read -t` (Src/utils.c) by clearing `O_NONBLOCK`.
123pub fn make_fd_blocking(fd: RawFd) -> io::Result<()> {
124    let flags = unsafe { libc::fcntl(fd, libc::F_GETFL, 0) };
125    if flags < 0 {
126        return Err(io::Error::last_os_error());
127    }
128
129    if (flags & libc::O_NONBLOCK) != 0 {
130        let result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK) };
131        if result < 0 {
132            return Err(io::Error::last_os_error());
133        }
134    }
135
136    Ok(())
137}
138
139/// Close a file descriptor, retrying on EINTR.
140/// Port of `zclose(int fd)` from Src/utils.c — same `EINTR` retry loop;
141/// negative fds are treated as already-closed (no-op).
142pub fn close_fd(fd: RawFd) {
143    if fd < 0 {
144        return;
145    }
146    loop {
147        let result = unsafe { libc::close(fd) };
148        if result == 0 {
149            break;
150        }
151        let err = io::Error::last_os_error();
152        if err.raw_os_error() != Some(libc::EINTR) {
153            break;
154        }
155    }
156}
157
158/// Duplicate a file descriptor.
159/// Port of the `dup(2)` call sites in Src/exec.c that copy a saved
160/// fd back into place during `exec >` / `exec <` redirection
161/// teardown.
162pub fn dup_fd(fd: RawFd) -> io::Result<RawFd> {
163    let new_fd = unsafe { libc::dup(fd) };
164    if new_fd < 0 {
165        Err(io::Error::last_os_error())
166    } else {
167        Ok(new_fd)
168    }
169}
170
171/// Duplicate fd to a specific target fd.
172/// Port of the `dup2(2)` calls Src/exec.c uses to install the
173/// shell's pipeline ends on fd 0/1/2 before `execve(2)`.
174pub fn dup2_fd(src: RawFd, dst: RawFd) -> io::Result<()> {
175    if src == dst {
176        return Ok(());
177    }
178    let result = unsafe { libc::dup2(src, dst) };
179    if result < 0 {
180        Err(io::Error::last_os_error())
181    } else {
182        Ok(())
183    }
184}
185
186/// A `File` wrapper that doesn't close on drop (borrows the fd).
187/// zshrs-original (borrowed from fish-shell). C zsh shares fds
188/// across builtins with bare `int fd` since lifetime is implicit;
189/// Rust needs an explicit RAII handle that dups the fd without
190/// transferring ownership.
191pub struct BorrowedFdFile(ManuallyDrop<File>);
192
193impl Deref for BorrowedFdFile {
194    type Target = File;
195    fn deref(&self) -> &Self::Target {
196        &self.0
197    }
198}
199
200impl DerefMut for BorrowedFdFile {
201    fn deref_mut(&mut self) -> &mut Self::Target {
202        &mut self.0
203    }
204}
205
206impl FromRawFd for BorrowedFdFile {
207    unsafe fn from_raw_fd(fd: RawFd) -> Self {
208        Self(ManuallyDrop::new(unsafe { File::from_raw_fd(fd) }))
209    }
210}
211
212impl AsRawFd for BorrowedFdFile {
213    fn as_raw_fd(&self) -> RawFd {
214        self.0.as_raw_fd()
215    }
216}
217
218impl IntoRawFd for BorrowedFdFile {
219    fn into_raw_fd(self) -> RawFd {
220        ManuallyDrop::into_inner(self.0).into_raw_fd()
221    }
222}
223
224impl Clone for BorrowedFdFile {
225    fn clone(&self) -> Self {
226        unsafe { Self::from_raw_fd(self.as_raw_fd()) }
227    }
228}
229
230impl BorrowedFdFile {
231    /// `stdin` — see implementation.
232    pub fn stdin() -> Self {
233        unsafe { Self::from_raw_fd(libc::STDIN_FILENO) }
234    }
235    /// `stdout` — see implementation.
236    pub fn stdout() -> Self {
237        unsafe { Self::from_raw_fd(libc::STDOUT_FILENO) }
238    }
239    /// `stderr` — see implementation.
240    pub fn stderr() -> Self {
241        unsafe { Self::from_raw_fd(libc::STDERR_FILENO) }
242    }
243}
244
245impl io::Read for BorrowedFdFile {
246    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
247        self.deref_mut().read(buf)
248    }
249}
250
251impl io::Write for BorrowedFdFile {
252    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
253        self.deref_mut().write(buf)
254    }
255
256    fn flush(&mut self) -> io::Result<()> {
257        self.deref_mut().flush()
258    }
259}
260
261// ============================================================================
262// Port from zsh/Src/utils.c: File descriptor table and management
263// ============================================================================
264
265/// File descriptor type constants.
266/// Port of the `FDT_*` enum from `Src/zsh.h` — the C source uses
267/// these to tag every fd in `fdtable[]` so `closem()` (Src/utils.c)
268/// can decide which fds to leave open across `exec(2)` / `fork(2)`.
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub enum FdType {
271    Unused = 0,
272    External = 1,
273    Internal = 2,
274    Module = 3,
275    Flock = 4,
276    FlockExec = 5,
277    Proc = 6,
278}
279
280/// Move a file descriptor to >= `FIRST_HIGH_FD` so the low fds 0-9
281/// stay free for user redirection.
282/// Port of `movefd(int fd)` from Src/utils.c (~lines 1980-2012). Uses
283/// `fcntl(F_DUPFD_CLOEXEC)` to get the new fd in the high range
284/// then closes the original.
285pub fn movefd(fd: RawFd) -> RawFd {
286    if !(0..FIRST_HIGH_FD).contains(&fd) {
287        return fd;
288    }
289
290    unsafe {
291        let new_fd = libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, FIRST_HIGH_FD);
292        if new_fd != -1 {
293            libc::close(fd);
294            return new_fd;
295        }
296    }
297    fd
298}
299
300/// Duplicate fd `x` to `y`. If `x == -1`, fd `y` is closed.
301/// Port of `redup(int x, int y)` from Src/utils.c (~lines 2019-2068) — the C
302/// source's "move fd x onto y, closing x" primitive that the
303/// pipeline-builder calls for every redirection.
304pub fn redup(x: RawFd, y: RawFd) -> RawFd {
305    if x < 0 {
306        unsafe { libc::close(y) };
307        return y;
308    }
309
310    if x == y {
311        return y;
312    }
313
314    let result = unsafe { libc::dup2(x, y) };
315    if result == -1 {
316        return -1;
317    }
318
319    unsafe { libc::close(x) };
320    y
321}
322
323/// Close a file descriptor.
324/// Port of `zclose(int fd)` from Src/utils.c (~lines 2126-2148) — the
325/// safe-close shim that no-ops on negative fds and clears the
326/// fdtable entry the C source maintains for `closem()` accounting.
327pub fn zclose(fd: RawFd) -> i32 {
328    if fd >= 0 {
329        unsafe { libc::close(fd) }
330    } else {
331        -1
332    }
333}
334
335/// Duplicate a file descriptor.
336/// Port of the bare `dup(2)` calls Src/utils.c sprinkles around
337/// the redirection-save/restore code. Negative input returns -1
338/// (matches the C source's no-op-on-bad-fd convention).
339pub fn zdup(fd: RawFd) -> RawFd {
340    if fd < 0 {
341        return -1;
342    }
343    unsafe { libc::dup(fd) }
344}
345
346/// Check if a file descriptor is open.
347/// zshrs-original convenience — equivalent to the
348/// `fcntl(fd, F_GETFD)` probe Src/utils.c uses inline before
349/// touching an fd that may have been closed by a parallel branch.
350pub fn fd_is_open(fd: RawFd) -> bool {
351    if fd < 0 {
352        return false;
353    }
354    unsafe { libc::fcntl(fd, libc::F_GETFD, 0) >= 0 }
355}
356
357// ============================================================================
358// Tests
359// ============================================================================
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_make_autoclose_pipes() {
367        let _g = crate::test_util::global_state_lock();
368        let pipes = make_autoclose_pipes().expect("Failed to create pipes");
369
370        // Both fds should be in the high range
371        assert!(pipes.read.as_raw_fd() >= FIRST_HIGH_FD);
372        assert!(pipes.write.as_raw_fd() >= FIRST_HIGH_FD);
373
374        // Both should have CLOEXEC set
375        let read_flags = unsafe { libc::fcntl(pipes.read.as_raw_fd(), libc::F_GETFD, 0) };
376        let write_flags = unsafe { libc::fcntl(pipes.write.as_raw_fd(), libc::F_GETFD, 0) };
377
378        assert!(read_flags >= 0);
379        assert!(write_flags >= 0);
380        assert_ne!(read_flags & libc::FD_CLOEXEC, 0);
381        assert_ne!(write_flags & libc::FD_CLOEXEC, 0);
382    }
383
384    #[test]
385    fn test_set_cloexec() {
386        let _g = crate::test_util::global_state_lock();
387        let file = std::fs::File::open("/dev/null").unwrap();
388        let fd = file.as_raw_fd();
389
390        // Set CLOEXEC
391        set_cloexec(fd, true).unwrap();
392        let flags = unsafe { libc::fcntl(fd, libc::F_GETFD, 0) };
393        assert_ne!(flags & libc::FD_CLOEXEC, 0);
394
395        // Clear CLOEXEC
396        set_cloexec(fd, false).unwrap();
397        let flags = unsafe { libc::fcntl(fd, libc::F_GETFD, 0) };
398        assert_eq!(flags & libc::FD_CLOEXEC, 0);
399    }
400
401    #[test]
402    fn test_nonblocking() {
403        let _g = crate::test_util::global_state_lock();
404        let file = std::fs::File::open("/dev/null").unwrap();
405        let fd = file.as_raw_fd();
406
407        // Make nonblocking
408        make_fd_nonblocking(fd).unwrap();
409        let flags = unsafe { libc::fcntl(fd, libc::F_GETFL, 0) };
410        assert_ne!(flags & libc::O_NONBLOCK, 0);
411
412        // Make blocking again
413        make_fd_blocking(fd).unwrap();
414        let flags = unsafe { libc::fcntl(fd, libc::F_GETFL, 0) };
415        assert_eq!(flags & libc::O_NONBLOCK, 0);
416    }
417
418    #[test]
419    fn test_borrowed_fd_file_does_not_close() {
420        let _g = crate::test_util::global_state_lock();
421        let file = std::fs::File::open("/dev/null").unwrap();
422        let fd = file.as_raw_fd();
423
424        // Create borrowed file and let it fall out of scope. Direct
425        // `drop()` would tickle clippy::drop_non_drop because
426        // BorrowedFdFile has no Drop impl by design — the whole
427        // point of this type is that scope-end is a no-op for the
428        // underlying fd. The inner block makes the lifetime
429        // boundary explicit.
430        {
431            let _borrowed = unsafe { BorrowedFdFile::from_raw_fd(fd) };
432        }
433
434        // fd should still be valid
435        let flags = unsafe { libc::fcntl(fd, libc::F_GETFD, 0) };
436        assert!(
437            flags >= 0,
438            "fd should still be valid after dropping BorrowedFdFile"
439        );
440
441        // Now drop the original file
442        drop(file);
443
444        // fd should now be invalid
445        let flags = unsafe { libc::fcntl(fd, libc::F_GETFD, 0) };
446        assert!(
447            flags < 0,
448            "fd should be invalid after dropping original File"
449        );
450    }
451
452    // ========================================================
453    // fd_is_open — F_GETFD probe
454    // ========================================================
455
456    #[test]
457    fn fd_is_open_true_for_stdin_stdout_stderr() {
458        let _g = crate::test_util::global_state_lock();
459        // In a unit-test process, fds 0/1/2 are always open.
460        assert!(fd_is_open(0));
461        assert!(fd_is_open(1));
462        assert!(fd_is_open(2));
463    }
464
465    #[test]
466    fn fd_is_open_false_for_huge_unused_fd() {
467        let _g = crate::test_util::global_state_lock();
468        // Pick a fd far above any reasonable open count.
469        assert!(!fd_is_open(99_999));
470    }
471
472    #[test]
473    fn fd_is_open_false_after_close() {
474        let _g = crate::test_util::global_state_lock();
475        let file = std::fs::File::open("/dev/null").unwrap();
476        let fd = file.as_raw_fd();
477        assert!(fd_is_open(fd));
478        drop(file);
479        // After drop the fd should be invalid.
480        assert!(!fd_is_open(fd));
481    }
482
483    // ========================================================
484    // dup_fd / dup2_fd — pure-syscall behavior
485    // ========================================================
486
487    #[test]
488    fn dup_fd_yields_independent_open_fd() {
489        let _g = crate::test_util::global_state_lock();
490        let file = std::fs::File::open("/dev/null").unwrap();
491        let fd = file.as_raw_fd();
492        let dup = dup_fd(fd).unwrap();
493        assert!(fd_is_open(dup));
494        assert_ne!(dup, fd, "dup must yield a fresh descriptor");
495        // Close the dup — the original must still be open.
496        close_fd(dup);
497        assert!(fd_is_open(fd));
498        drop(file);
499    }
500
501    #[test]
502    fn dup_fd_on_invalid_fd_fails() {
503        let _g = crate::test_util::global_state_lock();
504        // Use a fd we know is closed.
505        let r = dup_fd(99_998);
506        assert!(r.is_err(), "dup of invalid fd must error: {:?}", r);
507    }
508
509    #[test]
510    fn close_fd_on_invalid_fd_is_silent() {
511        let _g = crate::test_util::global_state_lock();
512        // close_fd swallows errors per its `zclose` contract.
513        close_fd(99_997);
514    }
515
516    // ========================================================
517    // movefd / redup — fd renaming wrappers
518    // ========================================================
519
520    #[test]
521    fn movefd_returns_a_valid_high_fd() {
522        let _g = crate::test_util::global_state_lock();
523        let file = std::fs::File::open("/dev/null").unwrap();
524        let fd = file.as_raw_fd();
525        let dup = dup_fd(fd).unwrap();
526        let moved = movefd(dup);
527        // Either succeeded and returned a different fd, or returned -1
528        // — both are acceptable contract outcomes. If success, must be open.
529        if moved >= 0 {
530            assert!(fd_is_open(moved));
531            close_fd(moved);
532        }
533        drop(file);
534    }
535
536    #[test]
537    fn zclose_on_open_fd_returns_zero() {
538        let _g = crate::test_util::global_state_lock();
539        let file = std::fs::File::open("/dev/null").unwrap();
540        let fd = dup_fd(file.as_raw_fd()).unwrap();
541        let r = zclose(fd);
542        assert_eq!(r, 0, "zclose should return 0 on successful close");
543        drop(file);
544    }
545
546    #[test]
547    fn zdup_returns_new_open_fd() {
548        let _g = crate::test_util::global_state_lock();
549        let file = std::fs::File::open("/dev/null").unwrap();
550        let fd = file.as_raw_fd();
551        let dup = zdup(fd);
552        if dup >= 0 {
553            assert!(fd_is_open(dup));
554            assert_ne!(dup, fd);
555            close_fd(dup);
556        }
557        drop(file);
558    }
559
560    #[test]
561    fn zdup_on_invalid_fd_returns_negative() {
562        let _g = crate::test_util::global_state_lock();
563        let r = zdup(99_996);
564        assert!(r < 0, "zdup of invalid fd should be negative: {}", r);
565    }
566}