Skip to content

Commit 3008bff

Browse files
authored
fix(fspy): ignore malformed tracked paths without panicking (#330)
## Summary Fixes #325. Windows file access tracking could panic when fspy observed a malformed path such as `foo/C:/bar`. The path is initially accepted as relative, but lexical cleanup can turn it into a drive-prefixed path that no longer satisfies `RelativePath` invariants. - Make `RelativePath::clean()` fallible instead of panicking when cleanup produces a non-relative path - Ignore fspy-observed paths that cannot be represented as valid workspace-relative inputs after cleanup - Add a unit repro for the malformed Windows drive-path case - Add a Windows-only E2E snapshot repro through the fspy input-inference path - Add a changelog entry ## Test plan - [x] `cargo test -p vite_path clean_malformed_drive_path` - [x] `cargo test -p vite_task malformed_windows_drive_path_after_workspace_strip_is_ignored` - [x] `cargo test -p vite_task_bin --test e2e_snapshots -- malformed-fspy-path` - [x] `cargo check -p vite_path -p vite_task` --------- Co-authored-by: SegaraRai <29276700+SegaraRai@users.noreply.github.com>
1 parent 0e041c4 commit 3008bff

File tree

8 files changed

+108
-34
lines changed

8 files changed

+108
-34
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Changelog
22

3+
- **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))
34
- **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))
45
- **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))
56
- **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))

crates/vite_path/src/relative.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,16 @@ impl RelativePath {
7272
/// yields `a/c` instead of the correct `x/c`. Use
7373
/// [`std::fs::canonicalize`] when you need symlink-correct resolution.
7474
///
75-
/// # Panics
75+
/// # Errors
7676
///
77-
/// Panics if the cleaned path is no longer a valid relative path, which
78-
/// should never happen in practice.
79-
#[must_use]
80-
pub fn clean(&self) -> RelativePathBuf {
77+
/// Returns an error if the cleaned path is no longer a valid relative path.
78+
/// This can happen on Windows when malformed inputs such as `foo/C:/bar`
79+
/// are cleaned into drive-prefixed paths.
80+
pub fn clean(&self) -> Result<RelativePathBuf, FromPathError> {
8181
use path_clean::PathClean as _;
8282

8383
let cleaned = self.as_path().clean();
84-
RelativePathBuf::new(cleaned).expect("cleaning a relative path preserves relativity")
84+
RelativePathBuf::new(cleaned)
8585
}
8686

8787
/// Returns a path that, when joined onto `base`, yields `self`.
@@ -441,6 +441,20 @@ mod tests {
441441
assert_eq!(joined_path.as_str(), "baz");
442442
}
443443

444+
#[test]
445+
fn clean() {
446+
let rel_path = RelativePathBuf::new("../foo/../bar").unwrap();
447+
let cleaned = rel_path.clean().unwrap();
448+
assert_eq!(cleaned.as_str(), "../bar");
449+
}
450+
451+
#[cfg(windows)]
452+
#[test]
453+
fn clean_malformed_drive_path() {
454+
let rel_path = RelativePathBuf::new(r"foo\C:\bar").unwrap();
455+
let_assert!(Err(FromPathError::NonRelative) = rel_path.clean());
456+
}
457+
444458
#[test]
445459
fn strip_prefix() {
446460
let rel_path = RelativePathBuf::new("foo/bar/baz").unwrap();

crates/vite_task/src/session/execute/spawn.rs

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,38 @@ pub struct TrackedPathAccesses {
5757
pub path_writes: FxHashSet<RelativePathBuf>,
5858
}
5959

60+
#[expect(
61+
clippy::disallowed_types,
62+
reason = "fspy strip_path_prefix exposes std::path::Path; convert to RelativePathBuf immediately"
63+
)]
64+
fn normalize_tracked_workspace_path(
65+
stripped_path: &std::path::Path,
66+
resolved_negatives: &[wax::Glob<'static>],
67+
) -> Option<RelativePathBuf> {
68+
// On Windows, paths are possible to be still absolute after stripping the workspace root.
69+
// For example: c:\workspace\subdir\c:\workspace\subdir
70+
// Just ignore those accesses.
71+
let relative = RelativePathBuf::new(stripped_path).ok()?;
72+
73+
// Clean `..` components — fspy may report paths like
74+
// `packages/sub-pkg/../shared/dist/output.js`. Normalize them for
75+
// consistent behavior across platforms and clean user-facing messages.
76+
let relative = relative.clean().ok()?;
77+
78+
// Skip .git directory accesses (workaround for tools like oxlint)
79+
if relative.as_path().strip_prefix(".git").is_ok() {
80+
return None;
81+
}
82+
83+
if !resolved_negatives.is_empty()
84+
&& resolved_negatives.iter().any(|neg| neg.is_match(relative.as_str()))
85+
{
86+
return None;
87+
}
88+
89+
Some(relative)
90+
}
91+
6092
/// How the child process is awaited after stdout/stderr are drained.
6193
enum ChildWait {
6294
/// fspy tracking enabled — fspy manages cancellation internally.
@@ -231,28 +263,7 @@ pub async fn spawn_with_tracking(
231263
let Ok(stripped_path) = strip_result else {
232264
return None;
233265
};
234-
// On Windows, paths are possible to be still absolute after stripping the workspace root.
235-
// For example: c:\workspace\subdir\c:\workspace\subdir
236-
// Just ignore those accesses.
237-
let relative = RelativePathBuf::new(stripped_path).ok()?;
238-
239-
// Clean `..` components — fspy may report paths like
240-
// `packages/sub-pkg/../shared/dist/output.js`. Normalize them for
241-
// consistent behavior across platforms and clean user-facing messages.
242-
let relative = relative.clean();
243-
244-
// Skip .git directory accesses (workaround for tools like oxlint)
245-
if relative.as_path().strip_prefix(".git").is_ok() {
246-
return None;
247-
}
248-
249-
if !resolved_negatives.is_empty()
250-
&& resolved_negatives.iter().any(|neg| neg.is_match(relative.as_str()))
251-
{
252-
return None;
253-
}
254-
255-
Some(relative)
266+
normalize_tracked_workspace_path(stripped_path, resolved_negatives)
256267
});
257268

258269
let Some(relative_path) = relative_path else {
@@ -300,3 +311,21 @@ pub async fn spawn_with_tracking(
300311
}
301312
}
302313
}
314+
315+
#[cfg(test)]
316+
mod tests {
317+
#[cfg(windows)]
318+
use super::*;
319+
320+
#[cfg(windows)]
321+
#[test]
322+
fn malformed_windows_drive_path_after_workspace_strip_is_ignored() {
323+
#[expect(
324+
clippy::disallowed_types,
325+
reason = "normalize_tracked_workspace_path requires std::path::Path for fspy strip_path_prefix output"
326+
)]
327+
let relative_path =
328+
normalize_tracked_workspace_path(std::path::Path::new(r"foo\C:\bar"), &[]);
329+
assert!(relative_path.is_none());
330+
}
331+
}

crates/vite_task/src/session/mod.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -345,19 +345,18 @@ impl<'a> Session<'a> {
345345
// created with CREATE_NEW_PROCESS_GROUP, which sets a per-process
346346
// flag that silently drops CTRL_C_EVENT before it reaches
347347
// registered handlers. Clear it so our handler fires.
348+
//
349+
// SAFETY: Passing (None, FALSE) clears the inherited
350+
// CTRL_C ignore flag.
348351
#[cfg(windows)]
349-
{
350-
// SAFETY: Passing (None, FALSE) clears the inherited
351-
// CTRL_C ignore flag.
352+
unsafe {
352353
unsafe extern "system" {
353354
fn SetConsoleCtrlHandler(
354355
handler: Option<unsafe extern "system" fn(u32) -> i32>,
355356
add: i32,
356357
) -> i32;
357358
}
358-
unsafe {
359-
SetConsoleCtrlHandler(None, 0);
360-
}
359+
SetConsoleCtrlHandler(None, 0);
361360
}
362361
let interrupt_token = tokio_util::sync::CancellationToken::new();
363362
let ct = interrupt_token.clone();
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "malformed-fspy-path",
3+
"private": true
4+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Windows-only repro for issue 325: a malformed observed path must not panic
2+
# when fspy input inference normalizes workspace-relative accesses.
3+
4+
[[e2e]]
5+
name = "malformed observed path does not panic"
6+
platform = "windows"
7+
steps = [{ argv = ["vt", "run", "read-malformed-path"], envs = [["TEMP", "."], ["TMP", "."]] }]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
source: crates/vite_task_bin/tests/e2e_snapshots/main.rs
3+
expression: e2e_outputs
4+
---
5+
> TEMP=. TMP=. vt run read-malformed-path
6+
$ vtt print-file foo/C:/bar
7+
foo/C:/bar: not found
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"tasks": {
3+
"read-malformed-path": {
4+
"command": "vtt print-file foo/C:/bar",
5+
"cache": true,
6+
"input": [
7+
{
8+
"auto": true
9+
}
10+
]
11+
}
12+
}
13+
}

0 commit comments

Comments
 (0)