Skip to content

Commit e29e264

Browse files
committed
feat(fspy): add optional blocking open/close supervisor callbacks
Adds `Command::on_file_event(mask, callback)` so a consumer can register a callback that runs in the supervisor and blocks the traced process right after a file is opened and right before a file is closed. The callback receives a file descriptor / handle usable inside the supervisor's own process, filtered by an access-mode mask. Implemented for all three backends: - Unix preload (Linux glibc + macOS): synchronous Unix-socket round-trip with the fd passed via SCM_RIGHTS; new close/fclose interceptions. - Linux seccomp-unotify: the supervisor opens the file itself and installs the descriptor into the target via SECCOMP_IOCTL_NOTIF_ADDFD; close is added to the filter only when a callback is registered. - Windows (Detours): named-pipe round-trip with the handle duplicated out of the target via DuplicateHandle; new NtClose detour with a lock-free DashMap tracking open file handles. When no callback is registered there is no overhead. https://claude.ai/code/session_018qAQ7XbdyzTCdGrVVMBmK2
1 parent c945cc0 commit e29e264

40 files changed

Lines changed: 2149 additions & 63 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.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ crossterm = { version = "0.29.0", features = ["event-stream"] }
6767
csv-async = { version = "1.3.1", features = ["tokio"] }
6868
ctor = "1.0"
6969
ctrlc = "3.5.2"
70+
dashmap = "6.1.0"
7071
derive_more = "2.0.1"
7172
diff-struct = "0.5.3"
7273
directories = "6.0.0"

crates/fspy/Cargo.toml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ ouroboros = { workspace = true }
1919
rustc-hash = { workspace = true }
2020
tempfile = { workspace = true }
2121
thiserror = { workspace = true }
22-
tokio = { workspace = true, features = ["net", "process", "io-util", "sync", "rt"] }
22+
tokio = { workspace = true, features = ["net", "process", "io-util", "sync", "rt", "macros"] }
2323
tokio-util = { workspace = true }
2424
which = { workspace = true, features = ["tracing"] }
2525

@@ -31,10 +31,17 @@ tokio = { workspace = true, features = ["bytes"] }
3131
[target.'cfg(unix)'.dependencies]
3232
fspy_shared_unix = { workspace = true }
3333
nix = { workspace = true, features = ["fs", "process", "socket", "feature"] }
34+
passfd = { workspace = true, features = ["async"] }
3435

3536
[target.'cfg(target_os = "windows")'.dependencies]
3637
fspy_detours_sys = { workspace = true }
37-
winapi = { workspace = true, features = ["winbase", "securitybaseapi", "handleapi"] }
38+
winapi = { workspace = true, features = [
39+
"winbase",
40+
"securitybaseapi",
41+
"handleapi",
42+
"processthreadsapi",
43+
"winnt",
44+
] }
3845
winsafe = { workspace = true }
3946

4047
[target.'cfg(target_os = "macos")'.dev-dependencies]

crates/fspy/src/callback/mod.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
//! Optional blocking open/close callbacks.
2+
//!
3+
//! When a callback is registered on a [`Command`](crate::Command), the traced
4+
//! process blocks and round-trips to the supervisor right after a file is
5+
//! opened and right before a file is closed. The callback receives a file
6+
//! descriptor / handle that is usable inside the supervisor's own process.
7+
8+
#[cfg(unix)]
9+
pub mod unix;
10+
#[cfg(windows)]
11+
pub mod windows;
12+
13+
#[cfg(unix)]
14+
use std::os::fd::{AsFd, BorrowedFd};
15+
#[cfg(windows)]
16+
use std::os::windows::io::{AsHandle, BorrowedHandle};
17+
use std::{fs::File, mem::ManuallyDrop, path::Path, sync::Arc};
18+
19+
use fspy_shared::ipc::AccessMode;
20+
21+
/// Whether a [`FileEvent`] fires right after an open or right before a close.
22+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23+
pub enum FileEventKind {
24+
/// The file has just been opened; the fd/handle is valid.
25+
Opened,
26+
/// The file is about to be closed; the fd/handle is still valid.
27+
Closing,
28+
}
29+
30+
/// The path carried by a [`FileEvent`].
31+
#[derive(Debug, Clone, Copy)]
32+
pub enum FileEventPath<'a> {
33+
/// An [`FileEventKind::Opened`] event always carries the absolute path.
34+
Open(&'a Path),
35+
/// A [`FileEventKind::Closing`] event resolves the path from the
36+
/// fd/handle; resolution may fail (anonymous fd, deleted file,
37+
/// unrecoverable handle).
38+
Close(Option<&'a Path>),
39+
}
40+
41+
impl<'a> FileEventPath<'a> {
42+
/// The path, if known.
43+
#[must_use]
44+
pub const fn get(self) -> Option<&'a Path> {
45+
match self {
46+
Self::Open(path) => Some(path),
47+
Self::Close(path) => path,
48+
}
49+
}
50+
}
51+
52+
/// A file descriptor / handle borrowed for the duration of a callback.
53+
///
54+
/// It is valid and usable inside the supervisor's own process. The supervisor
55+
/// owns the underlying descriptor and closes it as soon as the callback
56+
/// returns, so it must not be stored beyond the callback.
57+
#[derive(Debug, Clone, Copy)]
58+
pub struct BorrowedFile<'a> {
59+
#[cfg(unix)]
60+
fd: BorrowedFd<'a>,
61+
#[cfg(windows)]
62+
handle: BorrowedHandle<'a>,
63+
}
64+
65+
impl<'a> BorrowedFile<'a> {
66+
#[cfg(unix)]
67+
#[must_use]
68+
pub const fn new(fd: BorrowedFd<'a>) -> Self {
69+
Self { fd }
70+
}
71+
72+
#[cfg(windows)]
73+
#[must_use]
74+
pub const fn new(handle: BorrowedHandle<'a>) -> Self {
75+
Self { handle }
76+
}
77+
78+
/// Borrow this fd/handle as a [`std::fs::File`] for `read`/`seek`.
79+
///
80+
/// The returned value is wrapped in [`ManuallyDrop`] and must not be
81+
/// unwrapped and dropped — the supervisor owns and closes the descriptor.
82+
#[must_use]
83+
pub fn as_file(self) -> ManuallyDrop<File> {
84+
#[cfg(unix)]
85+
{
86+
use std::os::fd::{AsRawFd, FromRawFd};
87+
// SAFETY: the fd is valid for the borrow; `ManuallyDrop` ensures
88+
// the `File` destructor never runs, so the fd is not closed here.
89+
ManuallyDrop::new(unsafe { File::from_raw_fd(self.fd.as_raw_fd()) })
90+
}
91+
#[cfg(windows)]
92+
{
93+
use std::os::windows::io::{AsRawHandle, FromRawHandle};
94+
// SAFETY: the handle is valid for the borrow; `ManuallyDrop`
95+
// ensures the `File` destructor never runs.
96+
ManuallyDrop::new(unsafe { File::from_raw_handle(self.handle.as_raw_handle()) })
97+
}
98+
}
99+
}
100+
101+
#[cfg(unix)]
102+
impl AsFd for BorrowedFile<'_> {
103+
fn as_fd(&self) -> BorrowedFd<'_> {
104+
self.fd
105+
}
106+
}
107+
108+
#[cfg(windows)]
109+
impl AsHandle for BorrowedFile<'_> {
110+
fn as_handle(&self) -> BorrowedHandle<'_> {
111+
self.handle
112+
}
113+
}
114+
115+
/// An open/close event delivered to a registered file callback.
116+
///
117+
/// While the callback runs, the traced process is blocked.
118+
#[derive(Debug)]
119+
pub struct FileEvent<'a> {
120+
/// Whether the file was just opened or is about to be closed.
121+
pub kind: FileEventKind,
122+
/// Process id of the traced process that opened/closed the file.
123+
pub pid: u32,
124+
/// Access mode of the file.
125+
pub mode: AccessMode,
126+
/// Path of the file. Always present for [`FileEventKind::Opened`].
127+
pub path: FileEventPath<'a>,
128+
/// A file descriptor / handle usable inside the supervisor process.
129+
pub fd: BorrowedFile<'a>,
130+
}
131+
132+
/// A boxed user callback invoked for matching open/close events.
133+
pub type FileCallbackFn = dyn Fn(FileEvent<'_>) + Send + Sync + 'static;
134+
135+
/// The registered callback together with its access-mode mask.
136+
#[derive(Clone)]
137+
pub struct FileCallback {
138+
/// An event fires the callback only if its mode intersects this mask.
139+
pub mask: AccessMode,
140+
/// The user callback.
141+
pub callback: Arc<FileCallbackFn>,
142+
}

0 commit comments

Comments
 (0)