diff --git a/CHANGELOG.md b/CHANGELOG.md index 63c29bfc7..f6f1d90be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** On Linux and macOS, programs using automatic file-access tracking no longer abort when they call `execve`, `execv`, `execl`, `execle`, or `posix_spawn` with an empty path; the call fails with `ENOENT`, as it does without tracking. On Linux, `execveat` with an empty path and without `AT_EMPTY_PATH` now also fails with `ENOENT`, instead of trying to run what its directory descriptor refers to ([#896](https://github.com/voidzero-dev/vite-task/pull/896)). - **Perf** `vp run` starts faster in workspaces whose tasks share cache `input` and `output` patterns: each distinct pattern is now parsed once instead of once for every task that uses it. In a 138-package workspace, this saves about 2 seconds of CPU on every run ([#887](https://github.com/voidzero-dev/vite-task/pull/887), [#890](https://github.com/voidzero-dev/vite-task/pull/890)). - **Perf** When tasks miss the cache, `vp run` no longer waits for the disk after saving each task's result, so the run finishes sooner. If the computer loses power or the operating system crashes during `vp run`, the cache entries that the run saved can be lost, and those tasks run again next time instead of hitting the cache ([#871](https://github.com/voidzero-dev/vite-task/pull/871)). - **Fixed** Running `vp run` in a package that sits inside a workspace folder but isn't listed in its `pnpm-workspace.yaml` `packages` or `package.json` `workspaces` now runs that package's own tasks, instead of treating the enclosing workspace as its root. When both configurations exist, `pnpm-workspace.yaml` takes precedence ([#859](https://github.com/voidzero-dev/vite-task/pull/859)). diff --git a/crates/fspy/tests/exec_empty_path.rs b/crates/fspy/tests/exec_empty_path.rs new file mode 100644 index 000000000..d8ad11d3d --- /dev/null +++ b/crates/fspy/tests/exec_empty_path.rs @@ -0,0 +1,103 @@ +//! An exec of an empty path fails with `ENOENT` before the kernel looks +//! anything up. Under fspy, every exec entry point must fail the same way it +//! does untraced, without aborting the program or recording an access. +#![cfg(unix)] + +mod test_utils; + +use std::{env::current_dir, io::Error, ptr::null}; + +use libc::{c_char, c_int}; +use test_log::test; +use test_utils::assert_not_contains; + +/// Calls each exec entry point with an empty path and returns the error +/// each reports: `errno` from the exec family, and the return value of +/// `posix_spawn` and `posix_spawnp`. +fn exec_empty_path() -> Vec { + let argv = [c"".as_ptr(), null()]; + let envp = [null::()]; + let errno = |ret: c_int| { + assert_eq!(ret, -1); + Error::last_os_error().raw_os_error().unwrap() + }; + let mut pid = 0; + // SAFETY: every pointer is a valid NUL-terminated C string or a + // NULL-terminated array of them, and an exec of an empty path returns. + unsafe { + vec![ + errno(libc::execve(c"".as_ptr(), argv.as_ptr(), envp.as_ptr())), + errno(libc::execv(c"".as_ptr(), argv.as_ptr())), + errno(libc::execvp(c"".as_ptr(), argv.as_ptr())), + errno(libc::execl(c"".as_ptr(), argv[0], null::())), + errno(libc::execle(c"".as_ptr(), argv[0], null::(), envp.as_ptr())), + errno(libc::execlp(c"".as_ptr(), argv[0], null::())), + libc::posix_spawn( + &raw mut pid, + c"".as_ptr(), + null(), + null(), + argv.as_ptr().cast(), + envp.as_ptr().cast(), + ), + libc::posix_spawnp( + &raw mut pid, + c"".as_ptr(), + null(), + null(), + argv.as_ptr().cast(), + envp.as_ptr().cast(), + ), + #[cfg(target_os = "linux")] + errno(libc::execvpe(c"".as_ptr(), argv.as_ptr(), envp.as_ptr())), + #[cfg(all(target_os = "linux", target_env = "gnu"))] + errno(execveat(libc::AT_FDCWD, c"".as_ptr(), argv.as_ptr(), envp.as_ptr(), 0)), + ] + } +} + +/// Calls `execveat`, looked up at run time: CI links against glibc 2.17, +/// and `execveat` arrived in glibc 2.34. The lookup finds the same function +/// a direct call would, fspy's interposer when traced. +/// +/// # Safety +/// +/// The arguments must be valid for `execveat`. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +unsafe fn execveat( + dirfd: c_int, + path: *const c_char, + argv: *const *const c_char, + envp: *const *const c_char, + flags: c_int, +) -> c_int { + type Execveat = unsafe extern "C" fn( + c_int, + *const c_char, + *const *const c_char, + *const *const c_char, + c_int, + ) -> c_int; + // SAFETY: the name is a valid C string. + let execveat = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"execveat".as_ptr()) }; + assert!(!execveat.is_null(), "libc has no execveat"); + // SAFETY: `execveat` has this signature, and the caller passes valid arguments. + unsafe { + std::mem::transmute::<*mut libc::c_void, Execveat>(execveat)(dirfd, path, argv, envp, flags) + } +} + +#[test(tokio::test)] +async fn exec_empty_path_fails_like_untraced() -> anyhow::Result<()> { + let untraced = exec_empty_path(); + let accesses = track_fn!(untraced, |untraced: Vec| { + assert_eq!(exec_empty_path(), untraced); + }) + .await?; + // Seccomp tracking, used on musl, reports the directory an empty path + // is relative to. + if cfg!(not(target_env = "musl")) { + assert_not_contains(&accesses, ¤t_dir()?); + } + Ok(()) +} 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 49ca99fea..08c8c20f7 100644 --- a/crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs +++ b/crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs @@ -240,6 +240,15 @@ mod linux_only { reason = "suppresses unused warning on *::original" )] let _unused = execveat::original; + // Without `AT_EMPTY_PATH`, an empty pathname fails with `ENOENT` + // before the kernel looks anything up, while resolving it against + // `dirfd` would run whatever `dirfd` refers to. Let the real call + // fail. + // SAFETY: pathname is a valid C string from the interposed execveat call. + if unsafe { *pathname } == 0 && flags & libc::AT_EMPTY_PATH == 0 { + // SAFETY: forwarding the original arguments to the real execveat syscall + return unsafe { execveat::original()(dirfd, pathname, argv, envp, flags) }; + } let arena = fspy_nostd_alloc::pooled_bump(); // SAFETY: dirfd is -1 or a valid file descriptor, and pathname is a valid C string, from the interposed execveat call. diff --git a/crates/fspy_shared_unix/src/exec/mod.rs b/crates/fspy_shared_unix/src/exec/mod.rs index c931b1cbd..bcc3fc819 100644 --- a/crates/fspy_shared_unix/src/exec/mod.rs +++ b/crates/fspy_shared_unix/src/exec/mod.rs @@ -118,7 +118,7 @@ impl Exec { /// # Errors /// /// Returns an error if: - /// - The program is not found in PATH (`ENOENT`) + /// - The program is empty, or is not found in PATH (`ENOENT`) /// - The program file cannot be accessed or read (`EACCES`, `EISDIR`, `EIO`) /// - Shebang parsing fails due to I/O errors (`EIO`) pub fn resolve( @@ -126,6 +126,11 @@ impl Exec { mut on_path_read: impl FnMut(&Path), config: ExecResolveConfig, ) -> nix::Result<()> { + // execve and libc's PATH search both fail an empty program with + // ENOENT before looking anything up, so nothing is read. + if self.program.is_empty() { + return Err(nix::Error::ENOENT); + } if let Some(search_path) = config.search_path { let path = search_path.custom_path.unwrap_or_else(|| { getenv(c"PATH").map_or_else(