From f55b19dfc71e56fc78830d709d2d079f6290fc89 Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Fri, 16 Oct 2026 21:48:40 +0900 Subject: [PATCH 1/6] test(fspy): cover calls with -1 as the directory descriptor A tracked program can pass -1 as a directory descriptor, such as a failed `open` result it does not check. procps-ng's `ps -p ` does this for an exited process, and the interposers abort it while borrowing -1 (voidzero-dev/vite-plus#2954). Co-authored-by: Claude Opus 5.5 --- crates/fspy/tests/invalid_dirfd.rs | 89 ++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 crates/fspy/tests/invalid_dirfd.rs diff --git a/crates/fspy/tests/invalid_dirfd.rs b/crates/fspy/tests/invalid_dirfd.rs new file mode 100644 index 000000000..0adf0fa02 --- /dev/null +++ b/crates/fspy/tests/invalid_dirfd.rs @@ -0,0 +1,89 @@ +//! A tracked program may pass `-1` as a directory descriptor, typically a +//! failed `open` result that it does not check. With a relative path the call +//! fails with `EBADF`; with an absolute path the descriptor is ignored. The +//! interposers must forward both cases instead of aborting the program. +//! +//! procps-ng's `ps -p ` does this when the process has exited: it opens +//! `/proc/`, then calls `openat(-1, "status", ...)`. +//! +//! Directory descriptors exist only on Unix; Windows has no equivalent calls. +#![cfg(unix)] + +mod test_utils; + +use fspy::PathAccess; +use test_log::test; +use test_utils::assert_contains; + +fn errno() -> libc::c_int { + std::io::Error::last_os_error().raw_os_error().unwrap() +} + +#[test(tokio::test)] +async fn relative_path_at_invalid_dirfd() -> anyhow::Result<()> { + track_fn!((), |(): ()| { + let path = c"hello"; + // SAFETY: every call below receives a valid C string and writable + // output storage; the kernel rejects the descriptor. + unsafe { + assert_eq!(libc::openat(-1, path.as_ptr(), libc::O_RDONLY), -1); + assert_eq!(errno(), libc::EBADF); + + let mut stat = std::mem::MaybeUninit::::uninit(); + assert_eq!(libc::fstatat(-1, path.as_ptr(), stat.as_mut_ptr(), 0), -1); + assert_eq!(errno(), libc::EBADF); + + assert_eq!(libc::faccessat(-1, path.as_ptr(), libc::F_OK, 0), -1); + assert_eq!(errno(), libc::EBADF); + + assert!(libc::fdopendir(-1).is_null()); + assert_eq!(errno(), libc::EBADF); + } + #[cfg(target_os = "linux")] + // SAFETY: as above. + unsafe { + let mut statx = std::mem::MaybeUninit::::uninit(); + assert_eq!( + libc::statx(-1, path.as_ptr(), 0, libc::STATX_BASIC_STATS, statx.as_mut_ptr()), + -1 + ); + assert_eq!(errno(), libc::EBADF); + + assert_eq!( + libc::syscall( + libc::SYS_statx, + -1, + path.as_ptr(), + 0, + libc::STATX_BASIC_STATS, + statx.as_mut_ptr(), + ), + -1 + ); + assert_eq!(errno(), libc::EBADF); + } + }) + .await?; + Ok(()) +} + +#[test(tokio::test)] +async fn absolute_path_at_invalid_dirfd() -> anyhow::Result<()> { + let tmp_dir = tempfile::tempdir()?; + let tmp_path = std::fs::canonicalize(tmp_dir.path())?.join("hello"); + std::fs::write(&tmp_path, "")?; + + let accesses = track_fn!(tmp_path.to_str().unwrap().to_owned(), |tmp_path: String| { + let path = std::ffi::CString::new(tmp_path).unwrap(); + // SAFETY: `path` is a valid C string; an absolute path makes the + // kernel ignore the descriptor. + let fd = unsafe { libc::openat(-1, path.as_ptr(), libc::O_RDONLY) }; + assert!(fd >= 0, "openat failed with errno {}", errno()); + // SAFETY: `fd` was opened above and is closed once. + unsafe { libc::close(fd) }; + }) + .await?; + assert_contains(&accesses, &tmp_path, PathAccess::Read); + + Ok(()) +} From b4762664b72337f38de2d79cdf24bd083d65e177 Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Fri, 16 Oct 2026 21:50:26 +0900 Subject: [PATCH 2/6] fix(fspy): forward calls with -1 as the directory descriptor The interposers borrowed every descriptor argument with `BorrowedFd::borrow_raw`, which panics on -1, so a tracked program that passed a failed `open` result to `openat`, `fstatat`, `faccessat`, `statx`, `fdopendir`, or `execveat` aborted. procps-ng's `ps -p ` does this when the process has exited. Borrow descriptor arguments as `Option`, with `None` for -1. An absolute pathname is still tracked, since the kernel ignores the descriptor; a relative pathname or a lone descriptor is not, since the call fails with `EBADF`. Co-authored-by: Claude Opus 5.5 --- crates/fspy_client_unix/src/convert.rs | 35 +++++++++++++++---- crates/fspy_nostd/src/fd.rs | 21 +++++++++++ .../src/interceptions/dirent.rs | 12 +++---- .../src/interceptions/linux_syscall.rs | 4 +-- .../src/interceptions/stat.rs | 4 +-- 5 files changed, 60 insertions(+), 16 deletions(-) diff --git a/crates/fspy_client_unix/src/convert.rs b/crates/fspy_client_unix/src/convert.rs index 53c3db99c..d8f28b6a7 100644 --- a/crates/fspy_client_unix/src/convert.rs +++ b/crates/fspy_client_unix/src/convert.rs @@ -107,19 +107,38 @@ impl ToAbsolutePath for BorrowedFd<'_> { } } -pub struct PathAt<'fd, 'path>(pub BorrowedFd<'fd>, pub fspy_nostd::CStr<'path, fspy_nostd::Thin>); +impl ToAbsolutePath for Option { + fn to_absolute_path<'a, A: Allocator>( + self, + allocator: &'a A, + ) -> nix::Result>> + where + Self: 'a, + { + self.map_or(Ok(None), |path| path.to_absolute_path(allocator)) + } +} + +/// A pathname resolved against a directory descriptor, as in `openat`. +/// +/// The descriptor is `None` when the caller passed `-1`. The kernel ignores +/// it for an absolute pathname and fails a relative one with `EBADF`. +pub struct PathAt<'fd, 'path>( + pub Option>, + pub fspy_nostd::CStr<'path, fspy_nostd::Thin>, +); impl PathAt<'_, '_> { /// Borrows raw directory-descriptor and pathname arguments. /// /// # Safety /// - /// `fd` must remain valid while the returned value is used, and `path` - /// must point to a valid NUL-terminated string. + /// `fd` must be `-1` or remain valid while the returned value is used, + /// and `path` must point to a valid NUL-terminated string. #[must_use] pub const unsafe fn borrow_raw(fd: c_int, path: *const c_char) -> Self { // SAFETY: both invariants are upheld by the caller. - Self(unsafe { BorrowedFd::borrow_raw(fd) }, unsafe { + Self(unsafe { BorrowedFd::try_borrow_raw(fd) }, unsafe { fspy_nostd::CStr::from_ptr(path.cast()) }) } @@ -140,7 +159,11 @@ impl ToAbsolutePath for PathAt<'_, '_> { // Already absolute, and already NUL-terminated by the caller. Ok(Some(counted)) } else { - let Some(mut base) = get_fd_path(allocator, self.0)? else { + // A relative pathname at `-1` accesses nothing: the call fails. + let Some(dirfd) = self.0 else { + return Ok(None); + }; + let Some(mut base) = get_fd_path(allocator, dirfd)? else { return Ok(None); }; if !pathname.is_empty() { @@ -167,7 +190,7 @@ impl ToAbsolutePath for fspy_nostd::CStr<'_, fspy_nostd::Thin> { where Self: 'a, { - PathAt(CWD, self).to_absolute_path(allocator) + PathAt(Some(CWD), self).to_absolute_path(allocator) } } diff --git a/crates/fspy_nostd/src/fd.rs b/crates/fspy_nostd/src/fd.rs index 202411033..c7b894850 100644 --- a/crates/fspy_nostd/src/fd.rs +++ b/crates/fspy_nostd/src/fd.rs @@ -36,6 +36,27 @@ impl BorrowedFd<'_> { Self { fd, lifetime: PhantomData } } + /// Borrows a descriptor argument passed by a caller, or returns `None` + /// when it is `-1`. + /// + /// Callers can pass `-1` unchecked from a failed call such as `open`. + /// The kernel rejects it with `EBADF` wherever it uses the descriptor, + /// so it is not a caller error to report here. + /// + /// # Safety + /// + /// Any value other than `-1` must satisfy [`Self::borrow_raw`]. + #[must_use] + pub const unsafe fn try_borrow_raw(fd: RawFd) -> Option { + if fd == -1 { + None + } else { + // SAFETY: `fd` is not `-1`, and the caller upholds the + // remaining requirements of `borrow_raw`. + Some(unsafe { Self::borrow_raw(fd) }) + } + } + /// Returns the raw descriptor without transferring ownership. #[must_use] pub const fn as_raw_fd(self) -> RawFd { diff --git a/crates/fspy_preload_unix/src/interceptions/dirent.rs b/crates/fspy_preload_unix/src/interceptions/dirent.rs index e4d7c781e..048f8f447 100644 --- a/crates/fspy_preload_unix/src/interceptions/dirent.rs +++ b/crates/fspy_preload_unix/src/interceptions/dirent.rs @@ -51,8 +51,8 @@ mod macos_only { buf_len: usize, basep: *mut i64, ) -> isize { - // SAFETY: fd is a valid file descriptor provided by the caller of __getdirentries64 - unsafe { handle_open(BorrowedFd::borrow_raw(fd), AccessMode::ReadDir) }; + // SAFETY: fd is -1 or a valid file descriptor provided by the caller of __getdirentries64 + unsafe { handle_open(BorrowedFd::try_borrow_raw(fd), AccessMode::ReadDir) }; // SAFETY: calling the original libc __getdirentries64() with the same arguments forwarded from the interposed function unsafe { __getdirentries64::original()(fd, buf, buf_len, basep) } } @@ -65,16 +65,16 @@ unsafe extern "C" fn getdirentries( nbytes: c_int, basep: *mut c_long, ) -> c_int { - // SAFETY: fd is a valid file descriptor provided by the caller of the interposed function - unsafe { handle_open(BorrowedFd::borrow_raw(fd), AccessMode::ReadDir) }; + // SAFETY: fd is -1 or a valid file descriptor provided by the caller of the interposed function + unsafe { handle_open(BorrowedFd::try_borrow_raw(fd), AccessMode::ReadDir) }; // SAFETY: calling the original libc getdirentries() with the same arguments forwarded from the interposed function unsafe { getdirentries::original()(fd, buf, nbytes, basep) } } intercept!(fdopendir(64): unsafe extern "C" fn (fd: c_int) -> *mut DIR); unsafe extern "C" fn fdopendir(fd: c_int) -> *mut DIR { - // SAFETY: fd is a valid file descriptor provided by the caller of the interposed function - unsafe { handle_open(BorrowedFd::borrow_raw(fd), AccessMode::ReadDir) }; + // SAFETY: fd is -1 or a valid file descriptor provided by the caller of the interposed function + unsafe { handle_open(BorrowedFd::try_borrow_raw(fd), AccessMode::ReadDir) }; // SAFETY: calling the original libc fdopendir() with the same arguments forwarded from the interposed function unsafe { fdopendir::original()(fd) } } diff --git a/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs b/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs index a9d7750c0..3ec4a3a97 100644 --- a/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs +++ b/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs @@ -38,8 +38,8 @@ unsafe extern "C" fn syscall(syscall_no: c_long, mut args: ...) -> c_long { let flags = a2 as c_int; if pathname.is_null() { if flags & libc::AT_EMPTY_PATH != 0 { - // SAFETY: dirfd is provided by the statx syscall caller. - unsafe { handle_open(BorrowedFd::borrow_raw(dirfd), AccessMode::Read) }; + // SAFETY: dirfd is -1 or a valid file descriptor provided by the statx syscall caller. + unsafe { handle_open(BorrowedFd::try_borrow_raw(dirfd), AccessMode::Read) }; } } else { // SAFETY: pathname is a non-null C string pointer provided by the statx syscall caller. diff --git a/crates/fspy_preload_unix/src/interceptions/stat.rs b/crates/fspy_preload_unix/src/interceptions/stat.rs index 050288500..cfbbc3b73 100644 --- a/crates/fspy_preload_unix/src/interceptions/stat.rs +++ b/crates/fspy_preload_unix/src/interceptions/stat.rs @@ -70,8 +70,8 @@ unsafe extern "C" fn statx( if pathname.is_null() { if flags & libc::AT_EMPTY_PATH != 0 { - // SAFETY: dirfd is provided by the statx caller. - unsafe { handle_open(BorrowedFd::borrow_raw(dirfd), AccessMode::Read) }; + // SAFETY: dirfd is -1 or a valid file descriptor provided by the statx caller. + unsafe { handle_open(BorrowedFd::try_borrow_raw(dirfd), AccessMode::Read) }; } } else { // SAFETY: pathname is a non-null C string pointer provided by the statx caller. From 5985c66f3138e403b99e0002592f4ebe1d579e54 Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Fri, 16 Oct 2026 21:55:34 +0900 Subject: [PATCH 3/6] docs(changelog): note the -1 directory descriptor fix Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index edd3132ec..47a1d1fa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** In cached tasks on Linux and macOS, programs that pass `-1` as a directory descriptor to calls such as `openat` no longer abort. For example, `ps -p ` from procps-ng 4.0.6 or later aborted when the process had already exited ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). - **Fixed** On Linux, concurrent system calls in tracked programs, such as `pnpm dlx`, no longer cause a stack overflow ([#854](https://github.com/voidzero-dev/vite-task/pull/854)). - **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)). From a9e6ec14f0fa09120bb1246432cbb710237c5bf0 Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Sun, 18 Oct 2026 01:13:32 +0900 Subject: [PATCH 4/6] test(fspy): build the -1 directory descriptor tests on every Linux target The tests called the libc `statx` wrapper, which glibc 2.17 lacks and the libc crate does not declare for musl, so the Linux CI builds failed. Call `statx` through `syscall` instead, and resolve the glibc `statx` and `execveat` wrappers with `dlsym` so that their interposers are still covered on glibc. Also cover `statx` with a null path and `AT_EMPTY_PATH`, and check that a relative path at -1 is not recorded under the current directory. Co-authored-by: Claude Opus 5.5 --- crates/fspy/tests/invalid_dirfd.rs | 144 ++++++++++++++++++++++++---- crates/fspy/tests/test_utils/mod.rs | 30 ++++++ 2 files changed, 154 insertions(+), 20 deletions(-) diff --git a/crates/fspy/tests/invalid_dirfd.rs b/crates/fspy/tests/invalid_dirfd.rs index 0adf0fa02..6dfc802a5 100644 --- a/crates/fspy/tests/invalid_dirfd.rs +++ b/crates/fspy/tests/invalid_dirfd.rs @@ -13,15 +13,104 @@ mod test_utils; use fspy::PathAccess; use test_log::test; -use test_utils::assert_contains; +use test_utils::{assert_contains, assert_not_contains}; fn errno() -> libc::c_int { std::io::Error::last_os_error().raw_os_error().unwrap() } +/// `STATX_BASIC_STATS`. The libc crate does not define it for every Linux +/// target, such as musl without `musl_v1_2_3`. +#[cfg(target_os = "linux")] +const STATX_BASIC_STATS: libc::c_uint = 0x7ff; + +/// Storage for the kernel's 256-byte `struct statx`. The libc crate does not +/// define the struct for every Linux target. +#[cfg(target_os = "linux")] +#[repr(C, align(8))] +struct StatxBuf([u8; 256]); + +/// Calls `statx` through `syscall`, which fspy intercepts on glibc. +/// +/// The libc `statx` wrapper is not called directly: glibc added it in 2.28, +/// so a test built for an older glibc fails to link. +#[cfg(target_os = "linux")] +fn syscall_statx( + dirfd: libc::c_int, + path: *const libc::c_char, + flags: libc::c_int, +) -> libc::c_long { + let mut buf = StatxBuf([0; 256]); + // SAFETY: `path` is null or a valid C string, and `buf` is writable + // storage of the size that the kernel writes. + unsafe { + libc::syscall( + libc::SYS_statx, + dirfd, + path, + flags, + STATX_BASIC_STATS, + core::ptr::from_mut(&mut buf), + ) + } +} + +/// Calls the glibc `statx` wrapper, resolved at run time. With `LD_PRELOAD`, +/// the lookup finds the fspy interposer first. +/// +/// # Panics +/// +/// Panics if glibc has no `statx` (added in 2.28). +#[cfg(all(target_os = "linux", target_env = "gnu"))] +fn glibc_statx(dirfd: libc::c_int, path: *const libc::c_char, flags: libc::c_int) -> libc::c_int { + type Statx = unsafe extern "C" fn( + libc::c_int, + *const libc::c_char, + libc::c_int, + libc::c_uint, + *mut StatxBuf, + ) -> libc::c_int; + // SAFETY: the name is a valid C string. + let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"statx".as_ptr()) }; + assert!(!symbol.is_null(), "glibc has no statx"); + // SAFETY: glibc's `statx` has this signature. + let statx = unsafe { core::mem::transmute::<*mut libc::c_void, Statx>(symbol) }; + let mut buf = StatxBuf([0; 256]); + // SAFETY: `path` is null or a valid C string, and `buf` is writable + // storage of the size that the kernel writes. + unsafe { statx(dirfd, path, flags, STATX_BASIC_STATS, core::ptr::from_mut(&mut buf)) } +} + +/// Calls the glibc `execveat` wrapper, resolved at run time for the same +/// reason as [`glibc_statx`]. +/// +/// # Panics +/// +/// Panics if glibc has no `execveat` (added in 2.34). +#[cfg(all(target_os = "linux", target_env = "gnu"))] +fn glibc_execveat(dirfd: libc::c_int, path: &core::ffi::CStr) -> libc::c_int { + type Execveat = unsafe extern "C" fn( + libc::c_int, + *const libc::c_char, + *const *const libc::c_char, + *const *const libc::c_char, + libc::c_int, + ) -> libc::c_int; + // SAFETY: the name is a valid C string. + let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"execveat".as_ptr()) }; + assert!(!symbol.is_null(), "glibc has no execveat"); + // SAFETY: glibc's `execveat` has this signature. + let execveat = unsafe { core::mem::transmute::<*mut libc::c_void, Execveat>(symbol) }; + let argv = [path.as_ptr(), core::ptr::null()]; + let envp = [core::ptr::null()]; + // SAFETY: `path` is a valid C string, and `argv` and `envp` are + // null-terminated arrays of valid C strings. + unsafe { execveat(dirfd, path.as_ptr(), argv.as_ptr(), envp.as_ptr(), 0) } +} + #[test(tokio::test)] async fn relative_path_at_invalid_dirfd() -> anyhow::Result<()> { - track_fn!((), |(): ()| { + let accesses = track_fn!((), |(): ()| { let path = c"hello"; // SAFETY: every call below receives a valid C string and writable // output storage; the kernel rejects the descriptor. @@ -40,30 +129,45 @@ async fn relative_path_at_invalid_dirfd() -> anyhow::Result<()> { assert_eq!(errno(), libc::EBADF); } #[cfg(target_os = "linux")] - // SAFETY: as above. - unsafe { - let mut statx = std::mem::MaybeUninit::::uninit(); - assert_eq!( - libc::statx(-1, path.as_ptr(), 0, libc::STATX_BASIC_STATS, statx.as_mut_ptr()), - -1 - ); + { + assert_eq!(syscall_statx(-1, path.as_ptr(), 0), -1); + assert_eq!(errno(), libc::EBADF); + } + #[cfg(all(target_os = "linux", target_env = "gnu"))] + { + assert_eq!(glibc_statx(-1, path.as_ptr(), 0), -1); assert_eq!(errno(), libc::EBADF); - assert_eq!( - libc::syscall( - libc::SYS_statx, - -1, - path.as_ptr(), - 0, - libc::STATX_BASIC_STATS, - statx.as_mut_ptr(), - ), - -1 - ); + assert_eq!(glibc_execveat(-1, path), -1); assert_eq!(errno(), libc::EBADF); } }) .await?; + // `-1` is not the current directory. The child inherits the current + // directory of the test. + assert_not_contains(&accesses, &std::env::current_dir()?.join("hello")); + Ok(()) +} + +/// `statx` with a null path and `AT_EMPTY_PATH` uses only the descriptor. +#[cfg(target_os = "linux")] +#[test(tokio::test)] +async fn empty_path_at_invalid_dirfd() -> anyhow::Result<()> { + track_fn!((), |(): ()| { + // Linux 6.11 and later accept a null path with `AT_EMPTY_PATH` and + // reject the descriptor. Earlier kernels reject the null path. + let rejected = [libc::EBADF, libc::EFAULT]; + + assert_eq!(syscall_statx(-1, core::ptr::null(), libc::AT_EMPTY_PATH), -1); + assert!(rejected.contains(&errno()), "unexpected errno {}", errno()); + + #[cfg(target_env = "gnu")] + { + assert_eq!(glibc_statx(-1, core::ptr::null(), libc::AT_EMPTY_PATH), -1); + assert!(rejected.contains(&errno()), "unexpected errno {}", errno()); + } + }) + .await?; Ok(()) } diff --git a/crates/fspy/tests/test_utils/mod.rs b/crates/fspy/tests/test_utils/mod.rs index 0ae779051..4ffa8f6d1 100644 --- a/crates/fspy/tests/test_utils/mod.rs +++ b/crates/fspy/tests/test_utils/mod.rs @@ -57,6 +57,36 @@ pub fn assert_contains<'a>( ); } +/// Asserts that `unexpected_path` was not accessed in any way. +/// +/// # Panics +/// +/// Panics if the path was accessed. +#[expect( + clippy::allow_attributes, + reason = "allow attribute required for conditionally-used helper" +)] +#[allow(dead_code, reason = "not all test files use this helper")] +#[track_caller] +pub fn assert_not_contains(accesses: &PathAccessIterable, unexpected_path: &Path) { + let matching = accesses + .iter() + .filter(|access| { + access.paths().any(|path| { + path.strip_path_prefix(unexpected_path, |strip_result| { + strip_result.is_ok_and(|stripped| stripped.as_os_str().is_empty()) + }) + }) + }) + .collect::>(); + assert!( + matching.is_empty(), + "Expected no accesses to {}, found: {:?}", + unexpected_path.display(), + matching + ); +} + /// Spawns a subprocess that executes the given function with file access tracking. /// /// - $arg: The argument to pass to the function From 0bd89a37efa9e04432fe6de409cf85b4352992b5 Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Sun, 18 Oct 2026 01:13:32 +0900 Subject: [PATCH 5/6] docs(fspy): allow -1 in directory descriptor SAFETY comments Co-authored-by: Claude Opus 5.5 --- crates/fspy_preload_unix/src/interceptions/access.rs | 2 +- crates/fspy_preload_unix/src/interceptions/linux_syscall.rs | 2 +- crates/fspy_preload_unix/src/interceptions/open.rs | 4 ++-- crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs | 2 +- crates/fspy_preload_unix/src/interceptions/stat.rs | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/fspy_preload_unix/src/interceptions/access.rs b/crates/fspy_preload_unix/src/interceptions/access.rs index c856a2bbf..db220a314 100644 --- a/crates/fspy_preload_unix/src/interceptions/access.rs +++ b/crates/fspy_preload_unix/src/interceptions/access.rs @@ -23,7 +23,7 @@ unsafe extern "C" fn faccessat( mode: c_int, flags: c_int, ) -> c_int { - // SAFETY: dirfd and pathname are valid arguments provided by the caller of the interposed function + // SAFETY: dirfd is -1 or a valid file descriptor, and pathname is a valid C string, provided by the caller of the interposed function unsafe { handle_open(PathAt::borrow_raw(dirfd, pathname), AccessMode::Read); } diff --git a/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs b/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs index 3ec4a3a97..f351ca1fa 100644 --- a/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs +++ b/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs @@ -42,7 +42,7 @@ unsafe extern "C" fn syscall(syscall_no: c_long, mut args: ...) -> c_long { unsafe { handle_open(BorrowedFd::try_borrow_raw(dirfd), AccessMode::Read) }; } } else { - // SAFETY: pathname is a non-null C string pointer provided by the statx syscall caller. + // SAFETY: dirfd is -1 or a valid file descriptor, and pathname is a non-null C string pointer, provided by the statx syscall caller. unsafe { handle_open(PathAt::borrow_raw(dirfd, pathname), AccessMode::Read); }; diff --git a/crates/fspy_preload_unix/src/interceptions/open.rs b/crates/fspy_preload_unix/src/interceptions/open.rs index e4e6138a2..cb8ac5f3b 100644 --- a/crates/fspy_preload_unix/src/interceptions/open.rs +++ b/crates/fspy_preload_unix/src/interceptions/open.rs @@ -47,7 +47,7 @@ unsafe extern "C" fn openat( flags: c_int, mut args: ... ) -> c_int { - // SAFETY: dirfd and path are valid arguments provided by the caller of the interposed function + // SAFETY: dirfd is -1 or a valid file descriptor, and path is a valid C string, provided by the caller of the interposed function unsafe { handle_open(PathAt::borrow_raw(dirfd, path), OpenFlags(flags)) }; if has_mode_arg(flags) { @@ -88,7 +88,7 @@ unsafe extern "C" fn openat_nocancel( flags: c_int, mut args: ... ) -> c_int { - // SAFETY: dirfd and path are valid arguments provided by the caller of openat$NOCANCEL + // SAFETY: dirfd is -1 or a valid file descriptor, and path is a valid C string, provided by the caller of openat$NOCANCEL unsafe { handle_open(PathAt::borrow_raw(dirfd, path), OpenFlags(flags)) }; if has_mode_arg(flags) { // SAFETY: O_CREAT requires a mode argument, matching the openat$NOCANCEL contract diff --git a/crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs b/crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs index 729162d02..6aa15e4aa 100644 --- a/crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs +++ b/crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs @@ -240,7 +240,7 @@ mod linux_only { let _unused = execveat::original; let arena = fspy_nostd_alloc::pooled_bump(); - // SAFETY: dirfd and pathname are valid arguments from the interposed execveat call. + // SAFETY: dirfd is -1 or a valid file descriptor, and pathname is a valid C string, from the interposed execveat call. let path = unsafe { PathAt::borrow_raw(dirfd, pathname) }; let abs_path = match path.to_absolute_path(&arena) { Ok(None) => { diff --git a/crates/fspy_preload_unix/src/interceptions/stat.rs b/crates/fspy_preload_unix/src/interceptions/stat.rs index cfbbc3b73..a386de6b5 100644 --- a/crates/fspy_preload_unix/src/interceptions/stat.rs +++ b/crates/fspy_preload_unix/src/interceptions/stat.rs @@ -36,7 +36,7 @@ unsafe extern "C" fn fstatat( buf: *mut stat_struct, flags: c_int, ) -> c_int { - // SAFETY: dirfd and pathname are valid arguments provided by the caller of the interposed function + // SAFETY: dirfd is -1 or a valid file descriptor, and pathname is a valid C string, provided by the caller of the interposed function unsafe { handle_open(PathAt::borrow_raw(dirfd, pathname), AccessMode::Read); } @@ -74,7 +74,7 @@ unsafe extern "C" fn statx( unsafe { handle_open(BorrowedFd::try_borrow_raw(dirfd), AccessMode::Read) }; } } else { - // SAFETY: pathname is a non-null C string pointer provided by the statx caller. + // SAFETY: dirfd is -1 or a valid file descriptor, and pathname is a non-null C string pointer, provided by the statx caller. unsafe { handle_open(PathAt::borrow_raw(dirfd, pathname), AccessMode::Read) }; } // SAFETY: calling the original libc statx() with the same arguments forwarded from the interposed function From 2e00d7b4e67388a33abca5f9b76503746fa8cd03 Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Sun, 18 Oct 2026 13:59:01 +0900 Subject: [PATCH 6/6] docs(changelog): link the -1 directory descriptor fix to #862 Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a1d1fa3..904eff040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** In cached tasks on Linux and macOS, programs that pass `-1` as a directory descriptor to calls such as `openat` no longer abort. For example, `ps -p ` from procps-ng 4.0.6 or later aborted when the process had already exited ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). +- **Fixed** In cached tasks on Linux and macOS, programs that pass `-1` as a directory descriptor to calls such as `openat` no longer abort. For example, `ps -p ` from procps-ng 4.0.6 or later aborted when the process had already exited ([#862](https://github.com/voidzero-dev/vite-task/pull/862)). - **Fixed** On Linux, concurrent system calls in tracked programs, such as `pnpm dlx`, no longer cause a stack overflow ([#854](https://github.com/voidzero-dev/vite-task/pull/854)). - **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)).