diff --git a/CHANGELOG.md b/CHANGELOG.md index 851dba695..3eef1d6f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** Windows file access tracking no longer panics when a task touches malformed paths that cannot be represented as workspace-relative inputs ([#330](https://github.com/voidzero-dev/vite-task/pull/330)) - **Fixed** `vp run --cache` now supports running without a task specifier and opens the interactive task selector, matching bare `vp run` behavior ([#312](https://github.com/voidzero-dev/vite-task/pull/313)) - **Fixed** Ctrl-C now prevents future tasks from being scheduled and prevents caching of in-flight task results ([#309](https://github.com/voidzero-dev/vite-task/pull/309)) - **Added** `--concurrency-limit` flag to limit the number of tasks running at the same time (defaults to 4) ([#288](https://github.com/voidzero-dev/vite-task/pull/288), [#309](https://github.com/voidzero-dev/vite-task/pull/309)) diff --git a/crates/vite_path/src/relative.rs b/crates/vite_path/src/relative.rs index 3d3bd8fe5..25b38804b 100644 --- a/crates/vite_path/src/relative.rs +++ b/crates/vite_path/src/relative.rs @@ -72,16 +72,16 @@ impl RelativePath { /// yields `a/c` instead of the correct `x/c`. Use /// [`std::fs::canonicalize`] when you need symlink-correct resolution. /// - /// # Panics + /// # Errors /// - /// Panics if the cleaned path is no longer a valid relative path, which - /// should never happen in practice. - #[must_use] - pub fn clean(&self) -> RelativePathBuf { + /// Returns an error if the cleaned path is no longer a valid relative path. + /// This can happen on Windows when malformed inputs such as `foo/C:/bar` + /// are cleaned into drive-prefixed paths. + pub fn clean(&self) -> Result { use path_clean::PathClean as _; let cleaned = self.as_path().clean(); - RelativePathBuf::new(cleaned).expect("cleaning a relative path preserves relativity") + RelativePathBuf::new(cleaned) } /// Returns a path that, when joined onto `base`, yields `self`. @@ -441,6 +441,20 @@ mod tests { assert_eq!(joined_path.as_str(), "baz"); } + #[test] + fn clean() { + let rel_path = RelativePathBuf::new("../foo/../bar").unwrap(); + let cleaned = rel_path.clean().unwrap(); + assert_eq!(cleaned.as_str(), "../bar"); + } + + #[cfg(windows)] + #[test] + fn clean_malformed_drive_path() { + let rel_path = RelativePathBuf::new(r"foo\C:\bar").unwrap(); + let_assert!(Err(FromPathError::NonRelative) = rel_path.clean()); + } + #[test] fn strip_prefix() { let rel_path = RelativePathBuf::new("foo/bar/baz").unwrap(); diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 6a3b7842b..9c1ccb8ef 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -57,6 +57,38 @@ pub struct TrackedPathAccesses { pub path_writes: FxHashSet, } +#[expect( + clippy::disallowed_types, + reason = "fspy strip_path_prefix exposes std::path::Path; convert to RelativePathBuf immediately" +)] +fn normalize_tracked_workspace_path( + stripped_path: &std::path::Path, + resolved_negatives: &[wax::Glob<'static>], +) -> Option { + // On Windows, paths are possible to be still absolute after stripping the workspace root. + // For example: c:\workspace\subdir\c:\workspace\subdir + // Just ignore those accesses. + let relative = RelativePathBuf::new(stripped_path).ok()?; + + // Clean `..` components — fspy may report paths like + // `packages/sub-pkg/../shared/dist/output.js`. Normalize them for + // consistent behavior across platforms and clean user-facing messages. + let relative = relative.clean().ok()?; + + // Skip .git directory accesses (workaround for tools like oxlint) + if relative.as_path().strip_prefix(".git").is_ok() { + return None; + } + + if !resolved_negatives.is_empty() + && resolved_negatives.iter().any(|neg| neg.is_match(relative.as_str())) + { + return None; + } + + Some(relative) +} + /// How the child process is awaited after stdout/stderr are drained. enum ChildWait { /// fspy tracking enabled — fspy manages cancellation internally. @@ -231,28 +263,7 @@ pub async fn spawn_with_tracking( let Ok(stripped_path) = strip_result else { return None; }; - // On Windows, paths are possible to be still absolute after stripping the workspace root. - // For example: c:\workspace\subdir\c:\workspace\subdir - // Just ignore those accesses. - let relative = RelativePathBuf::new(stripped_path).ok()?; - - // Clean `..` components — fspy may report paths like - // `packages/sub-pkg/../shared/dist/output.js`. Normalize them for - // consistent behavior across platforms and clean user-facing messages. - let relative = relative.clean(); - - // Skip .git directory accesses (workaround for tools like oxlint) - if relative.as_path().strip_prefix(".git").is_ok() { - return None; - } - - if !resolved_negatives.is_empty() - && resolved_negatives.iter().any(|neg| neg.is_match(relative.as_str())) - { - return None; - } - - Some(relative) + normalize_tracked_workspace_path(stripped_path, resolved_negatives) }); let Some(relative_path) = relative_path else { @@ -300,3 +311,21 @@ pub async fn spawn_with_tracking( } } } + +#[cfg(test)] +mod tests { + #[cfg(windows)] + use super::*; + + #[cfg(windows)] + #[test] + fn malformed_windows_drive_path_after_workspace_strip_is_ignored() { + #[expect( + clippy::disallowed_types, + reason = "normalize_tracked_workspace_path requires std::path::Path for fspy strip_path_prefix output" + )] + let relative_path = + normalize_tracked_workspace_path(std::path::Path::new(r"foo\C:\bar"), &[]); + assert!(relative_path.is_none()); + } +} diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 1becb8b66..a6968c90e 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -345,19 +345,18 @@ impl<'a> Session<'a> { // created with CREATE_NEW_PROCESS_GROUP, which sets a per-process // flag that silently drops CTRL_C_EVENT before it reaches // registered handlers. Clear it so our handler fires. + // + // SAFETY: Passing (None, FALSE) clears the inherited + // CTRL_C ignore flag. #[cfg(windows)] - { - // SAFETY: Passing (None, FALSE) clears the inherited - // CTRL_C ignore flag. + unsafe { unsafe extern "system" { fn SetConsoleCtrlHandler( handler: Option i32>, add: i32, ) -> i32; } - unsafe { - SetConsoleCtrlHandler(None, 0); - } + SetConsoleCtrlHandler(None, 0); } let interrupt_token = tokio_util::sync::CancellationToken::new(); let ct = interrupt_token.clone(); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/malformed-fspy-path/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/malformed-fspy-path/package.json new file mode 100644 index 000000000..13bc35eb6 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/malformed-fspy-path/package.json @@ -0,0 +1,4 @@ +{ + "name": "malformed-fspy-path", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/malformed-fspy-path/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/malformed-fspy-path/snapshots.toml new file mode 100644 index 000000000..429b5303b --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/malformed-fspy-path/snapshots.toml @@ -0,0 +1,7 @@ +# Windows-only repro for issue 325: a malformed observed path must not panic +# when fspy input inference normalizes workspace-relative accesses. + +[[e2e]] +name = "malformed observed path does not panic" +platform = "windows" +steps = [{ argv = ["vt", "run", "read-malformed-path"], envs = [["TEMP", "."], ["TMP", "."]] }] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/malformed-fspy-path/snapshots/malformed observed path does not panic.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/malformed-fspy-path/snapshots/malformed observed path does not panic.snap new file mode 100644 index 000000000..fe34e746c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/malformed-fspy-path/snapshots/malformed observed path does not panic.snap @@ -0,0 +1,7 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> TEMP=. TMP=. vt run read-malformed-path +$ vtt print-file foo/C:/bar +foo/C:/bar: not found diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/malformed-fspy-path/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/malformed-fspy-path/vite-task.json new file mode 100644 index 000000000..c6790a866 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/malformed-fspy-path/vite-task.json @@ -0,0 +1,13 @@ +{ + "tasks": { + "read-malformed-path": { + "command": "vtt print-file foo/C:/bar", + "cache": true, + "input": [ + { + "auto": true + } + ] + } + } +}