diff --git a/crates/fspy/tests/oxlint.rs b/crates/fspy/tests/oxlint.rs index f093aab0..397a1e34 100644 --- a/crates/fspy/tests/oxlint.rs +++ b/crates/fspy/tests/oxlint.rs @@ -1,20 +1,24 @@ mod test_utils; -use std::{env::vars_os, process::Stdio}; +use std::{env::vars_os, ffi::OsString}; use fspy::{AccessMode, PathAccessIterable}; use test_log::test; -/// Find the oxlint executable in test_bins -fn find_oxlint() -> std::path::PathBuf { - let test_bins_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) +/// Get the test_bins/.bin directory path +fn test_bins_bin_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .join("vite_task_bin") .join("test_bins") .join("node_modules") - .join(".bin"); + .join(".bin") +} +/// Find the oxlint executable in test_bins +fn find_oxlint() -> std::path::PathBuf { + let test_bins_dir = test_bins_bin_dir(); which::which_in("oxlint", Some(&test_bins_dir), std::env::current_dir().unwrap()) .expect("oxlint not found in test_bins/node_modules/.bin") } @@ -22,7 +26,22 @@ fn find_oxlint() -> std::path::PathBuf { async fn track_oxlint(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { let oxlint_path = find_oxlint(); let mut command = fspy::Command::new(&oxlint_path); - command.args(args).stdout(Stdio::null()).stderr(Stdio::null()).envs(vars_os()).current_dir(dir); + + // Build PATH with test_bins/.bin prepended so oxlint can find tsgolint + let test_bins_dir = test_bins_bin_dir(); + let new_path = if let Some(existing_path) = std::env::var_os("PATH") { + let mut paths = vec![test_bins_dir.as_os_str().to_owned()]; + paths.extend(std::env::split_paths(&existing_path).map(|p| p.into_os_string())); + std::env::join_paths(paths)? + } else { + OsString::from(&test_bins_dir) + }; + + command + .args(args) + .envs(vars_os().filter(|(k, _)| !k.eq_ignore_ascii_case("PATH"))) + .env("PATH", new_path) + .current_dir(dir); let child = command.spawn().await?; let termination = child.wait_handle.await?; @@ -64,3 +83,37 @@ async fn oxlint_reads_directory() -> anyhow::Result<()> { test_utils::assert_contains(&accesses, &tmpdir_path, AccessMode::READ_DIR); Ok(()) } + +#[test(tokio::test)] +async fn oxlint_type_aware() -> anyhow::Result<()> { + let tmpdir = tempfile::tempdir()?; + // on macOS, tmpdir.path() may be a symlink, so we need to canonicalize it + let tmpdir_path = std::fs::canonicalize(tmpdir.path())?; + + // Create a simple TypeScript file + let ts_file = tmpdir_path.join("index.ts"); + std::fs::write( + &ts_file, + r#" +import type { Foo } from './types'; +declare const _foo: Foo; +"#, + )?; + + // Run oxlint without --type-aware first + let accesses = track_oxlint(&tmpdir_path, &[""]).await?; + let access_to_types_ts = accesses.iter().find(|access| { + let os_str = access.path.to_cow_os_str(); + os_str.as_encoded_bytes().ends_with(b"\\types.ts") + || os_str.as_encoded_bytes().ends_with(b"/types.ts") + }); + assert_eq!(access_to_types_ts, None, "oxlint should not read types.ts without --type-aware"); + + // Run oxlint with --type-aware to enable type-aware linting + let accesses = track_oxlint(&tmpdir_path, &["--type-aware"]).await?; + + // Check that oxlint read types.ts + test_utils::assert_contains(&accesses, &tmpdir_path.join("types.ts"), AccessMode::READ); + + Ok(()) +} diff --git a/crates/fspy/tests/rust_std.rs b/crates/fspy/tests/rust_std.rs index 2ff9e0a0..633ec11b 100644 --- a/crates/fspy/tests/rust_std.rs +++ b/crates/fspy/tests/rust_std.rs @@ -37,11 +37,25 @@ async fn open_write() -> anyhow::Result<()> { #[test(tokio::test)] async fn readdir() -> anyhow::Result<()> { - let accesses = track_child!((), |(): ()| { + let tmpdir = tempfile::tempdir()?; + let tmpdir_path = std::fs::canonicalize(tmpdir.path())?; + // Reading a non-existent directory results in different tracked accesses on different platforms: + // - Windows: READ, because the NT APIs open the directory as handle just like files (NtCreateFile/NtOpenFile), + // and if that fails, not read dir call (NtQueryDirectoryFile/NtQueryDirectoryFileEx) is made. + // - macOS/Linux: + // - opendir results in a read_dir access. This call is directly made without trying to open the directory as a fd first. + // - open + fopendir results in READ access, because open would fail with ENOENT, and fopendir is not called. + // + // This difference is acceptable because both will result in a "not found" fingerprint in vite-task. + // To keep the test consistent across platforms, we create the directory first. + std::fs::create_dir(tmpdir.path().join("hello_dir"))?; + + let accesses = track_child!(tmpdir_path.to_str().unwrap().to_owned(), |tmpdir_path: String| { + std::env::set_current_dir(tmpdir_path).unwrap(); let _ = std::fs::read_dir("hello_dir"); }) .await?; - assert_contains(&accesses, current_dir()?.join("hello_dir").as_path(), AccessMode::READ_DIR); + assert_contains(&accesses, tmpdir_path.join("hello_dir").as_path(), AccessMode::READ_DIR); Ok(()) } diff --git a/crates/fspy/tests/rust_tokio.rs b/crates/fspy/tests/rust_tokio.rs index e847ba1a..7e13ea36 100644 --- a/crates/fspy/tests/rust_tokio.rs +++ b/crates/fspy/tests/rust_tokio.rs @@ -42,7 +42,13 @@ async fn open_write() -> anyhow::Result<()> { #[test(tokio::test)] async fn readdir() -> anyhow::Result<()> { - let accesses = track_child!((), |(): ()| { + let tmpdir = tempfile::tempdir()?; + let tmpdir_path = std::fs::canonicalize(tmpdir.path())?; + + std::fs::create_dir(tmpdir.path().join("hello_dir"))?; + + let accesses = track_child!(tmpdir_path.to_str().unwrap().to_owned(), |tmpdir_path: String| { + std::env::set_current_dir(tmpdir_path).unwrap(); tokio::runtime::Builder::new_current_thread().enable_io().build().unwrap().block_on( async { let _ = tokio::fs::read_dir("hello_dir").await; @@ -50,7 +56,7 @@ async fn readdir() -> anyhow::Result<()> { ); }) .await?; - assert_contains(&accesses, current_dir()?.join("hello_dir").as_path(), AccessMode::READ_DIR); + assert_contains(&accesses, tmpdir_path.join("hello_dir").as_path(), AccessMode::READ_DIR); Ok(()) } diff --git a/crates/fspy_preload_windows/src/windows/convert.rs b/crates/fspy_preload_windows/src/windows/convert.rs index 6787bc13..5b085a12 100644 --- a/crates/fspy_preload_windows/src/windows/convert.rs +++ b/crates/fspy_preload_windows/src/windows/convert.rs @@ -50,16 +50,26 @@ impl ToAbsolutePath for POBJECT_ATTRIBUTES { self, f: F, ) -> winsafe::SysResult { - let filename_str = unsafe { get_u16_str(&*(*self).ObjectName) }; + let filename_str = if let Some(object_name) = unsafe { (*self).ObjectName.as_ref() } { + unsafe { get_u16_str(object_name) } + } else { + U16Str::from_slice(&[]) + }; let filename_slice = filename_str.as_slice(); - let is_absolute = (filename_slice.get(0) == Some(&b'\\'.into()) - && filename_slice.get(1) == Some(&b'\\'.into())) // \\... + let is_absolute = filename_slice.get(0) == Some(&b'\\'.into()) // \... || filename_slice.get(1) == Some(&b':'.into()); // C:... - if is_absolute { + if !is_absolute { let Ok(mut root_dir) = (unsafe { get_path_name((*self).RootDirectory) }) else { return f(None); }; + + // If filename is empty, just use root_dir directly + if filename_slice.is_empty() { + let root_dir_str = U16Str::from_slice(&root_dir); + return f(Some(root_dir_str)); + } + let root_dir_cstr = { root_dir.push(0); unsafe { U16CStr::from_ptr_str(root_dir.as_ptr()) } diff --git a/crates/fspy_preload_windows/src/windows/detour.rs b/crates/fspy_preload_windows/src/windows/detour.rs index 0b8a2ddc..92bce9c7 100644 --- a/crates/fspy_preload_windows/src/windows/detour.rs +++ b/crates/fspy_preload_windows/src/windows/detour.rs @@ -21,7 +21,6 @@ impl Detour { Detour { symbol_name, target: UnsafeCell::new(unsafe { transmute_copy(&target) }), new } } - #[expect(dead_code)] pub const unsafe fn dynamic(symbol_name: &'static CStr, new: T) -> Self { Detour { symbol_name, target: UnsafeCell::new(null_mut()), new } } @@ -52,15 +51,18 @@ pub struct DetourAny { pub struct AttachContext { kernelbase: HMODULE, kernel32: HMODULE, + ntdll: HMODULE, } impl AttachContext { pub fn new() -> Self { let kernelbase = unsafe { LoadLibraryA(c"kernelbase".as_ptr()) }; let kernel32 = unsafe { LoadLibraryA(c"kernel32".as_ptr()) }; + let ntdll = unsafe { LoadLibraryA(c"ntdll".as_ptr()) }; assert_ne!(kernelbase, null_mut()); assert_ne!(kernel32, null_mut()); - Self { kernelbase, kernel32 } + assert_ne!(ntdll, null_mut()); + Self { kernelbase, kernel32, ntdll } } } @@ -74,9 +76,14 @@ impl DetourAny { unsafe { *self.target = symbol_in_kernelbase.cast() }; } else { if unsafe { *self.target }.is_null() { - // dynamic symbol + // dynamic symbol - look up from kernel32 or ntdll let symbol_in_kernel32 = unsafe { GetProcAddress(ctx.kernel32, symbol_name) }; - unsafe { *self.target = symbol_in_kernel32.cast() }; + if !symbol_in_kernel32.is_null() { + unsafe { *self.target = symbol_in_kernel32.cast() }; + } else { + let symbol_in_ntdll = unsafe { GetProcAddress(ctx.ntdll, symbol_name) }; + unsafe { *self.target = symbol_in_ntdll.cast() }; + } } } if unsafe { *self.target }.is_null() { diff --git a/crates/fspy_preload_windows/src/windows/detours/nt.rs b/crates/fspy_preload_windows/src/windows/detours/nt.rs index 344837f6..cbefa0fd 100644 --- a/crates/fspy_preload_windows/src/windows/detours/nt.rs +++ b/crates/fspy_preload_windows/src/windows/detours/nt.rs @@ -286,6 +286,55 @@ static DETOUR_NT_QUERY_DIRECTORY_FILE: Detour< }) }; +// NtQueryDirectoryFileEx is not in ntapi crate, so we define it here. +// https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-ntquerydirectoryfileex +type NtQueryDirectoryFileExFn = unsafe extern "system" fn( + file_handle: HANDLE, + event: HANDLE, + apc_routine: PIO_APC_ROUTINE, + apc_context: PVOID, + io_status_block: PIO_STATUS_BLOCK, + file_information: PVOID, + length: ULONG, + file_information_class: FILE_INFORMATION_CLASS, + query_flags: ULONG, + file_name: PUNICODE_STRING, +) -> NTSTATUS; + +static DETOUR_NT_QUERY_DIRECTORY_FILE_EX: Detour = unsafe { + Detour::dynamic(c"NtQueryDirectoryFileEx", { + 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, + file_information: PVOID, + length: ULONG, + file_information_class: FILE_INFORMATION_CLASS, + query_flags: ULONG, + file_name: PUNICODE_STRING, + ) -> NTSTATUS { + unsafe { handle_open(AccessMode::READ_DIR, file_handle) }; + unsafe { + (DETOUR_NT_QUERY_DIRECTORY_FILE_EX.real())( + file_handle, + event, + apc_routine, + apc_context, + io_status_block, + file_information, + length, + file_information_class, + query_flags, + file_name, + ) + } + } + new_fn + }) +}; + pub const DETOURS: &[DetourAny] = &[ DETOUR_NT_CREATE_FILE.as_any(), DETOUR_NT_OPEN_FILE.as_any(), @@ -294,4 +343,5 @@ pub const DETOURS: &[DetourAny] = &[ DETOUR_NT_OPEN_SYMBOLIC_LINK_OBJECT.as_any(), DETOUR_NT_QUERY_INFORMATION_BY_NAME.as_any(), DETOUR_NT_QUERY_DIRECTORY_FILE.as_any(), + DETOUR_NT_QUERY_DIRECTORY_FILE_EX.as_any(), ]; diff --git a/crates/fspy_preload_windows/src/windows/winapi_utils.rs b/crates/fspy_preload_windows/src/windows/winapi_utils.rs index 4f01c9a2..afd0ba06 100644 --- a/crates/fspy_preload_windows/src/windows/winapi_utils.rs +++ b/crates/fspy_preload_windows/src/windows/winapi_utils.rs @@ -29,8 +29,16 @@ pub fn ck_long(val: c_long) -> winsafe::SysResult<()> { } pub unsafe fn get_u16_str(ustring: &UNICODE_STRING) -> &U16Str { - let chars = - unsafe { slice::from_raw_parts((*ustring).Buffer, (*ustring).Length.try_into().unwrap()) }; + // https://learn.microsoft.com/en-us/windows/win32/api/subauth/ns-subauth-unicode_string + // UNICODE_STRING.Length is in bytes + let u16_count = ustring.Length / 2; + let chars: &[u16] = if u16_count == 0 { + // If length is zero, we can't use slice::from_raw_parts as it requires a non-null pointer but + // Buffer may be null in that case. + &[] + } else { + unsafe { slice::from_raw_parts((*ustring).Buffer, u16_count.try_into().unwrap()) } + }; match U16CStr::from_slice_truncate(chars) { Ok(ok) => ok.as_ustr(), Err(_) => chars.into(), diff --git a/crates/fspy_shared/src/ipc/mod.rs b/crates/fspy_shared/src/ipc/mod.rs index 2b348caf..14d9bab7 100644 --- a/crates/fspy_shared/src/ipc/mod.rs +++ b/crates/fspy_shared/src/ipc/mod.rs @@ -32,7 +32,7 @@ impl Debug for AccessMode { } } -#[derive(Encode, BorrowDecode, Debug, Clone, Copy)] +#[derive(Encode, BorrowDecode, Debug, Clone, Copy, PartialEq, Eq)] pub struct PathAccess<'a> { pub mode: AccessMode, pub path: &'a NativeStr, diff --git a/crates/vite_task_bin/test_bins/package.json b/crates/vite_task_bin/test_bins/package.json index e759a6e4..99651da9 100644 --- a/crates/vite_task_bin/test_bins/package.json +++ b/crates/vite_task_bin/test_bins/package.json @@ -12,6 +12,7 @@ "@yarnpkg/shell": "catalog:", "cross-env": "^10.1.0", "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "vite-task-test-bins": "link:" } }