From 692e8a81f381eebd84616cca6da8022d0107d420 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 30 Oct 2026 11:36:33 +0900 Subject: [PATCH 1/2] test(e2e): show that symlinks a task creates aren't outputs Two cases for a task that creates symbolic links. In `symlink_in_output`, the task links `out/link` to `out/target.txt` with `vtt symlink`, which now works on Windows too, making a directory link when the target is a directory. In `nitro_auto_outputs_restore_links`, the Nitro build of `nitro_build_cache` runs with automatic tracking only, without `input` or `output` globs; nf3 links `tslib` and each package's `tslib` into `.nf3`. fspy records nothing for `symlink` on Unix, so a link a task creates isn't an output. Removing `out/link` is a cache hit that leaves it missing. nf3 checks each path before linking it, so Nitro's links are plain inputs, and removing `.output`, as on a clean checkout, is a cache miss. Several inputs are then missing, and vt names whichever its parallel validation finds first, so the step redacts its miss reason. Both cases are Unix only for now. On Windows, `CreateSymbolicLinkW` opens the new link without write access, so fspy records it as a read, and removing `out/link` is a cache miss. nf3 makes junctions there, which libuv opens with write access before setting the reparse point, so they're recorded as written already. Co-Authored-By: Claude Opus 5.5 --- crates/vt_bin/src/vtt/main.rs | 4 -- crates/vt_bin/src/vtt/symlink.rs | 12 +++++ .../fixtures/nitro_build_cache/snapshots.toml | 37 ++++++++++++++ .../nitro_auto_outputs_restore_links.md | 50 +++++++++++++++++++ .../fixtures/nitro_build_cache/vite-task.json | 4 ++ .../fixtures/symlink_in_output/out/target.txt | 1 + .../fixtures/symlink_in_output/package.json | 4 ++ .../fixtures/symlink_in_output/snapshots.toml | 30 +++++++++++ .../snapshots/symlink_in_output.md | 33 ++++++++++++ .../fixtures/symlink_in_output/vite-task.json | 8 +++ 10 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_auto_outputs_restore_links.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/out/target.txt create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/package.json create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots.toml create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots/symlink_in_output.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/vite-task.json diff --git a/crates/vt_bin/src/vtt/main.rs b/crates/vt_bin/src/vtt/main.rs index 44aa0b41c..00c221c16 100644 --- a/crates/vt_bin/src/vtt/main.rs +++ b/crates/vt_bin/src/vtt/main.rs @@ -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; @@ -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 => { diff --git a/crates/vt_bin/src/vtt/symlink.rs b/crates/vt_bin/src/vtt/symlink.rs index 2ab960572..e0172f41c 100644 --- a/crates/vt_bin/src/vtt/symlink.rs +++ b/crates/vt_bin/src/vtt/symlink.rs @@ -1,8 +1,20 @@ /// Create a symbolic link at `` that points to ``, like `ln -s`. +/// On Windows, the link is a directory link if ``, resolved from the +/// link's folder, is a directory. pub fn run(args: &[String]) -> Result<(), Box> { let [target, link] = args else { return Err("Usage: vtt symlink ".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(()) } diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml index 422b82f62..c07cef888 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml @@ -30,3 +30,40 @@ steps = [ "check.cjs", ], comment = "the restored output has the same links, not copies, and resolves the same versions" }, ] + +[[e2e]] +name = "nitro_auto_outputs_restore_links" +comment = """ +The same Nitro build with automatic input and output tracking only, no `input` or `output` globs. nf3 checks each path before linking it, so the links are among the paths the task read. +""" +# Unix only for now: on Windows, nf3 makes junctions, which libuv opens for +# writing before turning them into links, so they're recorded as written there +# already. +cfg = "unix" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "build-auto", + ], comment = "first run — cache miss, Nitro builds .output/" }, + [ + "node", + "check.cjs", + ], + { argv = [ + "vtt", + "rm", + "-rf", + ".output", + ], comment = "remove .output/, as on a clean checkout" }, + { argv = [ + "vt", + "run", + "build-auto", + ], comment = "several inputs are missing and vt names whichever it finds first, so the miss reason is redacted", redact = { 'cache miss: .*, executing' = 'cache miss: , executing' } }, + { argv = [ + "node", + "check.cjs", + ], comment = "the output has the same links, not copies, and resolves the same versions" }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_auto_outputs_restore_links.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_auto_outputs_restore_links.md new file mode 100644 index 000000000..08b7d48a2 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_auto_outputs_restore_links.md @@ -0,0 +1,50 @@ +# nitro_auto_outputs_restore_links + +The same Nitro build with automatic input and output tracking only, no `input` or `output` globs. nf3 checks each path before linking it, so the links are among the paths the task read. + +## `vt run build-auto` + +first run — cache miss, Nitro builds .output/ + +``` +$ CONSOLA_LEVEL=0 nitro build +``` + +## `node check.cjs` + +``` +tslib: link to .nf3/tslib@2.8.1 +dep-a/node_modules/tslib: link to ../../.nf3/tslib@1.14.1 +dep-b/node_modules/tslib: link to ../../.nf3/tslib@2.8.1 +dep-a: tslib 1.14.1 +dep-b: tslib 2.8.1 +hoisted: tslib 2.8.1 +``` + +## `vtt rm -rf .output` + +remove .output/, as on a clean checkout + +``` +``` + +## `vt run build-auto` + +several inputs are missing and vt names whichever it finds first, so the miss reason is redacted + +``` +$ CONSOLA_LEVEL=0 nitro build ○ cache miss: , executing +``` + +## `node check.cjs` + +the output has the same links, not copies, and resolves the same versions + +``` +tslib: link to .nf3/tslib@2.8.1 +dep-a/node_modules/tslib: link to ../../.nf3/tslib@1.14.1 +dep-b/node_modules/tslib: link to ../../.nf3/tslib@2.8.1 +dep-a: tslib 1.14.1 +dep-b: tslib 2.8.1 +hoisted: tslib 2.8.1 +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json index f53f3a9da..113c8977f 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json @@ -6,6 +6,10 @@ "input": ["routes/**", "nitro.config.ts"], "output": [".output/**"] } + }, + "build-auto": { + "command": "CONSOLA_LEVEL=0 nitro build", + "cache": true } } } diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/out/target.txt b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/out/target.txt new file mode 100644 index 000000000..ce0136250 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/out/target.txt @@ -0,0 +1 @@ +hello diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/package.json new file mode 100644 index 000000000..b63abe658 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/package.json @@ -0,0 +1,4 @@ +{ + "name": "symlink-in-output", + "private": true +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots.toml new file mode 100644 index 000000000..6459fafd3 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots.toml @@ -0,0 +1,30 @@ +[[e2e]] +name = "symlink_in_output" +comment = """ +The task links `out/link` to `out/target.txt`, a file in the fixture, like a build tool linking a dependency into its output. +""" +# Unix only for now: on Windows, the link is recorded as a read, so removing it +# is a cache miss there. +cfg = "unix" +steps = [ + [ + "vt", + "run", + "build", + ], + { argv = [ + "vtt", + "rm", + "out/link", + ], comment = "remove the link, as on a clean checkout" }, + [ + "vt", + "run", + "build", + ], + [ + "vtt", + "read-link", + "out/link", + ], +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots/symlink_in_output.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots/symlink_in_output.md new file mode 100644 index 000000000..8041ab90e --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots/symlink_in_output.md @@ -0,0 +1,33 @@ +# symlink_in_output + +The task links `out/link` to `out/target.txt`, a file in the fixture, like a build tool linking a dependency into its output. + +## `vt run build` + +``` +$ vtt symlink target.txt out/link +``` + +## `vtt rm out/link` + +remove the link, as on a clean checkout + +``` +``` + +## `vt run build` + +``` +$ vtt symlink target.txt out/link ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `vtt read-link out/link` + +**Exit code:** 1 + +``` +No such file or directory (os error 2) +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/vite-task.json new file mode 100644 index 000000000..4ee14470c --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "build": { + "command": "vtt symlink target.txt out/link", + "cache": true + } + } +} From 25a585d68690684e0804ed57514f7a7f526be9c7 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 30 Oct 2026 11:50:38 +0900 Subject: [PATCH 2/2] feat(cache): restore symlinks a task creates A task that creates a symbolic link, like Nitro's dependency tracer linking `.output/server/node_modules/tslib` into `.nf3`, didn't have the link among its outputs unless an `output` glob matched it: fspy recorded nothing for `symlink`, so a hit left the link out, and when the task checked the path first, as nf3 does, the link was a plain input, so removing it, as on a clean checkout, was a cache miss. fspy now records the path of a new link as a write, before the call, so a failed call is recorded too. The target is stored as text, not accessed, and needs no record. - Unix preload: `symlink` and `symlinkat`. - Linux seccomp supervisor, for static binaries: `symlinkat`, plus `symlink` on x86_64. - Windows: `NtFsControlFile` with `FSCTL_SET_REPARSE_POINT(_EX)`, for the file the reparse point is set on. `CreateSymbolicLinkW` creates the link with an open that lacks write access, recorded as a read, and then sets its reparse point. A junction, which libuv makes for Node's `fs.symlink` with `'junction'` and nf3 uses on Windows, is a folder with a mount point set on it; libuv opens the folder with write access first, so those junctions were recorded as written already. vt needs no change: `Rebuildable` treats a written symlink as a file, and the archive stores links as links (#885). std reports a junction as a symlink, not a folder, so the mkdir recorded for it doesn't make it an empty folder, and the write makes it rebuildable. Both e2e cases now run on Windows too. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + crates/fspy/src/unix/syscall_handler/mod.rs | 2 + .../src/unix/syscall_handler/namespace.rs | 31 ++++++++-- crates/fspy/tests/node_fs.rs | 28 +++++++++ crates/fspy/tests/rust_std.rs | 36 +++++++++++ crates/fspy/tests/static_executable.rs | 8 +++ crates/fspy/tests/test_utils/mod.rs | 33 ++++++++++ .../src/interceptions/namespace.rs | 34 ++++++++-- crates/fspy_preload_windows/Cargo.toml | 6 +- .../src/windows/detours/nt.rs | 62 +++++++++++++++++++ crates/fspy_test_bin/src/main.rs | 3 + crates/vt/src/session/execute/rebuildable.rs | 3 + .../fixtures/nitro_build_cache/snapshots.toml | 4 -- .../nitro_auto_outputs_restore_links.md | 5 +- .../fixtures/symlink_in_output/snapshots.toml | 3 - .../snapshots/symlink_in_output.md | 4 +- 16 files changed, 239 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b382b4834..4c27e5249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/crates/fspy/src/unix/syscall_handler/mod.rs b/crates/fspy/src/unix/syscall_handler/mod.rs index a84ccd853..d55ce8fbb 100644 --- a/crates/fspy/src/unix/syscall_handler/mod.rs +++ b/crates/fspy/src/unix/syscall_handler/mod.rs @@ -112,6 +112,8 @@ impl_handler!( renameat2, #[cfg(target_arch = "x86_64")] link, linkat, + #[cfg(target_arch = "x86_64")] symlink, + symlinkat, execve, execveat, diff --git a/crates/fspy/src/unix/syscall_handler/namespace.rs b/crates/fspy/src/unix/syscall_handler/namespace.rs index 8f8a73715..2eccd1f4b 100644 --- a/crates/fspy/src/unix/syscall_handler/namespace.rs +++ b/crates/fspy/src/unix/syscall_handler/namespace.rs @@ -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; @@ -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) + } } diff --git a/crates/fspy/tests/node_fs.rs b/crates/fspy/tests/node_fs.rs index 3940b4dc2..7430bd6a5 100644 --- a/crates/fspy/tests/node_fs.rs +++ b/crates/fspy/tests/node_fs.rs @@ -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"))] diff --git a/crates/fspy/tests/rust_std.rs b/crates/fspy/tests/rust_std.rs index d7776c3c2..ce4a251e9 100644 --- a/crates/fspy/tests/rust_std.rs +++ b/crates/fspy/tests/rust_std.rs @@ -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()?; diff --git a/crates/fspy/tests/static_executable.rs b/crates/fspy/tests/static_executable.rs index c095de671..80a2d6400 100644 --- a/crates/fspy/tests/static_executable.rs +++ b/crates/fspy/tests/static_executable.rs @@ -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; diff --git a/crates/fspy/tests/test_utils/mod.rs b/crates/fspy/tests/test_utils/mod.rs index f47bb6837..485b269fa 100644 --- a/crates/fspy/tests/test_utils/mod.rs +++ b/crates/fspy/tests/test_utils/mod.rs @@ -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::>() + ); +} + /// Asserts that `unexpected_path` was not accessed in any way. /// /// # Panics diff --git a/crates/fspy_preload_unix/src/interceptions/namespace.rs b/crates/fspy_preload_unix/src/interceptions/namespace.rs index c4e1fbf3c..7f33c7404 100644 --- a/crates/fspy_preload_unix/src/interceptions/namespace.rs +++ b/crates/fspy_preload_unix/src/interceptions/namespace.rs @@ -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}; @@ -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) } +} diff --git a/crates/fspy_preload_windows/Cargo.toml b/crates/fspy_preload_windows/Cargo.toml index e9f0dfd57..88f512fa3 100644 --- a/crates/fspy_preload_windows/Cargo.toml +++ b/crates/fspy_preload_windows/Cargo.toml @@ -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] diff --git a/crates/fspy_preload_windows/src/windows/detours/nt.rs b/crates/fspy_preload_windows/src/windows/detours/nt.rs index e38da4e03..769f74f25 100644 --- a/crates/fspy_preload_windows/src/windows/detours/nt.rs +++ b/crates/fspy_preload_windows/src/windows/detours/nt.rs @@ -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, @@ -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, @@ -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(), diff --git a/crates/fspy_test_bin/src/main.rs b/crates/fspy_test_bin/src/main.rs index 4bd6dbc50..d1a059a0d 100644 --- a/crates/fspy_test_bin/src/main.rs +++ b/crates/fspy_test_bin/src/main.rs @@ -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}"), } } diff --git a/crates/vt/src/session/execute/rebuildable.rs b/crates/vt/src/session/execute/rebuildable.rs index 162aa7fd5..d64837375 100644 --- a/crates/vt/src/session/execute/rebuildable.rs +++ b/crates/vt/src/session/execute/rebuildable.rs @@ -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}; diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml index c07cef888..80494cae7 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml @@ -36,10 +36,6 @@ name = "nitro_auto_outputs_restore_links" comment = """ The same Nitro build with automatic input and output tracking only, no `input` or `output` globs. nf3 checks each path before linking it, so the links are among the paths the task read. """ -# Unix only for now: on Windows, nf3 makes junctions, which libuv opens for -# writing before turning them into links, so they're recorded as written there -# already. -cfg = "unix" ignore = true steps = [ { argv = [ diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_auto_outputs_restore_links.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_auto_outputs_restore_links.md index 08b7d48a2..1d535c5de 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_auto_outputs_restore_links.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_auto_outputs_restore_links.md @@ -33,7 +33,10 @@ remove .output/, as on a clean checkout several inputs are missing and vt names whichever it finds first, so the miss reason is redacted ``` -$ CONSOLA_LEVEL=0 nitro build ○ cache miss: , executing +$ CONSOLA_LEVEL=0 nitro build ◉ cache hit, replaying + +--- +vt run: cache hit. ``` ## `node check.cjs` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots.toml index 6459fafd3..0920bba7e 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots.toml @@ -3,9 +3,6 @@ name = "symlink_in_output" comment = """ The task links `out/link` to `out/target.txt`, a file in the fixture, like a build tool linking a dependency into its output. """ -# Unix only for now: on Windows, the link is recorded as a read, so removing it -# is a cache miss there. -cfg = "unix" steps = [ [ "vt", diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots/symlink_in_output.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots/symlink_in_output.md index 8041ab90e..8cfba5c98 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots/symlink_in_output.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots/symlink_in_output.md @@ -26,8 +26,6 @@ vt run: cache hit. ## `vtt read-link out/link` -**Exit code:** 1 - ``` -No such file or directory (os error 2) +target.txt ```