Skip to content

Commit a81d817

Browse files
committed
test(fspy): end-to-end tests for blocking open/close callbacks
file_callback.rs covers the preload backend on Linux glibc / macOS / Windows: blocking proof (target cannot progress while the callback runs), the supervisor can read the passed descriptor, the close callback fires before the close with a still-valid descriptor, the mask filters events, and registering no callback leaves access tracking unchanged. static_executable.rs adds two seccomp-backend cases (ADDFD round-trip + multi-threaded concurrent opens).
1 parent 8afe314 commit a81d817

3 files changed

Lines changed: 430 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 26 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fspy/tests/file_callback.rs

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
//! Tests for the optional blocking open/close callbacks.
2+
//!
3+
//! These exercise the preload backend (`LD_PRELOAD` on Linux glibc, `DYLD` on
4+
//! macOS, Detours on Windows): the traced process is a dynamically-linked
5+
//! re-exec of this test binary. The seccomp backend is covered separately in
6+
//! `static_executable.rs`.
7+
8+
mod test_utils;
9+
10+
use std::{
11+
fs::File,
12+
io,
13+
path::{Path, PathBuf},
14+
sync::{
15+
Arc, Condvar, Mutex,
16+
atomic::{AtomicBool, Ordering},
17+
},
18+
};
19+
20+
use fspy::{AccessMode, FileEvent, FileEventKind, TrackedChild};
21+
use test_utils::{assert_contains, command_for_fn};
22+
use tokio_util::sync::CancellationToken;
23+
24+
/// A one-shot latch: many waiters block until `set` is called once.
25+
#[derive(Default)]
26+
struct Latch {
27+
raised: Mutex<bool>,
28+
cv: Condvar,
29+
}
30+
31+
impl Latch {
32+
fn set(&self) {
33+
*self.raised.lock().unwrap() = true;
34+
self.cv.notify_all();
35+
}
36+
37+
#[expect(
38+
clippy::significant_drop_tightening,
39+
reason = "the mutex guard must be held across the condvar wait loop"
40+
)]
41+
fn wait(&self) {
42+
let mut raised = self.raised.lock().unwrap();
43+
while !*raised {
44+
raised = self.cv.wait(raised).unwrap();
45+
}
46+
}
47+
}
48+
49+
/// Reads up to 256 bytes at offset 0 without disturbing the descriptor's
50+
/// current file offset (which is shared with the traced process).
51+
fn read_fd_content(file: &File) -> io::Result<Vec<u8>> {
52+
let mut buf = vec![0u8; 256];
53+
#[cfg(unix)]
54+
let read = {
55+
use std::os::unix::fs::FileExt as _;
56+
file.read_at(&mut buf, 0)?
57+
};
58+
#[cfg(windows)]
59+
let read = {
60+
use std::os::windows::fs::FileExt as _;
61+
file.seek_read(&mut buf, 0)?
62+
};
63+
buf.truncate(read);
64+
Ok(buf)
65+
}
66+
67+
/// Spawns a traced subprocess with a blocking open/close callback registered.
68+
async fn spawn_with_callback<F>(
69+
cmd: subprocess_test::Command,
70+
mask: AccessMode,
71+
callback: F,
72+
) -> anyhow::Result<TrackedChild>
73+
where
74+
F: Fn(FileEvent<'_>) + Send + Sync + 'static,
75+
{
76+
let mut command = fspy::Command::from(cmd);
77+
command.on_file_event(mask, callback);
78+
Ok(command.spawn(CancellationToken::new()).await?)
79+
}
80+
81+
/// The callback blocks the traced process: while it is running, the target
82+
/// cannot make progress past the open it is blocked in.
83+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
84+
async fn callback_blocks_until_supervisor_resumes() -> anyhow::Result<()> {
85+
let dir = tempfile::tempdir()?;
86+
std::fs::write(dir.path().join("file_a"), b"a")?;
87+
std::fs::write(dir.path().join("file_b"), b"b")?;
88+
let dir_path = dir.path().to_path_buf();
89+
90+
let opened: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
91+
let entered = Arc::new(Latch::default());
92+
let release = Arc::new(Latch::default());
93+
let blocked_once = Arc::new(AtomicBool::new(false));
94+
95+
let callback = {
96+
let (dir_path, opened, entered, release, blocked_once) = (
97+
dir_path.clone(),
98+
Arc::clone(&opened),
99+
Arc::clone(&entered),
100+
Arc::clone(&release),
101+
Arc::clone(&blocked_once),
102+
);
103+
move |event: FileEvent<'_>| {
104+
if event.kind != FileEventKind::Opened {
105+
return;
106+
}
107+
let Some(path) = event.path.get() else {
108+
return;
109+
};
110+
if !path.starts_with(&dir_path) {
111+
return; // ignore unrelated startup I/O
112+
}
113+
opened.lock().unwrap().push(path.to_path_buf());
114+
// Block the traced process on the very first matching open.
115+
if !blocked_once.swap(true, Ordering::SeqCst) {
116+
entered.set();
117+
release.wait();
118+
}
119+
}
120+
};
121+
122+
let cmd = command_for_fn!(dir_path.to_str().unwrap().to_owned(), |dir: String| {
123+
let dir = std::path::Path::new(&dir);
124+
let _ = std::fs::File::open(dir.join("file_a")).unwrap();
125+
let _ = std::fs::File::open(dir.join("file_b")).unwrap();
126+
});
127+
let child = spawn_with_callback(cmd, AccessMode::READ, callback).await?;
128+
129+
// Wait until the callback for `file_a` is blocking the traced process.
130+
{
131+
let entered = Arc::clone(&entered);
132+
tokio::task::spawn_blocking(move || entered.wait()).await?;
133+
}
134+
// The target is blocked inside `file_a`'s open hook awaiting the ACK, so it
135+
// cannot have reached the `file_b` open yet.
136+
assert_eq!(
137+
opened.lock().unwrap().len(),
138+
1,
139+
"traced process progressed past the open while the callback was still running"
140+
);
141+
142+
release.set();
143+
let termination = child.wait_handle.await?;
144+
assert!(termination.status.success());
145+
assert_eq!(
146+
opened.lock().unwrap().len(),
147+
2,
148+
"expected an Opened event for each of the two files"
149+
);
150+
Ok(())
151+
}
152+
153+
/// The callback receives a descriptor it can read inside the supervisor.
154+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
155+
async fn callback_can_read_fd() -> anyhow::Result<()> {
156+
let dir = tempfile::tempdir()?;
157+
let content = b"hello fspy callback";
158+
std::fs::write(dir.path().join("content"), content)?;
159+
let dir_path = dir.path().to_path_buf();
160+
161+
let seen: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
162+
let callback = {
163+
let (dir_path, seen) = (dir_path.clone(), Arc::clone(&seen));
164+
move |event: FileEvent<'_>| {
165+
if event.kind != FileEventKind::Opened {
166+
return;
167+
}
168+
let Some(path) = event.path.get() else {
169+
return;
170+
};
171+
if !path.starts_with(&dir_path) {
172+
return;
173+
}
174+
let file = event.fd.as_file();
175+
if let Ok(bytes) = read_fd_content(&file) {
176+
*seen.lock().unwrap() = Some(bytes);
177+
}
178+
}
179+
};
180+
181+
let cmd = command_for_fn!(dir_path.to_str().unwrap().to_owned(), |dir: String| {
182+
let path = std::path::Path::new(&dir).join("content");
183+
let _ = std::fs::read(path).unwrap();
184+
});
185+
let child = spawn_with_callback(cmd, AccessMode::READ, callback).await?;
186+
let termination = child.wait_handle.await?;
187+
assert!(termination.status.success());
188+
189+
assert_eq!(
190+
seen.lock().unwrap().as_deref(),
191+
Some(content.as_slice()),
192+
"callback should read the file content through the passed descriptor"
193+
);
194+
Ok(())
195+
}
196+
197+
/// A `Closing` event fires before the close, with a still-readable descriptor,
198+
/// and after the matching `Opened` event.
199+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
200+
async fn close_callback_fires_before_close() -> anyhow::Result<()> {
201+
let dir = tempfile::tempdir()?;
202+
std::fs::write(dir.path().join("closeme"), b"data")?;
203+
let dir_path = dir.path().to_path_buf();
204+
205+
let kinds: Arc<Mutex<Vec<FileEventKind>>> = Arc::new(Mutex::new(Vec::new()));
206+
let close_readable = Arc::new(AtomicBool::new(false));
207+
let callback = {
208+
let (dir_path, kinds, close_readable) =
209+
(dir_path.clone(), Arc::clone(&kinds), Arc::clone(&close_readable));
210+
move |event: FileEvent<'_>| {
211+
let Some(path) = event.path.get() else {
212+
return;
213+
};
214+
if !path.starts_with(&dir_path) {
215+
return;
216+
}
217+
kinds.lock().unwrap().push(event.kind);
218+
if event.kind == FileEventKind::Closing
219+
&& read_fd_content(&event.fd.as_file()).is_ok_and(|bytes| bytes == b"data")
220+
{
221+
close_readable.store(true, Ordering::SeqCst);
222+
}
223+
}
224+
};
225+
226+
let cmd = command_for_fn!(dir_path.to_str().unwrap().to_owned(), |dir: String| {
227+
let path = std::path::Path::new(&dir).join("closeme");
228+
let file = std::fs::File::open(path).unwrap();
229+
drop(file);
230+
});
231+
let child = spawn_with_callback(cmd, AccessMode::READ, callback).await?;
232+
let termination = child.wait_handle.await?;
233+
assert!(termination.status.success());
234+
235+
let kinds = kinds.lock().unwrap().clone();
236+
let open_idx = kinds.iter().position(|kind| *kind == FileEventKind::Opened);
237+
let close_idx = kinds.iter().position(|kind| *kind == FileEventKind::Closing);
238+
assert!(open_idx.is_some(), "expected an Opened event, got {kinds:?}");
239+
assert!(close_idx.is_some(), "expected a Closing event, got {kinds:?}");
240+
assert!(open_idx < close_idx, "Opened must come before Closing, got {kinds:?}");
241+
assert!(
242+
close_readable.load(Ordering::SeqCst),
243+
"the descriptor must still be readable inside the close callback"
244+
);
245+
Ok(())
246+
}
247+
248+
/// The access-mode mask filters which events reach the callback.
249+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
250+
async fn mask_filters_events() -> anyhow::Result<()> {
251+
let dir = tempfile::tempdir()?;
252+
std::fs::write(dir.path().join("read_file"), b"r")?;
253+
std::fs::write(dir.path().join("write_file"), b"w")?;
254+
let dir_path = dir.path().to_path_buf();
255+
256+
let modes: Arc<Mutex<Vec<AccessMode>>> = Arc::new(Mutex::new(Vec::new()));
257+
let callback = {
258+
let (dir_path, modes) = (dir_path.clone(), Arc::clone(&modes));
259+
move |event: FileEvent<'_>| {
260+
if event.kind != FileEventKind::Opened {
261+
return;
262+
}
263+
let Some(path) = event.path.get() else {
264+
return;
265+
};
266+
if !path.starts_with(&dir_path) {
267+
return;
268+
}
269+
modes.lock().unwrap().push(event.mode);
270+
}
271+
};
272+
273+
let cmd = command_for_fn!(dir_path.to_str().unwrap().to_owned(), |dir: String| {
274+
let dir = std::path::Path::new(&dir);
275+
let _ = std::fs::File::open(dir.join("read_file")).unwrap();
276+
let _ = std::fs::OpenOptions::new().write(true).open(dir.join("write_file")).unwrap();
277+
});
278+
let child = spawn_with_callback(cmd, AccessMode::WRITE, callback).await?;
279+
let termination = child.wait_handle.await?;
280+
assert!(termination.status.success());
281+
282+
let modes = modes.lock().unwrap().clone();
283+
assert_eq!(modes.len(), 1, "a WRITE mask should yield exactly the write open, got {modes:?}");
284+
assert!(modes[0].contains(AccessMode::WRITE));
285+
Ok(())
286+
}
287+
288+
/// Without a registered callback, access tracking is unaffected.
289+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
290+
async fn no_callback_is_zero_overhead() -> anyhow::Result<()> {
291+
let dir = tempfile::tempdir()?;
292+
let path = dir.path().join("plain");
293+
std::fs::write(&path, b"x")?;
294+
295+
let cmd = command_for_fn!(path.to_str().unwrap().to_owned(), |path: String| {
296+
let _ = std::fs::File::open(path).unwrap();
297+
});
298+
let termination =
299+
fspy::Command::from(cmd).spawn(CancellationToken::new()).await?.wait_handle.await?;
300+
assert!(termination.status.success());
301+
assert_contains(&termination.path_accesses, Path::new(&path), AccessMode::READ);
302+
Ok(())
303+
}

0 commit comments

Comments
 (0)