Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)).
Expand Down
103 changes: 103 additions & 0 deletions crates/fspy/tests/exec_empty_path.rs
Original file line number Diff line number Diff line change
@@ -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<c_int> {
let argv = [c"".as_ptr(), null()];
let envp = [null::<c_char>()];
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::<c_char>())),
errno(libc::execle(c"".as_ptr(), argv[0], null::<c_char>(), envp.as_ptr())),
errno(libc::execlp(c"".as_ptr(), argv[0], null::<c_char>())),
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<c_int>| {
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, &current_dir()?);
}
Ok(())
}
9 changes: 9 additions & 0 deletions crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion crates/fspy_shared_unix/src/exec/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,19 @@ impl<A: Allocator + Clone> Exec<A> {
/// # 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(
&mut self,
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(
Expand Down
Loading