Skip to content

Commit 07d4ddb

Browse files
branchseerclaude
andcommitted
fix: track FindFirstFile/FindNextFile on Windows for cache invalidation
When oxlint scans a directory, it uses FindFirstFile/FindNextFile Win32 APIs to enumerate files. Without intercepting these calls, fspy wouldn't see READ_DIR accesses, causing the cache to miss file additions. This fix: - Adds detours for FindFirstFileW and FindFirstFileExW - Uses GetCurrentDirectoryW for relative patterns like "*" - Fixes fingerprint.rs to handle paths with trailing backslash Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f50f05e commit 07d4ddb

File tree

6 files changed

+238
-1
lines changed

6 files changed

+238
-1
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ dist
55
*.tsbuildinfo
66
.DS_Store
77
/.vscode/settings.json
8+
*.snap.new

crates/fspy/tests/oxlint.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
mod test_utils;
2+
3+
use std::env::vars_os;
4+
5+
use fspy::{AccessMode, PathAccessIterable};
6+
use test_log::test;
7+
8+
/// Find the oxlint executable in test_bins
9+
fn find_oxlint() -> std::path::PathBuf {
10+
let test_bins_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
11+
.parent()
12+
.unwrap()
13+
.join("vite_task_bin")
14+
.join("test_bins")
15+
.join("node_modules")
16+
.join(".bin");
17+
18+
which::which_in("oxlint", Some(&test_bins_dir), std::env::current_dir().unwrap())
19+
.expect("oxlint not found in test_bins/node_modules/.bin")
20+
}
21+
22+
async fn track_oxlint(dir: &std::path::Path, args: &[&str]) -> anyhow::Result<PathAccessIterable> {
23+
let oxlint_path = find_oxlint();
24+
let mut command = fspy::Command::new(&oxlint_path);
25+
command.args(args).envs(vars_os()).current_dir(dir);
26+
27+
let child = command.spawn().await?;
28+
let termination = child.wait_handle.await?;
29+
// oxlint may return non-zero if it finds lint errors, that's OK
30+
Ok(termination.path_accesses)
31+
}
32+
33+
#[test(tokio::test)]
34+
async fn oxlint_reads_js_file() -> anyhow::Result<()> {
35+
let tmpdir = tempfile::tempdir()?;
36+
let js_file = tmpdir.path().join("test.js");
37+
std::fs::write(&js_file, "console.log('hello');")?;
38+
39+
let accesses = track_oxlint(tmpdir.path(), &[]).await?;
40+
41+
// Check that oxlint read the JS file
42+
test_utils::assert_contains(&accesses, &js_file, AccessMode::READ);
43+
44+
Ok(())
45+
}
46+
47+
#[test(tokio::test)]
48+
async fn oxlint_reads_directory() -> anyhow::Result<()> {
49+
let tmpdir = tempfile::tempdir()?;
50+
let js_file = tmpdir.path().join("test.js");
51+
std::fs::write(&js_file, "console.log('hello');")?;
52+
53+
let accesses = track_oxlint(tmpdir.path(), &[]).await?;
54+
55+
// Check that oxlint read the directory to find JS files
56+
// This is the key check - if READ_DIR is not tracked, cache won't detect new files
57+
test_utils::assert_contains(&accesses, tmpdir.path(), AccessMode::READ_DIR);
58+
59+
Ok(())
60+
}
61+
62+
#[test(tokio::test)]
63+
async fn oxlint_empty_directory_reads_dir() -> anyhow::Result<()> {
64+
let tmpdir = tempfile::tempdir()?;
65+
// No files in directory
66+
67+
let accesses = track_oxlint(tmpdir.path(), &[]).await?;
68+
69+
// Even with no JS files, oxlint should read the directory to discover files
70+
// Print all accesses for debugging
71+
println!("All accesses in empty directory:");
72+
for access in accesses.iter() {
73+
println!(" {:?}", access);
74+
}
75+
76+
// This assertion may fail - that would indicate the bug
77+
test_utils::assert_contains(&accesses, tmpdir.path(), AccessMode::READ_DIR);
78+
79+
Ok(())
80+
}

crates/fspy_preload_windows/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ fspy_shared = { workspace = true }
1414
ntapi = { workspace = true }
1515
smallvec = { workspace = true }
1616
widestring = { workspace = true }
17-
winapi = { workspace = true, features = ["winerror", "winbase", "namedpipeapi", "memoryapi", "std"] }
17+
winapi = { workspace = true, features = ["winerror", "winbase", "namedpipeapi", "memoryapi", "std", "processenv"] }
1818
winsafe = { workspace = true }
1919

2020
[target.'cfg(target_os = "windows")'.dev-dependencies]
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
//! Detours for FindFirstFile/FindNextFile APIs to track directory reads.
2+
//!
3+
//! These Win32 APIs are commonly used for directory enumeration and need to be
4+
//! intercepted to track READ_DIR accesses.
5+
6+
#![allow(non_snake_case)] // Windows API parameter naming convention
7+
8+
use fspy_shared::ipc::{AccessMode, NativeStr, PathAccess};
9+
use smallvec::SmallVec;
10+
use widestring::U16CStr;
11+
use winapi::{
12+
shared::{
13+
minwindef::{DWORD, LPVOID, MAX_PATH},
14+
ntdef::HANDLE,
15+
},
16+
um::{
17+
fileapi::FindFirstFileExW,
18+
minwinbase::{FINDEX_INFO_LEVELS, FINDEX_SEARCH_OPS, LPWIN32_FIND_DATAW},
19+
processenv::GetCurrentDirectoryW,
20+
winnt::LPCWSTR,
21+
},
22+
};
23+
24+
use crate::windows::{
25+
client::global_client,
26+
detour::{Detour, DetourAny},
27+
};
28+
29+
/// Get the current directory as a wide string
30+
fn get_current_directory() -> SmallVec<u16, MAX_PATH> {
31+
let mut buffer = SmallVec::<u16, MAX_PATH>::new();
32+
buffer.resize(MAX_PATH, 0);
33+
34+
let len = unsafe { GetCurrentDirectoryW(buffer.len() as DWORD, buffer.as_mut_ptr()) };
35+
if len == 0 {
36+
// Failed to get current directory, return empty
37+
return SmallVec::new();
38+
}
39+
40+
let len = len as usize;
41+
if len > buffer.len() {
42+
// Buffer too small, allocate more
43+
buffer.resize(len, 0);
44+
let len = unsafe { GetCurrentDirectoryW(buffer.len() as DWORD, buffer.as_mut_ptr()) };
45+
if len == 0 {
46+
return SmallVec::new();
47+
}
48+
buffer.truncate(len as usize);
49+
} else {
50+
buffer.truncate(len);
51+
}
52+
53+
buffer
54+
}
55+
56+
/// Extract the directory path from a search pattern like "C:\foo\*" -> "C:\foo"
57+
/// For patterns without a directory separator (like "*"), returns the current directory
58+
fn extract_directory_from_pattern(pattern: &U16CStr) -> SmallVec<u16, MAX_PATH> {
59+
let slice = pattern.as_slice();
60+
61+
// Find the last backslash or forward slash
62+
if let Some(last_sep_pos) = slice.iter().rposition(|&c| c == b'\\' as u16 || c == b'/' as u16) {
63+
// Return the directory part (without trailing separator)
64+
slice[..last_sep_pos].iter().cloned().collect()
65+
} else {
66+
// No separator found - pattern is in current directory (e.g., "*" or "*.js")
67+
// Return the current directory
68+
get_current_directory()
69+
}
70+
}
71+
72+
static DETOUR_FIND_FIRST_FILE_EX_W: Detour<
73+
unsafe extern "system" fn(
74+
lpFileName: LPCWSTR,
75+
fInfoLevelId: FINDEX_INFO_LEVELS,
76+
lpFindFileData: LPVOID,
77+
fSearchOp: FINDEX_SEARCH_OPS,
78+
lpSearchFilter: LPVOID,
79+
dwAdditionalFlags: DWORD,
80+
) -> HANDLE,
81+
> = unsafe {
82+
Detour::new(c"FindFirstFileExW", FindFirstFileExW, {
83+
unsafe extern "system" fn new_find_first_file_ex_w(
84+
lpFileName: LPCWSTR,
85+
fInfoLevelId: FINDEX_INFO_LEVELS,
86+
lpFindFileData: LPVOID,
87+
fSearchOp: FINDEX_SEARCH_OPS,
88+
lpSearchFilter: LPVOID,
89+
dwAdditionalFlags: DWORD,
90+
) -> HANDLE {
91+
// Track the directory access before calling the real function
92+
if !lpFileName.is_null() {
93+
let pattern = unsafe { U16CStr::from_ptr_str(lpFileName) };
94+
let dir_path = extract_directory_from_pattern(pattern);
95+
let client = unsafe { global_client() };
96+
let path_access = PathAccess {
97+
mode: AccessMode::READ_DIR,
98+
path: NativeStr::from_wide(&dir_path),
99+
};
100+
client.send(path_access);
101+
}
102+
103+
// Call the original function
104+
unsafe {
105+
(DETOUR_FIND_FIRST_FILE_EX_W.real())(
106+
lpFileName,
107+
fInfoLevelId,
108+
lpFindFileData,
109+
fSearchOp,
110+
lpSearchFilter,
111+
dwAdditionalFlags,
112+
)
113+
}
114+
}
115+
new_find_first_file_ex_w
116+
})
117+
};
118+
119+
// FindFirstFileW is typically a wrapper around FindFirstFileExW, but let's intercept it too
120+
// in case some applications call it directly
121+
static DETOUR_FIND_FIRST_FILE_W: Detour<
122+
unsafe extern "system" fn(lpFileName: LPCWSTR, lpFindFileData: LPWIN32_FIND_DATAW) -> HANDLE,
123+
> = unsafe {
124+
Detour::new(c"FindFirstFileW", winapi::um::fileapi::FindFirstFileW, {
125+
unsafe extern "system" fn new_find_first_file_w(
126+
lpFileName: LPCWSTR,
127+
lpFindFileData: LPWIN32_FIND_DATAW,
128+
) -> HANDLE {
129+
// Track the directory access before calling the real function
130+
if !lpFileName.is_null() {
131+
let pattern = unsafe { U16CStr::from_ptr_str(lpFileName) };
132+
let dir_path = extract_directory_from_pattern(pattern);
133+
let client = unsafe { global_client() };
134+
let path_access = PathAccess {
135+
mode: AccessMode::READ_DIR,
136+
path: NativeStr::from_wide(&dir_path),
137+
};
138+
client.send(path_access);
139+
}
140+
141+
// Call the original function
142+
unsafe { (DETOUR_FIND_FIRST_FILE_W.real())(lpFileName, lpFindFileData) }
143+
}
144+
new_find_first_file_w
145+
})
146+
};
147+
148+
pub const DETOURS: &[DetourAny] =
149+
&[DETOUR_FIND_FIRST_FILE_EX_W.as_any(), DETOUR_FIND_FIRST_FILE_W.as_any()];

crates/fspy_preload_windows/src/windows/detours/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod create_process;
2+
mod find_file;
23
mod nt;
34

45
use constcat::concat_slices;
@@ -7,5 +8,6 @@ use super::detour::DetourAny;
78

89
pub const DETOURS: &[DetourAny] = concat_slices!([DetourAny]:
910
create_process::DETOURS,
11+
find_file::DETOURS,
1012
nt::DETOURS,
1113
);

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,11 @@ pub fn fingerprint_path(
169169
// This might be a directory - try reading it as such
170170
return process_directory(std_path, path_read);
171171
}
172+
// On Windows, paths with trailing backslash (from joining empty path)
173+
// fail with NotFound (error code 3). Try as directory in this case.
174+
if err.raw_os_error() == Some(3) && std_path.to_string_lossy().ends_with('\\') {
175+
return process_directory(std_path, path_read);
176+
}
172177
}
173178
if err.kind() != io::ErrorKind::NotFound {
174179
tracing::trace!(

0 commit comments

Comments
 (0)