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** A task's outputs now include the symbolic links it creates, like the links Nitro's dependency tracer makes in `.output/server/node_modules`, without an `output` glob that matches them. A cache hit restores them as links, and the next run is a cache hit if they are missing, as on a fresh clone. Previously a hit left them out, and removing one that the task had checked for was a cache miss ([#918](https://github.com/voidzero-dev/vite-task/pull/918)).
- **Fixed** A cache hit now restores the empty folders a task made, like the `.astro/collections` folder that `astro build` leaves empty. A folder holding only such folders and files the task wrote, like `.astro`, may now be missing, as on a fresh clone, and the next run is still a cache hit that restores it. Previously a hit left empty folders missing, and removing a folder holding one was a cache miss ([#917](https://github.com/voidzero-dev/vite-task/pull/917)).
- **Fixed** A task's outputs now include the files it renames into place, like Vitest's `fsModuleCache` entries written under a temporary name, every file in a folder it renames into place, like Vite's dependency optimizer renaming `deps_temp_*` to `deps`, and the hard links it creates. A cache hit restores them, and the next run is a cache hit if they are missing, as on a fresh clone. Previously they weren't restored, and removing one that the task read back was a cache miss ([#916](https://github.com/voidzero-dev/vite-task/pull/916)).
- **Changed** When a task lists a folder, the files it wrote there and the folders holding only such files no longer count as entries of the listing: the next run is a cache hit if they are missing, as on a fresh clone, and the hit restores them. For example, `tsc` with no `include` lists the package root, which holds its `lib` output folder; removing `lib` used to be a cache miss. The folder's other entries still count ([#915](https://github.com/voidzero-dev/vite-task/pull/915)).
Expand Down
2 changes: 2 additions & 0 deletions crates/fspy/src/unix/syscall_handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ impl_handler!(
renameat2,
#[cfg(target_arch = "x86_64")] link,
linkat,
#[cfg(target_arch = "x86_64")] symlink,
symlinkat,

execve,
execveat,
Expand Down
31 changes: 25 additions & 6 deletions crates/fspy/src/unix/syscall_handler/namespace.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
//! Syscalls that make an entry at a path without opening it: mkdir, and
//! rename and hard link, which put an existing file or folder at a new path.
//! The path of a mkdir is recorded as a mkdir, whether or not the folder is
//! there already; the new path of a rename as a rename target, which may be
//! a whole folder; and that of a hard link, always a file, as a write. The
//! source of a rename needs no record.
//! Syscalls that make an entry at a path without opening it: mkdir, symlink,
//! and rename and hard link, which put an existing file or folder at a new
//! path. The path of a mkdir is recorded as a mkdir, whether or not the
//! folder is there already; the new path of a rename as a rename target,
//! which may be a whole folder; and that of a symlink, and of a hard link,
//! always a file, as a write. The source of a rename needs no record, and
//! neither does the target of a symlink, which is stored as text, not
//! accessed.

use std::io;

Expand Down Expand Up @@ -69,4 +71,21 @@ impl SyscallHandler {
) -> io::Result<()> {
self.handle_path(caller, new_dir_fd, new, AccessMode::Write)
}

#[cfg(target_arch = "x86_64")]
pub(super) fn symlink(
&mut self,
caller: Caller,
(_target, link): (Ignored, CStrPtr),
) -> io::Result<()> {
self.handle_path(caller, Fd::cwd(), link, AccessMode::Write)
}

pub(super) fn symlinkat(
&mut self,
caller: Caller,
(_target, new_dir_fd, link): (Ignored, Fd, CStrPtr),
) -> io::Result<()> {
self.handle_path(caller, new_dir_fd, link, AccessMode::Write)
}
}
28 changes: 28 additions & 0 deletions crates/fspy/tests/node_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,34 @@ fn write_sync(runtime: &str) -> anyhow::Result<()> {
Ok(())
}

#[test_case("node")]
#[ignore = "requires node"]
#[cfg_attr(not(target_os = "linux"), test_case("bun"))]
#[cfg_attr(not(target_os = "linux"), ignore = "requires node")]
#[cfg_attr(not(target_env = "musl"), test_case("deno"))]
#[cfg_attr(not(target_env = "musl"), ignore = "requires node")]
fn symlink_junction_sync(runtime: &str) -> anyhow::Result<()> {
let tmpdir = tempfile::tempdir()?;
// Long names, as in `rust_std::symlink_destination`.
let tmp_path = tmpdir.path().canonicalize()?;
let target = tmp_path.join("target");
let link = tmp_path.join("link");
std::fs::create_dir(&target)?;
// A junction on Windows, as nf3 makes them; a symlink elsewhere.
let accesses = track_script(
runtime,
"try { fs.symlinkSync(process.argv.at(-2), process.argv.at(-1), 'junction') } catch {}",
&[target.as_os_str(), link.as_os_str()],
)?;
#[cfg(unix)]
assert_contains(&accesses, &link, PathAccess::Write);
// A junction is a folder, recorded as a mkdir, with a mount point set on
// it.
#[cfg(windows)]
test_utils::assert_includes(&accesses, &link, PathAccess::Write);
Ok(())
}

#[test_case("node")]
#[ignore = "requires node"]
#[cfg_attr(not(target_os = "linux"), test_case("bun"))]
Expand Down
36 changes: 36 additions & 0 deletions crates/fspy/tests/rust_std.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,42 @@ async fn hard_link_destination() -> anyhow::Result<()> {
Ok(())
}

#[test(tokio::test)]
async fn symlink_destination() -> anyhow::Result<()> {
let tmp_dir = tempfile::tempdir()?;
// On Windows the link is recorded from its handle, by its long name, while
// `TEMP` may hold an 8.3 short name, so name the links by the long name.
let tmp_path = tmp_dir.path().canonicalize()?;
fs::create_dir(tmp_path.join("folder"))?;
let tmp_path_str = tmp_path.to_str().unwrap().to_owned();
let accesses = track_fn!(tmp_path_str, |tmp_path_str: String| {
let tmp_dir = std::path::Path::new(&tmp_path_str);
#[cfg(unix)]
{
std::os::unix::fs::symlink("file", tmp_dir.join("file_link")).unwrap();
std::os::unix::fs::symlink("folder", tmp_dir.join("folder_link")).unwrap();
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_file("file", tmp_dir.join("file_link")).unwrap();
std::os::windows::fs::symlink_dir("folder", tmp_dir.join("folder_link")).unwrap();
}
})
.await?;
for link in ["file_link", "folder_link"] {
let link = tmp_path.join(link);
#[cfg(unix)]
assert_contains(&accesses, &link, PathAccess::Write);
// `CreateSymbolicLinkW` creates the link with an open that lacks write
// access, recorded as a read, or as a mkdir for a folder link, before
// it sets the reparse point.
#[cfg(windows)]
test_utils::assert_includes(&accesses, &link, PathAccess::Write);
}

Ok(())
}

#[test(tokio::test)]
async fn mkdir() -> anyhow::Result<()> {
let tmp_dir = tempfile::tempdir()?;
Expand Down
8 changes: 8 additions & 0 deletions crates/fspy/tests/static_executable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ async fn hard_link_destination() {
assert_contains(&accesses, Path::new("/world"), fspy::PathAccess::Write);
}

#[test(tokio::test)]
async fn symlink_destination() {
// The symlink fails, since `/hello` doesn't exist; the link is recorded
// anyway.
let accesses = track_test_bin(&["symlink", "target", "/hello/world"], None).await;
assert_contains(&accesses, Path::new("/hello/world"), fspy::PathAccess::Write);
}

#[test(tokio::test)]
async fn open_relative() {
let accesses = track_test_bin(&["open_read", "hello"], Some("/home")).await;
Expand Down
33 changes: 33 additions & 0 deletions crates/fspy/tests/test_utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,39 @@ pub fn assert_contains<'a>(
);
}

/// Asserts that `expected_path` was accessed as `expected` (a variant such as
/// `PathAccess::Write`), whatever other accesses to it there were.
///
/// # Panics
///
/// Panics if the path was not accessed as `expected`.
#[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_includes<'a>(
accesses: &'a PathAccessIterable,
expected_path: &Path,
expected: fn(&'a IpcPath) -> PathAccess<'a>,
) {
let found = accesses.iter().any(|access| {
access.paths().any(|path| {
access == expected(path)
&& path.strip_path_prefix(expected_path, |strip_result| {
strip_result.is_ok_and(|stripped| stripped.as_os_str().is_empty())
})
})
});
assert!(
found,
"Expected an access to {} of the expected kind. All accesses: {:?}",
expected_path.display(),
accesses.iter().collect::<Vec<_>>()
);
}

/// Asserts that `unexpected_path` was not accessed in any way.
///
/// # Panics
Expand Down
34 changes: 28 additions & 6 deletions crates/fspy_preload_unix/src/interceptions/namespace.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
//! Calls that make an entry at a path without opening it: mkdir, and rename
//! and hard link, which put an existing file or folder at a new path. The
//! path is recorded before the call, so a failed call is recorded too: as a
//! mkdir for a mkdir, whose folder may be there already; as a rename target
//! for a rename, which may move a whole folder; and as a write for a hard
//! link, always to a file. The source of a rename needs no record.
//! Calls that make an entry at a path without opening it: mkdir, symlink,
//! and rename and hard link, which put an existing file or folder at a new
//! path. The path is recorded before the call, so a failed call is recorded
//! too: as a mkdir for a mkdir, whose folder may be there already; as a
//! rename target for a rename, which may move a whole folder; and as a write
//! for a symlink, and for a hard link, always to a file. The source of a
//! rename needs no record, and neither does the target of a symlink, which
//! is stored as text, not accessed.

use fspy_shared::ipc::AccessMode;
use libc::{c_char, c_int, mode_t};
Expand Down Expand Up @@ -126,3 +128,23 @@ unsafe extern "C" fn linkat(
// SAFETY: calling the original libc linkat() with the same arguments forwarded from the interposed function
unsafe { linkat::original()(olddirfd, old, newdirfd, new, flags) }
}

intercept!(symlink: unsafe extern "C" fn(target: *const c_char, link: *const c_char) -> c_int);
unsafe extern "C" fn symlink(target: *const c_char, link: *const c_char) -> c_int {
// SAFETY: link is a valid C string pointer provided by the caller of the interposed function
unsafe { handle_open(fspy_nostd::CStr::from_ptr(link.cast()), AccessMode::Write) };
// SAFETY: calling the original libc symlink() with the same arguments forwarded from the interposed function
unsafe { symlink::original()(target, link) }
}

intercept!(symlinkat: unsafe extern "C" fn(target: *const c_char, newdirfd: c_int, link: *const c_char) -> c_int);
unsafe extern "C" fn symlinkat(
target: *const c_char,
newdirfd: c_int,
link: *const c_char,
) -> c_int {
// SAFETY: newdirfd is -1 or a valid file descriptor, and link is a valid C string, provided by the caller of the interposed function
unsafe { handle_open(PathAt::borrow_raw(newdirfd, link), AccessMode::Write) };
// SAFETY: calling the original libc symlinkat() with the same arguments forwarded from the interposed function
unsafe { symlinkat::original()(target, newdirfd, link) }
}
6 changes: 5 additions & 1 deletion crates/fspy_preload_windows/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ winapi = { workspace = true, features = [
"memoryapi",
"std",
] }
windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_UI_Shell"] }
windows-sys = { workspace = true, features = [
"Win32_Foundation",
"Win32_System_Ioctl",
"Win32_UI_Shell",
] }
winsafe = { workspace = true, features = ["kernel"] }

[target.'cfg(target_os = "windows")'.dev-dependencies]
Expand Down
62 changes: 62 additions & 0 deletions crates/fspy_preload_windows/src/windows/detours/nt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use winapi::{
},
um::winnt::{ACCESS_MASK, GENERIC_READ},
};
use windows_sys::Win32::System::Ioctl::{FSCTL_SET_REPARSE_POINT, FSCTL_SET_REPARSE_POINT_EX};

use crate::windows::{
client::global_client,
Expand Down Expand Up @@ -339,6 +340,66 @@ unsafe fn handle_destination(access_mode: AccessMode, file_information: PVOID, l
unsafe { handle_open(access_mode, &raw mut object_attributes) };
}

// A reparse point set on a file or folder, which makes it a link, is recorded
// as a write before the call, so a failed call is recorded too, as `symlink`
// is on Unix. `CreateSymbolicLinkW` creates the link with an open that lacks
// write access, recorded as a read (or as a mkdir for a folder link), and
// sets its reparse point here. A junction, as libuv makes for Node's
// `fs.symlink` with `'junction'`, is a folder, recorded as a mkdir, with a
// mount point set here.
static DETOUR_NT_FS_CONTROL_FILE: Detour<
unsafe extern "system" fn(
file_handle: HANDLE,
event: HANDLE,
apc_routine: PIO_APC_ROUTINE,
apc_context: PVOID,
io_status_block: PIO_STATUS_BLOCK,
fs_control_code: ULONG,
input_buffer: PVOID,
input_buffer_length: ULONG,
output_buffer: PVOID,
output_buffer_length: ULONG,
) -> NTSTATUS,
> =
// SAFETY: initializing Detour with the real NtFsControlFile function pointer
unsafe {
Detour::new(c"NtFsControlFile", ntapi::ntioapi::NtFsControlFile, {
unsafe extern "system" fn new_fn(
file_handle: HANDLE,
event: HANDLE,
apc_routine: PIO_APC_ROUTINE,
apc_context: PVOID,
io_status_block: PIO_STATUS_BLOCK,
fs_control_code: ULONG,
input_buffer: PVOID,
input_buffer_length: ULONG,
output_buffer: PVOID,
output_buffer_length: ULONG,
) -> NTSTATUS {
if matches!(fs_control_code, FSCTL_SET_REPARSE_POINT | FSCTL_SET_REPARSE_POINT_EX) {
// SAFETY: intercepting reparse point creation to record the file it is set on before forwarding to real function
unsafe { handle_open(AccessMode::Write, file_handle) };
}
// SAFETY: calling the original NtFsControlFile with all original arguments
unsafe {
(DETOUR_NT_FS_CONTROL_FILE.real())(
file_handle,
event,
apc_routine,
apc_context,
io_status_block,
fs_control_code,
input_buffer,
input_buffer_length,
output_buffer,
output_buffer_length,
)
}
}
new_fn
})
};

static DETOUR_NT_OPEN_FILE: Detour<
unsafe extern "system" fn(
file_handle: PHANDLE,
Expand Down Expand Up @@ -641,6 +702,7 @@ pub const DETOURS: &[DetourAny] = &[
DETOUR_NT_CREATE_FILE.as_any(),
DETOUR_NT_OPEN_FILE.as_any(),
DETOUR_NT_SET_INFORMATION_FILE.as_any(),
DETOUR_NT_FS_CONTROL_FILE.as_any(),
DETOUR_NT_QUERY_ATTRIBUTES_FILE.as_any(),
DETOUR_NT_FULL_QUERY_ATTRIBUTES_FILE.as_any(),
DETOUR_NT_OPEN_SYMBOLIC_LINK_OBJECT.as_any(),
Expand Down
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 @@ -57,6 +57,9 @@ fn main() {
"hard_link" => {
let _ = std::fs::hard_link(path, second_path());
}
"symlink" => {
let _ = std::os::unix::fs::symlink(path, second_path());
}
_ => panic!("unknown action: {action}"),
}
}
3 changes: 3 additions & 0 deletions crates/vt/src/session/execute/rebuildable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
//! ignored. A mkdir record counts only for a folder that is empty after the
//! run, whether or not the call made it, since it may have been there
//! already: what any other folder holds decides whether it is rebuildable.
//! A Windows junction is made as a folder before its mount point is set, but
//! std reports it as a symlink, so it counts as written, like any symlink the
//! task made, and not as a folder.

use std::{cmp::Reverse, fs, io};

Expand Down
4 changes: 0 additions & 4 deletions crates/vt_bin/src/vtt/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ mod small_dev_shm;
mod stalled_remote_cache;
mod stat_file;
mod stat_many;
#[cfg(unix)]
mod symlink;
mod touch_file;
mod write_file;
Expand Down Expand Up @@ -84,10 +83,7 @@ fn main() {
Ok(())
}
"stat-many" => stat_many::run(&args[2..]),
#[cfg(unix)]
"symlink" => symlink::run(&args[2..]),
#[cfg(not(unix))]
"symlink" => Err("vtt symlink is only supported on Unix".into()),
"touch-file" => touch_file::run(&args[2..]),
"write-file" => write_file::run(&args[2..]),
other => {
Expand Down
12 changes: 12 additions & 0 deletions crates/vt_bin/src/vtt/symlink.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
/// Create a symbolic link at `<link>` that points to `<target>`, like `ln -s`.
/// On Windows, the link is a directory link if `<target>`, resolved from the
/// link's folder, is a directory.
pub fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let [target, link] = args else {
return Err("Usage: vtt symlink <target> <link>".into());
};
#[cfg(unix)]
std::os::unix::fs::symlink(target, link)?;
#[cfg(windows)]
{
let link = std::path::Path::new(link);
if link.parent().is_some_and(|parent| parent.join(target).is_dir()) {
std::os::windows::fs::symlink_dir(target, link)?;
} else {
std::os::windows::fs::symlink_file(target, link)?;
}
}
Ok(())
}
Loading
Loading