Skip to content

Commit 8b137ab

Browse files
authored
fix(fspy): improve intercepting node:fs operations (#35)
### TL;DR Enhanced file system access tracking with support for additional Node.js and system-level file operations. ### What changed? - Supported & tested tracking for more Node.js file operations: - `fs.existsSync()` - `fs.statSync()` - `fs.createReadStream()` - `fs.createWriteStream()` - `fs.writeFileSync()` - Improved Unix preload interception: - Added interception for `access()` and `faccessat()` functions - Added Linux-specific syscall interception for `statx` - Enhanced Windows file access detection: - Improved detection of read/write operations by checking specific file access flags - Improved test infrastructure: - Updated `track_child!` macro to accept arguments - Replaced hardcoded temp paths with proper tempfile usage
1 parent 3e53955 commit 8b137ab

File tree

10 files changed

+162
-56
lines changed

10 files changed

+162
-56
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ dist
44
.claude/settings.local.json
55
*.tsbuildinfo
66
.DS_Store
7+
/.vscode/settings.json

crates/fspy/tests/node_fs.rs

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
mod test_utils;
22

3-
use std::env::{current_dir, vars_os};
3+
use std::{
4+
env::{current_dir, vars_os},
5+
ffi::OsStr,
6+
};
47

58
use fspy::{AccessMode, PathAccessIterable};
69
use test_log::test;
710
use test_utils::assert_contains;
811

9-
async fn track_node_script(script: &str) -> anyhow::Result<PathAccessIterable> {
12+
async fn track_node_script(script: &str, args: &[&OsStr]) -> anyhow::Result<PathAccessIterable> {
1013
let mut command = fspy::Command::new("node");
1114
command
1215
.arg("-e")
1316
.envs(vars_os()) // https://github.com/jdx/mise/discussions/5968
14-
.arg(script);
17+
.arg(script)
18+
.args(args);
1519
let child = command.spawn().await?;
1620
let termination = child.wait_handle.await?;
1721
assert!(termination.status.success());
@@ -20,14 +24,65 @@ async fn track_node_script(script: &str) -> anyhow::Result<PathAccessIterable> {
2024

2125
#[test(tokio::test)]
2226
async fn read_sync() -> anyhow::Result<()> {
23-
let accesses = track_node_script("try { fs.readFileSync('hello') } catch {}").await?;
27+
let accesses = track_node_script("try { fs.readFileSync('hello') } catch {}", &[]).await?;
2428
assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read);
2529
Ok(())
2630
}
2731

32+
#[test(tokio::test)]
33+
async fn exist_sync() -> anyhow::Result<()> {
34+
let accesses = track_node_script("try { fs.existsSync('hello') } catch {}", &[]).await?;
35+
assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read);
36+
Ok(())
37+
}
38+
39+
#[test(tokio::test)]
40+
async fn stat_sync() -> anyhow::Result<()> {
41+
let accesses = track_node_script("try { fs.statSync('hello') } catch {}", &[]).await?;
42+
assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read);
43+
Ok(())
44+
}
45+
46+
#[test(tokio::test)]
47+
async fn create_read_stream() -> anyhow::Result<()> {
48+
let accesses = track_node_script(
49+
"try { fs.createReadStream('hello').on('error', () => {}) } catch {}",
50+
&[],
51+
)
52+
.await?;
53+
assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read);
54+
Ok(())
55+
}
56+
57+
#[test(tokio::test)]
58+
async fn create_write_stream() -> anyhow::Result<()> {
59+
let tmpdir = tempfile::tempdir()?;
60+
let file_path = tmpdir.path().join("hello");
61+
let accesses = track_node_script(
62+
"try { fs.createWriteStream(process.argv[1]).on('error', () => {}) } catch {}",
63+
&[file_path.as_os_str()],
64+
)
65+
.await?;
66+
assert_contains(&accesses, file_path.as_path(), AccessMode::Write);
67+
Ok(())
68+
}
69+
70+
#[test(tokio::test)]
71+
async fn write_sync() -> anyhow::Result<()> {
72+
let tmpdir = tempfile::tempdir()?;
73+
let file_path = tmpdir.path().join("hello");
74+
let accesses = track_node_script(
75+
"try { fs.writeFileSync(process.argv[1], '') } catch {}",
76+
&[file_path.as_os_str()],
77+
)
78+
.await?;
79+
assert_contains(&accesses, &file_path, AccessMode::Write);
80+
Ok(())
81+
}
82+
2883
#[test(tokio::test)]
2984
async fn read_dir_sync() -> anyhow::Result<()> {
30-
let accesses = track_node_script("try { fs.readdirSync('.') } catch {}").await?;
85+
let accesses = track_node_script("try { fs.readdirSync('.') } catch {}", &[]).await?;
3186
assert_contains(&accesses, &current_dir().unwrap(), AccessMode::ReadDir);
3287
Ok(())
3388
}
@@ -39,9 +94,10 @@ async fn subprocess() -> anyhow::Result<()> {
3994
} else {
4095
r"'/bin/sh', ['-c', 'cat hello']"
4196
};
42-
let accesses = track_node_script(&format!(
43-
"try {{ child_process.spawnSync({cmd}, {{ stdio: 'ignore' }}) }} catch {{}}"
44-
))
97+
let accesses = track_node_script(
98+
&format!("try {{ child_process.spawnSync({cmd}, {{ stdio: 'ignore' }}) }} catch {{}}"),
99+
&[],
100+
)
45101
.await?;
46102
assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read);
47103
Ok(())

crates/fspy/tests/rust_std.rs

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ mod test_utils;
33
use std::{
44
env::current_dir,
55
fs::{File, OpenOptions},
6-
path::Path,
76
process::Stdio,
87
};
98

@@ -13,7 +12,7 @@ use test_utils::assert_contains;
1312

1413
#[test(tokio::test)]
1514
async fn open_read() -> anyhow::Result<()> {
16-
let accesses = track_child!({
15+
let accesses = track_child!((), |(): ()| {
1716
let _ = File::open("hello");
1817
})
1918
.await?;
@@ -24,39 +23,32 @@ async fn open_read() -> anyhow::Result<()> {
2423

2524
#[test(tokio::test)]
2625
async fn open_write() -> anyhow::Result<()> {
27-
let accesses = track_child!({
28-
let path = format!("{}/hello", env!("CARGO_TARGET_TMPDIR"));
29-
let _ = OpenOptions::new().write(true).open(path);
26+
let tmp_dir = tempfile::tempdir()?;
27+
let tmp_path = tmp_dir.path().join("hello");
28+
let tmp_path_str = tmp_path.to_str().unwrap().to_owned();
29+
let accesses = track_child!(tmp_path_str, |tmp_path_str: String| {
30+
let _ = OpenOptions::new().write(true).open(tmp_path_str);
3031
})
3132
.await?;
32-
assert_contains(
33-
&accesses,
34-
Path::new(env!("CARGO_TARGET_TMPDIR")).join("hello").as_path(),
35-
AccessMode::Write,
36-
);
33+
assert_contains(&accesses, tmp_path.as_path(), AccessMode::Write);
3734

3835
Ok(())
3936
}
4037

4138
#[test(tokio::test)]
4239
async fn readdir() -> anyhow::Result<()> {
43-
let accesses = track_child!({
44-
let path = format!("{}/hello", env!("CARGO_TARGET_TMPDIR"));
45-
let _ = std::fs::read_dir(path);
40+
let accesses = track_child!((), |(): ()| {
41+
let _ = std::fs::read_dir("hello_dir");
4642
})
4743
.await?;
48-
assert_contains(
49-
&accesses,
50-
Path::new(env!("CARGO_TARGET_TMPDIR")).join("hello").as_path(),
51-
AccessMode::ReadDir,
52-
);
44+
assert_contains(&accesses, current_dir()?.join("hello_dir").as_path(), AccessMode::ReadDir);
5345

5446
Ok(())
5547
}
5648

5749
#[test(tokio::test)]
5850
async fn subprocess() -> anyhow::Result<()> {
59-
let accesses = track_child!({
51+
let accesses = track_child!((), |(): ()| {
6052
let mut command = if cfg!(windows) {
6153
let mut command = std::process::Command::new("cmd");
6254
command.arg("/c").arg("type hello");

crates/fspy/tests/rust_tokio.rs

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
mod test_utils;
22

3-
use std::{env::current_dir, path::Path, process::Stdio};
3+
use std::{env::current_dir, process::Stdio};
44

55
use fspy::AccessMode;
66
use test_log::test;
@@ -9,7 +9,7 @@ use tokio::fs::OpenOptions;
99

1010
#[test(tokio::test)]
1111
async fn open_read() -> anyhow::Result<()> {
12-
let accesses = track_child!({
12+
let accesses = track_child!((), |(): ()| {
1313
tokio::runtime::Builder::new_current_thread().enable_io().build().unwrap().block_on(
1414
async {
1515
let _ = tokio::fs::File::open("hello").await;
@@ -24,49 +24,40 @@ async fn open_read() -> anyhow::Result<()> {
2424

2525
#[test(tokio::test)]
2626
async fn open_write() -> anyhow::Result<()> {
27-
let accesses = track_child!({
28-
let path = format!("{}/hello", env!("CARGO_TARGET_TMPDIR"));
29-
27+
let tmp_dir = tempfile::tempdir()?;
28+
let tmp_path = tmp_dir.path().join("hello");
29+
let tmp_path_str = tmp_path.to_str().unwrap().to_owned();
30+
let accesses = track_child!(tmp_path_str, |tmp_path_str: String| {
3031
tokio::runtime::Builder::new_current_thread().enable_io().build().unwrap().block_on(
3132
async {
32-
let _ = OpenOptions::new().write(true).open(path).await;
33+
let _ = OpenOptions::new().write(true).open(tmp_path_str).await;
3334
},
3435
);
3536
})
3637
.await?;
37-
assert_contains(
38-
&accesses,
39-
Path::new(env!("CARGO_TARGET_TMPDIR")).join("hello").as_path(),
40-
AccessMode::Write,
41-
);
38+
assert_contains(&accesses, tmp_path.as_path(), AccessMode::Write);
4239

4340
Ok(())
4441
}
4542

4643
#[test(tokio::test)]
4744
async fn readdir() -> anyhow::Result<()> {
48-
let accesses = track_child!({
49-
let path = format!("{}/hello", env!("CARGO_TARGET_TMPDIR"));
50-
45+
let accesses = track_child!((), |(): ()| {
5146
tokio::runtime::Builder::new_current_thread().enable_io().build().unwrap().block_on(
5247
async {
53-
let _ = tokio::fs::read_dir(path).await;
48+
let _ = tokio::fs::read_dir("hello_dir").await;
5449
},
5550
);
5651
})
5752
.await?;
58-
assert_contains(
59-
&accesses,
60-
Path::new(env!("CARGO_TARGET_TMPDIR")).join("hello").as_path(),
61-
AccessMode::ReadDir,
62-
);
53+
assert_contains(&accesses, current_dir()?.join("hello_dir").as_path(), AccessMode::ReadDir);
6354

6455
Ok(())
6556
}
6657

6758
#[test(tokio::test)]
6859
async fn subprocess() -> anyhow::Result<()> {
69-
let accesses = track_child!({
60+
let accesses = track_child!((), |(): ()| {
7061
tokio::runtime::Builder::new_current_thread().enable_io().build().unwrap().block_on(
7162
async {
7263
let mut command = if cfg!(windows) {

crates/fspy/tests/test_utils.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,8 @@ pub fn assert_contains(
3434

3535
#[macro_export]
3636
macro_rules! track_child {
37-
($body: block) => {{
38-
let std_cmd = $crate::test_utils::command_executing!((), |(): ()| {
39-
let _ = $body;
40-
});
37+
($arg: expr, $body: expr) => {{
38+
let std_cmd = $crate::test_utils::command_executing!($arg, $body);
4139
$crate::test_utils::spawn_std(std_cmd)
4240
}};
4341
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
use fspy_shared::ipc::AccessMode;
2+
use libc::{c_char, c_int};
3+
4+
use crate::{
5+
client::{convert::PathAt, handle_open},
6+
macros::intercept,
7+
};
8+
9+
intercept!(access(64): unsafe extern "C" fn(pathname: *const c_char, mode: c_int) -> c_int);
10+
unsafe extern "C" fn access(pathname: *const c_char, mode: c_int) -> c_int {
11+
unsafe {
12+
handle_open(pathname, AccessMode::Read);
13+
}
14+
unsafe { access::original()(pathname, mode) }
15+
}
16+
17+
intercept!(faccessat(64): unsafe extern "C" fn(dirfd: c_int, pathname: *const c_char, mode: c_int, flags: c_int) -> c_int);
18+
unsafe extern "C" fn faccessat(
19+
dirfd: c_int,
20+
pathname: *const c_char,
21+
mode: c_int,
22+
flags: c_int,
23+
) -> c_int {
24+
unsafe {
25+
handle_open(PathAt(dirfd, pathname), AccessMode::Read);
26+
}
27+
unsafe { faccessat::original()(dirfd, pathname, mode, flags) }
28+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
use fspy_shared::ipc::AccessMode;
2+
use libc::{c_char, c_int, c_long};
3+
4+
use crate::{
5+
client::{convert::PathAt, handle_open},
6+
macros::intercept,
7+
};
8+
9+
intercept!(syscall(64): unsafe extern "C" fn(c_long, args: ...) -> c_long);
10+
unsafe extern "C" fn syscall(syscall_no: c_long, mut args: ...) -> c_long {
11+
// https://github.com/bminor/glibc/blob/efc8642051e6c4fe5165e8986c1338ba2c180de6/sysdeps/unix/sysv/linux/syscall.c#L23
12+
let a0 = unsafe { args.arg::<c_long>() };
13+
let a1 = unsafe { args.arg::<c_long>() };
14+
let a2 = unsafe { args.arg::<c_long>() };
15+
let a3 = unsafe { args.arg::<c_long>() };
16+
let a4 = unsafe { args.arg::<c_long>() };
17+
let a5 = unsafe { args.arg::<c_long>() };
18+
19+
match syscall_no {
20+
libc::SYS_statx => {
21+
// c-style conversion is expected: (4294967196 -> -100 aka libc::AT_FDCWD)
22+
let dirfd = a0 as c_int;
23+
let pathname = a1 as *const c_char;
24+
unsafe {
25+
handle_open(PathAt(dirfd, pathname), AccessMode::Read);
26+
}
27+
}
28+
_ => {}
29+
}
30+
unsafe { syscall::original()(syscall_no, a0, a1, a2, a3, a4, a5) }
31+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
mod access;
12
mod dirent;
23
mod open;
34
mod spawn;
45
mod stat;
6+
7+
#[cfg(target_os = "linux")]
8+
mod linux_syscall;

crates/fspy_preload_windows/src/windows/convert.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::fmt::Debug;
2+
13
use fspy_shared::ipc::AccessMode;
24
use widestring::{U16CStr, U16CString, U16Str};
35
use winapi::{
@@ -9,7 +11,7 @@ use crate::windows::winapi_utils::{
911
access_mask_to_mode, combine_paths, get_path_name, get_u16_str,
1012
};
1113

12-
pub trait ToAccessMode {
14+
pub trait ToAccessMode: Debug {
1315
unsafe fn to_access_mode(self) -> AccessMode;
1416
}
1517

crates/fspy_preload_windows/src/windows/winapi_utils.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ use winapi::{
1212
},
1313
um::{
1414
fileapi::GetFinalPathNameByHandleW,
15-
winnt::{ACCESS_MASK, GENERIC_READ, GENERIC_WRITE},
15+
winnt::{
16+
ACCESS_MASK, FILE_APPEND_DATA, FILE_READ_DATA, FILE_WRITE_DATA, GENERIC_READ,
17+
GENERIC_WRITE,
18+
},
1619
},
1720
};
1821
use winsafe::{GetLastError, co};
@@ -72,8 +75,8 @@ pub unsafe fn get_path_name(handle: HANDLE) -> winsafe::SysResult<SmallVec<u16,
7275
}
7376

7477
pub fn access_mask_to_mode(desired_access: ACCESS_MASK) -> AccessMode {
75-
let has_write = (desired_access & GENERIC_WRITE) != 0;
76-
let has_read = (desired_access & GENERIC_READ) != 0;
78+
let has_write = (desired_access & (FILE_WRITE_DATA | FILE_APPEND_DATA | GENERIC_WRITE)) != 0;
79+
let has_read = (desired_access & (FILE_READ_DATA | GENERIC_READ)) != 0;
7780
if has_write {
7881
if has_read { AccessMode::ReadWrite } else { AccessMode::Write }
7982
} else {

0 commit comments

Comments
 (0)