Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 59 additions & 6 deletions crates/fspy/tests/oxlint.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,47 @@
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")
}

async fn track_oxlint(dir: &std::path::Path, args: &[&str]) -> anyhow::Result<PathAccessIterable> {
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?;
Expand Down Expand Up @@ -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(())
}
18 changes: 16 additions & 2 deletions crates/fspy/tests/rust_std.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
10 changes: 8 additions & 2 deletions crates/fspy/tests/rust_tokio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,21 @@ 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;
},
);
})
.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(())
}
Expand Down
18 changes: 14 additions & 4 deletions crates/fspy_preload_windows/src/windows/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,26 @@ impl ToAbsolutePath for POBJECT_ATTRIBUTES {
self,
f: F,
) -> winsafe::SysResult<R> {
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()) }
Expand Down
15 changes: 11 additions & 4 deletions crates/fspy_preload_windows/src/windows/detour.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ impl<T: Copy> Detour<T> {
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 }
}
Expand Down Expand Up @@ -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 }
}
}

Expand All @@ -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() {
Expand Down
50 changes: 50 additions & 0 deletions crates/fspy_preload_windows/src/windows/detours/nt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NtQueryDirectoryFileExFn> = 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(),
Expand All @@ -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(),
];
12 changes: 10 additions & 2 deletions crates/fspy_preload_windows/src/windows/winapi_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion crates/fspy_shared/src/ipc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/vite_task_bin/test_bins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"@yarnpkg/shell": "catalog:",
"cross-env": "^10.1.0",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"vite-task-test-bins": "link:"
}
}
Loading