Skip to content
Open
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, editing `pnpm-workspace.yaml`, for example to add a catalog entry, no longer causes a cache miss in every task that loads a Vite config. A file a task only checks the existence of, as Vite does with `pnpm-workspace.yaml` and `lerna.json` while looking for the workspace root, is now an input by whether it exists rather than by its content. Creating or removing such a file is still a cache miss, and a task that reads the file still misses when it changes. On Windows, Node.js's `fs.existsSync` also reads the file's metadata, so the file stays a content input there ([#860](https://github.com/voidzero-dev/vite-task/issues/860), [#869](https://github.com/voidzero-dev/vite-task/pull/869)).
- **Fixed** A mistyped or misplaced setting in a task now gets an error that names it. For example, `cache: { outputs: [...] }` now fails with an `unknown field` error that names `outputs` and lists the accepted settings, instead of `data did not match any variant of untagged enum UserTaskDefinition` ([vite-plus#2635](https://github.com/voidzero-dev/vite-plus/issues/2635), [#815](https://github.com/voidzero-dev/vite-task/pull/815)).
- **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 <pid>` 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** `vp run` no longer panics before running a script that contains non-ASCII characters, such as `echo こんにちは`, and no longer cuts off such commands where it displays them ([#861](https://github.com/voidzero-dev/vite-task/pull/861)).
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/fspy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ fspy_preload_unix = { workspace = true }
fspy_detours_sys = { workspace = true }
fspy_preload_windows = { workspace = true }
ntapi = { workspace = true }
winapi = { workspace = true, features = ["winbase", "securitybaseapi", "handleapi"] }
winapi = { workspace = true, features = ["winbase", "securitybaseapi", "handleapi", "fileapi"] }
winsafe = { workspace = true, features = ["kernel"] }

[target.'cfg(target_os = "macos")'.dev-dependencies]
Expand Down
30 changes: 23 additions & 7 deletions crates/fspy/src/unix/syscall_handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use fspy_seccomp_unotify::{
impl_handler,
supervisor::handler::arg::{CStrPtr, Caller, Fd},
};
use fspy_shared::ipc::{PathAccess, channel::Sender};
use fspy_shared::ipc::{AccessMode, PathAccess, channel::Sender};

const PATH_MAX: usize = libc::PATH_MAX as usize;

Expand All @@ -41,6 +41,27 @@ impl SyscallHandler {
dir_fd: Fd,
path_ptr: CStrPtr,
flags: c_int,
) -> io::Result<()> {
let mode = match flags & libc::O_ACCMODE {
libc::O_RDWR => AccessMode::ReadWrite,
libc::O_WRONLY => AccessMode::Write,
_ => AccessMode::Read,
};
self.handle_path(caller, dir_fd, path_ptr, mode)
}

fn handle_probe(&mut self, caller: Caller, dir_fd: Fd, path_ptr: CStrPtr) -> io::Result<()> {
self.handle_path(caller, dir_fd, path_ptr, AccessMode::Probe)
}

/// Records a `mode` access to the path at `path_ptr`, resolved against
/// `dir_fd`.
fn handle_path(
&mut self,
caller: Caller,
dir_fd: Fd,
path_ptr: CStrPtr,
mode: AccessMode,
) -> io::Result<()> {
let Some(path_len) = path_ptr.read(caller, &mut self.path_read_buf)? else {
// Ignore paths that are too long to fit in PATH_MAX
Expand All @@ -54,12 +75,7 @@ impl SyscallHandler {
}
path = Cow::Owned(resolved_path);
}
let path = path.as_os_str().into();
self.ipc_sender.send(&match flags & libc::O_ACCMODE {
libc::O_RDWR => PathAccess::ReadWrite(path),
libc::O_WRONLY => PathAccess::Write(path),
_ => PathAccess::Read(path),
});
self.ipc_sender.send(&mode.with_path(path.as_os_str().into()));
Ok(())
}

Expand Down
6 changes: 3 additions & 3 deletions crates/fspy/src/unix/syscall_handler/stat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ impl SyscallHandler {
/// access(2) — check file accessibility (e.g. existsSync in Node.js).
#[cfg(target_arch = "x86_64")]
pub(super) fn access(&mut self, caller: Caller, (path,): (CStrPtr,)) -> io::Result<()> {
self.handle_open(caller, Fd::cwd(), path, libc::O_RDONLY)
self.handle_probe(caller, Fd::cwd(), path)
}

/// faccessat(2) — check file accessibility relative to directory fd.
Expand All @@ -54,7 +54,7 @@ impl SyscallHandler {
caller: Caller,
(dir_fd, path_ptr): (Fd, CStrPtr),
) -> io::Result<()> {
self.handle_open(caller, dir_fd, path_ptr, libc::O_RDONLY)
self.handle_probe(caller, dir_fd, path_ptr)
}

/// faccessat2(2) — extended faccessat with flags parameter.
Expand All @@ -63,6 +63,6 @@ impl SyscallHandler {
caller: Caller,
(dir_fd, path_ptr): (Fd, CStrPtr),
) -> io::Result<()> {
self.handle_open(caller, dir_fd, path_ptr, libc::O_RDONLY)
self.handle_probe(caller, dir_fd, path_ptr)
}
}
2 changes: 1 addition & 1 deletion crates/fspy/tests/node_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ fn read_sync(runtime: &str) -> anyhow::Result<()> {
#[cfg_attr(not(target_env = "musl"), ignore = "requires node")]
fn exist_sync(runtime: &str) -> anyhow::Result<()> {
let accesses = track_script(runtime, "try { fs.existsSync('hello') } catch {}", &[])?;
assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Read);
assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Probe);
Ok(())
}

Expand Down
6 changes: 6 additions & 0 deletions crates/fspy/tests/static_executable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ async fn stat() {
assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::Read);
}

#[test(tokio::test)]
async fn access() {
let accesses = track_test_bin(&["access", "/hello"], None).await;
assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::Probe);
}

#[test(tokio::test)]
async fn execve() {
let accesses = track_test_bin(&["execve", "/hello"], None).await;
Expand Down
20 changes: 18 additions & 2 deletions crates/fspy/tests/winapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ use std::{ffi::OsStr, os::windows::ffi::OsStrExt, path::Path, ptr::null_mut};
use fspy::PathAccess;
use test_log::test;
use test_utils::assert_contains;
use winapi::um::processthreadsapi::{
CreateProcessA, CreateProcessW, PROCESS_INFORMATION, STARTUPINFOA, STARTUPINFOW,
use winapi::um::{
fileapi::GetFileAttributesW,
processthreadsapi::{
CreateProcessA, CreateProcessW, PROCESS_INFORMATION, STARTUPINFOA, STARTUPINFOW,
},
};

#[test(tokio::test)]
Expand Down Expand Up @@ -73,3 +76,16 @@ async fn create_process_w() -> anyhow::Result<()> {

Ok(())
}

#[test(tokio::test)]
async fn get_file_attributes_w() -> anyhow::Result<()> {
let accesses = track_fn!((), |(): ()| {
let path = OsStr::new("C:\\fspy_test_not_exist_file\0").encode_wide().collect::<Vec<u16>>();
// SAFETY: `path` is a valid NUL-terminated wide string
unsafe { GetFileAttributesW(path.as_ptr()) };
})
.await?;
assert_contains(&accesses, Path::new("C:\\fspy_test_not_exist_file"), PathAccess::Probe);

Ok(())
}
1 change: 1 addition & 0 deletions crates/fspy_e2e/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ impl AccessCollector {
PathAccess::Write(path) => relative(path).map(|path| format!("{path}: Write")),
PathAccess::ReadWrite(path) => relative(path).map(|path| format!("{path}: ReadWrite")),
PathAccess::ReadDir(path) => relative(path).map(|path| format!("{path}: ReadDir")),
PathAccess::Probe(path) => relative(path).map(|path| format!("{path}: Probe")),
};
self.accesses.extend(line);
}
Expand Down
4 changes: 2 additions & 2 deletions crates/fspy_preload_unix/src/interceptions/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ intercept!(access(64): unsafe extern "C" fn(pathname: *const c_char, mode: c_int
unsafe extern "C" fn access(pathname: *const c_char, mode: c_int) -> c_int {
// SAFETY: pathname is a valid C string pointer provided by the caller of the interposed function
unsafe {
handle_open(fspy_nostd::CStr::from_ptr(pathname.cast()), AccessMode::Read);
handle_open(fspy_nostd::CStr::from_ptr(pathname.cast()), AccessMode::Probe);
}
// SAFETY: calling the original libc access() with the same arguments forwarded from the interposed function
unsafe { access::original()(pathname, mode) }
Expand All @@ -25,7 +25,7 @@ unsafe extern "C" fn faccessat(
) -> c_int {
// 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);
handle_open(PathAt::borrow_raw(dirfd, pathname), AccessMode::Probe);
}
// SAFETY: calling the original libc faccessat() with the same arguments forwarded from the interposed function
unsafe { faccessat::original()(dirfd, pathname, mode, flags) }
Expand Down
8 changes: 6 additions & 2 deletions crates/fspy_preload_windows/src/windows/detours/nt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,12 @@ static DETOUR_NT_QUERY_ATTRIBUTES_FILE: Detour<
object_attributes: POBJECT_ATTRIBUTES,
file_information: PFILE_BASIC_INFORMATION,
) -> HFILE {
// SAFETY: intercepting attribute query to record read access
unsafe { handle_open(AccessMode::Read, object_attributes) };
// NtQueryAttributesFile returns only attributes and timestamps, not
// the size, and backs `GetFileAttributesW`, which Node's
// `fs.existsSync` calls. NtQueryFullAttributesFile returns the size
// too, so it stays a read.
// SAFETY: intercepting attribute query to record the existence probe
unsafe { handle_open(AccessMode::Probe, object_attributes) };
// SAFETY: calling the original NtQueryAttributesFile with all original arguments
unsafe {
(DETOUR_NT_QUERY_ATTRIBUTES_FILE.real())(object_attributes, file_information)
Expand Down
9 changes: 8 additions & 1 deletion crates/fspy_shared/src/ipc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub enum AccessMode {
Write,
ReadWrite,
ReadDir,
Probe,
}

impl AccessMode {
Expand All @@ -30,6 +31,7 @@ impl AccessMode {
Self::Write => PathAccess::Write(path),
Self::ReadWrite => PathAccess::ReadWrite(path),
Self::ReadDir => PathAccess::ReadDir(path),
Self::Probe => PathAccess::Probe(path),
}
}
}
Expand All @@ -43,6 +45,9 @@ pub enum PathAccess<'a> {
Write(&'a IpcPath),
ReadWrite(&'a IpcPath),
ReadDir(&'a IpcPath),
/// Checked whether the path exists, as `access` does, without reading
/// its content or metadata such as the size.
Probe(&'a IpcPath),
}

impl<'a> PathAccess<'a> {
Expand All @@ -52,7 +57,8 @@ impl<'a> PathAccess<'a> {
PathAccess::Read(path)
| PathAccess::Write(path)
| PathAccess::ReadWrite(path)
| PathAccess::ReadDir(path) => iter::once(path),
| PathAccess::ReadDir(path)
| PathAccess::Probe(path) => iter::once(path),
}
}

Expand All @@ -63,6 +69,7 @@ impl<'a> PathAccess<'a> {
PathAccess::Write(path) => PathAccess::Write(path.clone_in(bump)),
PathAccess::ReadWrite(path) => PathAccess::ReadWrite(path.clone_in(bump)),
PathAccess::ReadDir(path) => PathAccess::ReadDir(path.clone_in(bump)),
PathAccess::Probe(path) => PathAccess::Probe(path.clone_in(bump)),
}
}
}
3 changes: 3 additions & 0 deletions crates/fspy_test_bin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ fn main() {
"stat" => {
let _ = std::fs::metadata(path);
}
"access" => {
let _ = nix::unistd::access(path, nix::unistd::AccessFlags::F_OK);
}
"execve" => {
let _ = std::process::Command::new(path).spawn();
}
Expand Down
3 changes: 3 additions & 0 deletions crates/vt/docs/task-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,16 @@ Vite Task uses `fspy` to monitor file system access during task execution:
│ • If file: Hash contents with xxHash3 │
│ • If directory: Record structure │
│ • If missing: Mark as NotFound │
│ • If only checked to exist (`access`): Record whether it │
│ exists and is a directory, not its content │
│ │ │
│ ▼ │
│ Path Fingerprint Types: │
│ ────────────────────── │
│ enum PathFingerprint { │
│ NotFound, // File doesn't exist │
│ FileContentHash(u64), // xxHash3 of content │
│ FileExists, // File only checked │
│ Folder(Option<BTreeMap<Str, DirEntryKind>>), │
│ } ▲ │
│ │ │
Expand Down
2 changes: 1 addition & 1 deletion crates/vt/src/session/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ pub fn split_path(path: &str) -> (Option<&str>, &str) {
/// its own cache warm across branch switches, and a cache from a different
/// version is simply ignored (it lives in a directory this build never looks
/// at) rather than aborting the run. Bumping the version starts a fresh cache.
const CACHE_SCHEMA_VERSION: u32 = 18;
const CACHE_SCHEMA_VERSION: u32 = 19;

/// Name of the per-version subdirectory (e.g. `v14`) under the task-cache
/// directory that holds the database and output archives for the current
Expand Down
40 changes: 40 additions & 0 deletions crates/vt/src/session/execute/fingerprint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ pub enum TrackedEnvQuery {
#[derive(Debug, Clone, Copy)]
pub struct PathRead {
pub read_dir_entries: bool,
/// The task only checked whether the path exists, so neither its content
/// nor its directory entries are inputs.
pub exists_only: bool,
}

/// Post-run fingerprint capturing file state after execution.
Expand Down Expand Up @@ -86,6 +89,9 @@ pub enum PathFingerprint {
NotFound,
/// File content hash using `xxHash3_64`
FileContentHash(u64),
/// File that exists, for a path the task only checked the existence of.
/// An existing directory checked this way is `Folder(None)`.
FileExists,
/// Directory with optional entry listing.
/// `Folder(None)` means the directory was opened but entries were not read
/// (e.g., for `openat` calls).
Expand Down Expand Up @@ -158,6 +164,7 @@ impl PostRunFingerprint {
let input_full_path = Arc::<AbsolutePath>::from(base_dir.join(input_relative_path));
let path_read = PathRead {
read_dir_entries: matches!(path_fingerprint, PathFingerprint::Folder(Some(_))),
exists_only: matches!(path_fingerprint, PathFingerprint::FileExists),
};
let current_path_fingerprint = match fingerprint_path(&input_full_path, path_read) {
Ok(ok) => ok,
Expand Down Expand Up @@ -374,6 +381,15 @@ pub fn fingerprint_path(
) -> anyhow::Result<PathFingerprint> {
let std_path = path.as_path();

if path_read.exists_only {
// Like the open below, any error counts as the path not existing.
return Ok(match std::fs::metadata(std_path) {
Ok(metadata) if metadata.is_dir() => PathFingerprint::Folder(None),
Ok(_) => PathFingerprint::FileExists,
Err(_) => PathFingerprint::NotFound,
});
}

let file = match File::open(std_path) {
Ok(file) => file,
Err(err) => {
Expand Down Expand Up @@ -500,8 +516,32 @@ fn process_directory_unix(file: &File, path_read: PathRead) -> anyhow::Result<Pa
mod tests {
use std::ffi::{OsStr, OsString};

use vt_path::AbsolutePathBuf;

use super::*;

#[test]
fn probed_path_fingerprints_only_existence() {
let dir = tempfile::tempdir().unwrap();
let dir = AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap();
let probe = |name: &str| {
let path = Arc::<AbsolutePath>::from(dir.join(name));
fingerprint_path(&path, PathRead { read_dir_entries: false, exists_only: true })
.unwrap()
};

std::fs::write(dir.join("file"), "a").unwrap();
assert_eq!(probe("file"), PathFingerprint::FileExists);
std::fs::write(dir.join("file"), "b").unwrap();
assert_eq!(probe("file"), PathFingerprint::FileExists);

std::fs::create_dir(dir.join("dir")).unwrap();
std::fs::write(dir.join("dir/entry"), "").unwrap();
assert_eq!(probe("dir"), PathFingerprint::Folder(None));

assert_eq!(probe("missing"), PathFingerprint::NotFound);
}

#[cfg(unix)]
fn non_utf8_os_string() -> OsString {
use std::os::unix::ffi::OsStringExt;
Expand Down
Loading