From 9100d2531bdc3c6391dc90d453149419c0b55e54 Mon Sep 17 00:00:00 2001 From: branchseer Date: Tue, 11 Nov 2025 16:05:33 +0800 Subject: [PATCH 1/6] refactor: simplify fspy::Command API and add pre_exec hook --- Cargo.lock | 2 + Cargo.toml | 1 + crates/fspy/Cargo.toml | 1 + crates/fspy/examples/cli.rs | 4 +- crates/fspy/src/command.rs | 73 ++++++++++---- crates/fspy/src/lib.rs | 39 ++------ crates/fspy/src/unix/mod.rs | 168 ++++++++++++++++---------------- crates/fspy/src/windows/mod.rs | 157 ++++++++++++++--------------- crates/fspy/tests/node_fs.rs | 2 +- crates/fspy/tests/test_utils.rs | 2 +- crates/fspy_e2e/src/main.rs | 3 +- crates/vite_task/src/execute.rs | 10 +- 12 files changed, 237 insertions(+), 225 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b410b0dd..db2ece27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,6 +730,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.106", + "unicode-xid", ] [[package]] @@ -1009,6 +1010,7 @@ dependencies = [ "const_format", "csv-async", "ctor", + "derive_more", "flate2", "fspy_detours_sys", "fspy_preload_unix", diff --git a/Cargo.toml b/Cargo.toml index 4548b104..65d8aa0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ crossterm = { version = "0.29.0", features = ["event-stream"] } csv-async = { version = "1.3.1", features = ["tokio"] } ctor = "0.6" dashmap = "6.1.0" +derive_more = "2.0.1" diff-struct = "0.5.3" directories = "6.0.0" elf = { version = "0.8.0", default-features = false } diff --git a/crates/fspy/Cargo.toml b/crates/fspy/Cargo.toml index 92d484b3..82f6d73f 100644 --- a/crates/fspy/Cargo.toml +++ b/crates/fspy/Cargo.toml @@ -10,6 +10,7 @@ bincode = { workspace = true } bstr = { workspace = true, default-features = false } bumpalo = { workspace = true } const_format = { workspace = true, features = ["fmt"] } +derive_more = { workspace = true, features = ["debug"] } fspy_shared = { workspace = true } futures-util = { workspace = true } libc = { workspace = true } diff --git a/crates/fspy/examples/cli.rs b/crates/fspy/examples/cli.rs index 0aade07b..54b2da0a 100644 --- a/crates/fspy/examples/cli.rs +++ b/crates/fspy/examples/cli.rs @@ -16,9 +16,7 @@ async fn main() -> anyhow::Result<()> { let program = PathBuf::from(args.next().unwrap()); - let spy = fspy::Spy::global()?; - - let mut command = spy.new_command(program); + let mut command = fspy::Command::new(program); command.envs(std::env::vars_os()).args(args); let child = command.spawn().await?; diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index c50b7ab5..034f5886 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -9,32 +9,46 @@ use std::{ use fspy_shared_unix::exec::Exec; use tokio::process::Command as TokioCommand; -use crate::{ - TrackedChild, - error::SpawnError, - os_impl::{self, spawn_impl}, -}; +use crate::{SPY_IMPL, TrackedChild, error::SpawnError}; -#[derive(Debug)] +#[derive(derive_more::Debug)] pub struct Command { - pub(crate) program: OsString, - pub(crate) args: Vec, - pub(crate) envs: HashMap, - pub(crate) cwd: Option, + program: OsString, + args: Vec, + envs: HashMap, + cwd: Option, #[cfg(unix)] - pub(crate) arg0: Option, + arg0: Option, - pub(crate) stderr: Option, - pub(crate) stdout: Option, - pub(crate) stdin: Option, + stderr: Option, + stdout: Option, + stdin: Option, - pub(crate) spy_inner: os_impl::SpyInner, + #[cfg(unix)] + #[debug("({} pre_exec closures)", pre_exec_closures.len())] + pre_exec_closures: Vec std::io::Result<()> + Send + Sync>>, } impl Command { + pub fn new>(program: P) -> Self { + Self { + program: program.as_ref().to_os_string(), + args: Vec::new(), + envs: HashMap::new(), + cwd: None, + #[cfg(unix)] + arg0: None, + stderr: None, + stdout: None, + stdin: None, + #[cfg(unix)] + pre_exec_closures: Vec::new(), + } + } + #[cfg(unix)] #[must_use] - pub fn get_exec(&self) -> Exec { + pub(crate) fn get_exec(&self) -> Exec { use std::{ iter::once, os::unix::ffi::{OsStrExt, OsStringExt}, @@ -57,7 +71,7 @@ impl Command { } #[cfg(unix)] - pub fn set_exec(&mut self, mut exec: Exec) { + pub(crate) fn set_exec(&mut self, mut exec: Exec) { use std::os::unix::ffi::OsStringExt; self.program = OsString::from_vec(exec.program.into()); @@ -147,7 +161,7 @@ impl Command { pub async fn spawn(mut self) -> Result { self.resolve_program()?; - spawn_impl(self).await + SPY_IMPL.spawn(self).await } /// Resolve program name to full path using `PATH` and cwd. @@ -179,7 +193,22 @@ impl Command { Ok(()) } - pub(crate) fn into_tokio_command(self) -> TokioCommand { + /// Schedules a closure to be run just before the exec function is invoked. + /// + /// # Safety + /// + /// https://doc.rust-lang.org/1.91.1/std/os/unix/process/trait.CommandExt.html#tymethod.pre_exec + #[cfg(unix)] + pub unsafe fn pre_exec(&mut self, f: F) -> &mut Self + where + F: FnMut() -> std::io::Result<()> + Send + Sync + 'static, + { + self.pre_exec_closures.push(Box::new(f)); + self + } + + /// Convert to a `tokio::process::Command` without tracking. + pub fn into_tokio_command(self) -> TokioCommand { let mut tokio_cmd = TokioCommand::new(self.program); if let Some(cwd) = &self.cwd { tokio_cmd.current_dir(cwd); @@ -205,6 +234,12 @@ impl Command { tokio_cmd.stderr(stderr); } + #[cfg(unix)] + for pre_exec in self.pre_exec_closures { + // Safety: The caller of `pre_exec` is responsible for ensuring safety. + unsafe { tokio_cmd.pre_exec(pre_exec) }; + } + tokio_cmd } } diff --git a/crates/fspy/src/lib.rs b/crates/fspy/src/lib.rs index 39b8fc76..33f8e8c2 100644 --- a/crates/fspy/src/lib.rs +++ b/crates/fspy/src/lib.rs @@ -20,13 +20,13 @@ mod os_impl; mod arena; mod command; -use std::{env::temp_dir, ffi::OsStr, fs::create_dir, io, process::ExitStatus, sync::OnceLock}; +use std::{env::temp_dir, fs::create_dir, io, process::ExitStatus, sync::LazyLock}; pub use command::Command; pub use fspy_shared::ipc::{AccessMode, PathAccess}; use futures_util::future::BoxFuture; pub use os_impl::PathAccessIterable; -use os_impl::SpyInner; +use os_impl::SpyImpl; use tokio::process::{ChildStderr, ChildStdin, ChildStdout}; /// The result of a tracked child process upon its termination. @@ -54,33 +54,8 @@ pub struct TrackedChild { pub wait_handle: BoxFuture<'static, io::Result>, } -pub struct Spy(SpyInner); -impl Spy { - pub fn new() -> io::Result { - let tmp_dir = temp_dir().join("fspy"); - let _ = create_dir(&tmp_dir); - Ok(Self(SpyInner::init_in(&tmp_dir)?)) - } - - pub fn global() -> io::Result<&'static Self> { - static GLOBAL_SPY: OnceLock = OnceLock::new(); - GLOBAL_SPY.get_or_try_init(Self::new) - } - - pub fn new_command>(&self, program: S) -> Command { - Command { - program: program.as_ref().to_os_string(), - envs: Default::default(), - args: vec![], - cwd: None, - #[cfg(unix)] - arg0: None, - spy_inner: self.0.clone(), - stderr: None, - stdout: None, - stdin: None, - } - } -} - -// pub use fspy_shared::ipc::*; +pub(crate) static SPY_IMPL: LazyLock = LazyLock::new(|| { + let tmp_dir = temp_dir().join("fspy"); + let _ = create_dir(&tmp_dir); + SpyImpl::init_in(&tmp_dir).expect("Failed to initialize global spy") +}); diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index e55e6a50..81cceb1d 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -29,7 +29,7 @@ use crate::{ }; #[derive(Debug, Clone)] -pub struct SpyInner { +pub struct SpyImpl { #[cfg(target_os = "macos")] fixtures: Fixtures, @@ -38,7 +38,7 @@ pub struct SpyInner { const PRELOAD_CDYLIB_BINARY: &[u8] = include_bytes!(env!("CARGO_CDYLIB_FILE_FSPY_PRELOAD_UNIX")); -impl SpyInner { +impl SpyImpl { /// Initialize the fs access spy by writing the preload library on disk pub fn init_in(dir: &Path) -> io::Result { use const_format::formatcp; @@ -66,6 +66,89 @@ impl SpyInner { }, }) } + + pub(crate) async fn spawn(&self, mut command: Command) -> Result { + #[cfg(target_os = "linux")] + let supervisor = supervise::().map_err(SpawnError::SupervisorError)?; + + let (ipc_channel_conf, ipc_receiver) = + channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreationError)?; + + let payload = Payload { + ipc_channel_conf, + + #[cfg(target_os = "macos")] + fixtures: self.fixtures.clone(), + + preload_path: self.preload_path.clone(), + + #[cfg(target_os = "linux")] + seccomp_payload: supervisor.payload().clone(), + }; + + let encoded_payload = encode_payload(payload); + + let mut exec = command.get_exec(); + let mut exec_resolve_accesses = PathAccessArena::default(); + let pre_exec = handle_exec( + &mut exec, + ExecResolveConfig::search_path_enabled(None), + &encoded_payload, + |path_access| { + exec_resolve_accesses.add(path_access); + }, + ) + .map_err(|err| SpawnError::InjectionError(err.into()))?; + command.set_exec(exec); + + let mut tokio_command = command.into_tokio_command(); + + unsafe { + tokio_command.pre_exec(move || { + if let Some(pre_exec) = pre_exec.as_ref() { + pre_exec.run()?; + } + Ok(()) + }); + } + + // tokio_command.spawn blocks while executing the `pre_exec` closure. + // Run it inside spawn_blocking to avoid blocking the tokio runtime, especially the supervisor loop, + // which needs to accept incoming connections while `pre_exec` is connecting to it. + let mut child = spawn_blocking(move || tokio_command.spawn()) + .await + .map_err(|err| SpawnError::OsSpawnError(err.into()))? + .map_err(SpawnError::OsSpawnError)?; + + Ok(TrackedChild { + stdin: child.stdin.take(), + stdout: child.stdout.take(), + stderr: child.stderr.take(), + // Keep polling for the child to exit in the background even if `wait_handle` is not awaited, + // because we need to stop the supervisor and lock the channel as soon as the child exits. + wait_handle: tokio::spawn(async move { + let status = child.wait().await?; + + let arenas = std::iter::once(exec_resolve_accesses); + // Stop the supervisor and collect path accesses from it. + #[cfg(target_os = "linux")] + let arenas = arenas.chain( + supervisor.stop().await?.into_iter().map(|handler| handler.into_arena()), + ); + let arenas = arenas.collect::>(); + + // Lock the ipc channel after the child has exited. + // We are not interested in path accesses from descendants after the main child has exited. + let ipc_receiver_lock_guard = + OwnedReceiverLockGuard::lock_async(ipc_receiver).await?; + let path_accesses = PathAccessIterable { arenas, ipc_receiver_lock_guard }; + + io::Result::Ok(ChildTermination { status, path_accesses }) + }) + .map(|f| io::Result::Ok(f??)) // flatten JoinError and io::Result + .boxed(), + }) + } } pub struct PathAccessIterable { @@ -82,84 +165,3 @@ impl PathAccessIterable { accesses_in_shm.chain(accesses_in_arena) } } - -pub(crate) async fn spawn_impl(mut command: Command) -> Result { - #[cfg(target_os = "linux")] - let supervisor = supervise::().map_err(SpawnError::SupervisorError)?; - - let (ipc_channel_conf, ipc_receiver) = - channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreationError)?; - - let payload = Payload { - ipc_channel_conf, - - #[cfg(target_os = "macos")] - fixtures: command.spy_inner.fixtures.clone(), - - preload_path: command.spy_inner.preload_path.clone(), - - #[cfg(target_os = "linux")] - seccomp_payload: supervisor.payload().clone(), - }; - - let encoded_payload = encode_payload(payload); - - let mut exec = command.get_exec(); - let mut exec_resolve_accesses = PathAccessArena::default(); - let pre_exec = handle_exec( - &mut exec, - ExecResolveConfig::search_path_enabled(None), - &encoded_payload, - |path_access| { - exec_resolve_accesses.add(path_access); - }, - ) - .map_err(|err| SpawnError::InjectionError(err.into()))?; - command.set_exec(exec); - - let mut tokio_command = command.into_tokio_command(); - - unsafe { - tokio_command.pre_exec(move || { - if let Some(pre_exec) = pre_exec.as_ref() { - pre_exec.run()?; - } - Ok(()) - }); - } - - // tokio_command.spawn blocks while executing the `pre_exec` closure. - // Run it inside spawn_blocking to avoid blocking the tokio runtime, especially the supervisor loop, - // which needs to accept incoming connections while `pre_exec` is connecting to it. - let mut child = spawn_blocking(move || tokio_command.spawn()) - .await - .map_err(|err| SpawnError::OsSpawnError(err.into()))? - .map_err(SpawnError::OsSpawnError)?; - - Ok(TrackedChild { - stdin: child.stdin.take(), - stdout: child.stdout.take(), - stderr: child.stderr.take(), - // Keep polling for the child to exit in the background even if `wait_handle` is not awaited, - // because we need to stop the supervisor and lock the channel as soon as the child exits. - wait_handle: tokio::spawn(async move { - let status = child.wait().await?; - - let arenas = std::iter::once(exec_resolve_accesses); - // Stop the supervisor and collect path accesses from it. - #[cfg(target_os = "linux")] - let arenas = arenas - .chain(supervisor.stop().await?.into_iter().map(|handler| handler.into_arena())); - let arenas = arenas.collect::>(); - - // Lock the ipc channel after the child has exited. - // We are not interested in path accesses from descendants after the main child has exited. - let ipc_receiver_lock_guard = OwnedReceiverLockGuard::lock_async(ipc_receiver).await?; - let path_accesses = PathAccessIterable { arenas, ipc_receiver_lock_guard }; - - io::Result::Ok(ChildTermination { status, path_accesses }) - }) - .map(|f| io::Result::Ok(f??)) // flatten JoinError and io::Result - .boxed(), - }) -} diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index 47ff43c8..29c5962c 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -51,11 +51,11 @@ impl PathAccessIterable { // } #[derive(Debug, Clone)] -pub struct SpyInner { +pub struct SpyImpl { asni_dll_path_with_nul: Arc, } -impl SpyInner { +impl SpyImpl { pub fn init_in(path: &Path) -> io::Result { let dll_path = INTERPOSE_CDYLIB.write_to(&path, ".dll").unwrap(); @@ -70,82 +70,83 @@ impl SpyInner { unsafe { CStr::from_bytes_with_nul_unchecked(asni_dll_path.as_slice()) }; Ok(Self { asni_dll_path_with_nul: asni_dll_path_with_nul.into() }) } -} -pub(crate) async fn spawn_impl(command: Command) -> Result { - let asni_dll_path_with_nul = Arc::clone(&command.spy_inner.asni_dll_path_with_nul); - let mut command = command.into_tokio_command(); - - command.creation_flags(CREATE_SUSPENDED); - - let (channel_conf, receiver) = - channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreationError)?; - - let mut spawn_success = false; - let spawn_success = &mut spawn_success; - let mut child = command - .spawn_with(|std_command| { - let std_child = std_command.spawn()?; - *spawn_success = true; - - let mut dll_paths = asni_dll_path_with_nul.as_ptr().cast::(); - let process_handle = std_child.as_raw_handle().cast::(); - let success = unsafe { DetourUpdateProcessWithDll(process_handle, &mut dll_paths, 1) }; - if success != TRUE { - return Err(io::Error::last_os_error()); - } - - let payload = Payload { - channel_conf: channel_conf.clone(), - asni_dll_path_with_nul: asni_dll_path_with_nul.to_bytes(), - }; - let payload_bytes = bincode::encode_to_vec(payload, BINCODE_CONFIG).unwrap(); - let success = unsafe { - DetourCopyPayloadToProcess( - process_handle, - &PAYLOAD_ID, - payload_bytes.as_ptr().cast(), - payload_bytes.len().try_into().unwrap(), - ) - }; - if success != TRUE { - return Err(io::Error::last_os_error()); - } - - let main_thread_handle = std_child.main_thread_handle(); - let resume_thread_ret = - unsafe { ResumeThread(main_thread_handle.as_raw_handle().cast()) } as i32; - - if resume_thread_ret == -1 { - return Err(io::Error::last_os_error()); - } - - Ok(std_child) - }) - .map_err(|err| { - if !*spawn_success { - SpawnError::InjectionError(err.into()) - } else { - SpawnError::OsSpawnError(err.into()) - } - })?; - - Ok(TrackedChild { - stdin: child.stdin.take(), - stdout: child.stdout.take(), - stderr: child.stderr.take(), - // Keep polling for the child to exit in the background even if `wait_handle` is not awaited, - // because we need to stop the supervisor and lock the channel as soon as the child exits. - wait_handle: tokio::spawn(async move { - let status = child.wait().await?; - // Lock the ipc channel after the child has exited. - // We are not interested in path accesses from descendants after the main child has exited. - let ipc_receiver_lock_guard = OwnedReceiverLockGuard::lock_async(receiver).await?; - let path_accesses = PathAccessIterable { ipc_receiver_lock_guard }; - - io::Result::Ok(ChildTermination { status, path_accesses }) + pub(crate) async fn spawn(&self, command: Command) -> Result { + let asni_dll_path_with_nul = Arc::clone(&self.asni_dll_path_with_nul); + let mut command = command.into_tokio_command(); + + command.creation_flags(CREATE_SUSPENDED); + + let (channel_conf, receiver) = + channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreationError)?; + + let mut spawn_success = false; + let spawn_success = &mut spawn_success; + let mut child = command + .spawn_with(|std_command| { + let std_child = std_command.spawn()?; + *spawn_success = true; + + let mut dll_paths = asni_dll_path_with_nul.as_ptr().cast::(); + let process_handle = std_child.as_raw_handle().cast::(); + let success = + unsafe { DetourUpdateProcessWithDll(process_handle, &mut dll_paths, 1) }; + if success != TRUE { + return Err(io::Error::last_os_error()); + } + + let payload = Payload { + channel_conf: channel_conf.clone(), + asni_dll_path_with_nul: asni_dll_path_with_nul.to_bytes(), + }; + let payload_bytes = bincode::encode_to_vec(payload, BINCODE_CONFIG).unwrap(); + let success = unsafe { + DetourCopyPayloadToProcess( + process_handle, + &PAYLOAD_ID, + payload_bytes.as_ptr().cast(), + payload_bytes.len().try_into().unwrap(), + ) + }; + if success != TRUE { + return Err(io::Error::last_os_error()); + } + + let main_thread_handle = std_child.main_thread_handle(); + let resume_thread_ret = + unsafe { ResumeThread(main_thread_handle.as_raw_handle().cast()) } as i32; + + if resume_thread_ret == -1 { + return Err(io::Error::last_os_error()); + } + + Ok(std_child) + }) + .map_err(|err| { + if !*spawn_success { + SpawnError::InjectionError(err.into()) + } else { + SpawnError::OsSpawnError(err.into()) + } + })?; + + Ok(TrackedChild { + stdin: child.stdin.take(), + stdout: child.stdout.take(), + stderr: child.stderr.take(), + // Keep polling for the child to exit in the background even if `wait_handle` is not awaited, + // because we need to stop the supervisor and lock the channel as soon as the child exits. + wait_handle: tokio::spawn(async move { + let status = child.wait().await?; + // Lock the ipc channel after the child has exited. + // We are not interested in path accesses from descendants after the main child has exited. + let ipc_receiver_lock_guard = OwnedReceiverLockGuard::lock_async(receiver).await?; + let path_accesses = PathAccessIterable { ipc_receiver_lock_guard }; + + io::Result::Ok(ChildTermination { status, path_accesses }) + }) + .map(|f| io::Result::Ok(f??)) // flatten JoinError and io::Result + .boxed(), }) - .map(|f| io::Result::Ok(f??)) // flatten JoinError and io::Result - .boxed(), - }) + } } diff --git a/crates/fspy/tests/node_fs.rs b/crates/fspy/tests/node_fs.rs index f77a43a6..f1563663 100644 --- a/crates/fspy/tests/node_fs.rs +++ b/crates/fspy/tests/node_fs.rs @@ -6,7 +6,7 @@ use fspy::{AccessMode, PathAccessIterable}; use test_utils::assert_contains; async fn track_node_script(script: &str) -> anyhow::Result { - let mut command = fspy::Spy::global()?.new_command("node"); + let mut command = fspy::Command::new("node"); command .arg("-e") .envs(vars_os()) // https://github.com/jdx/mise/discussions/5968 diff --git a/crates/fspy/tests/test_utils.rs b/crates/fspy/tests/test_utils.rs index e5ef61ee..7adc1b40 100644 --- a/crates/fspy/tests/test_utils.rs +++ b/crates/fspy/tests/test_utils.rs @@ -53,7 +53,7 @@ macro_rules! track_child { } pub async fn _spawn_with_id(id: &str) -> anyhow::Result { - let mut command = fspy::Spy::global()?.new_command(::std::env::current_exe()?); + let mut command = fspy::Command::new(::std::env::current_exe()?); command.arg(id); let termination = command.spawn().await?.wait_handle.await?; assert!(termination.status.success()); diff --git a/crates/fspy_e2e/src/main.rs b/crates/fspy_e2e/src/main.rs index b220f9b5..b7e49442 100644 --- a/crates/fspy_e2e/src/main.rs +++ b/crates/fspy_e2e/src/main.rs @@ -70,7 +70,6 @@ async fn main() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let config = read(manifest_dir.join("e2e_config.toml")).unwrap(); let config: Config = toml::from_slice(&config).unwrap(); - let spy = fspy::Spy::global().unwrap(); for (name, case) in config.cases { if let Some(filter) = &filter && !name.contains(filter) @@ -78,7 +77,7 @@ async fn main() { continue; } println!("Running case `{}` in dir `{}`", name, case.dir); - let mut cmd = spy.new_command(case.cmd[0].clone()); + let mut cmd = fspy::Command::new(case.cmd[0].clone()); let dir = manifest_dir.join(&case.dir); cmd.args(&case.cmd[1..]) .envs(env::vars_os()) diff --git a/crates/vite_task/src/execute.rs b/crates/vite_task/src/execute.rs index 7d73bb66..606287f0 100644 --- a/crates/vite_task/src/execute.rs +++ b/crates/vite_task/src/execute.rs @@ -9,7 +9,7 @@ use std::{ }; use bincode::{Decode, Encode}; -use fspy::{AccessMode, Spy}; +use fspy::AccessMode; use futures_util::future::try_join3; use itertools::Itertools; use serde::{Deserialize, Serialize}; @@ -330,17 +330,15 @@ pub async fn execute_task( resolved_command: &ResolvedTaskCommand, base_dir: &AbsolutePath, ) -> Result { - let spy = Spy::global()?; - let mut cmd = match &resolved_command.fingerprint.command { TaskCommand::ShellScript(script) => { let mut cmd = if cfg!(windows) { - let mut cmd = spy.new_command("cmd.exe"); + let mut cmd = fspy::Command::new("cmd.exe"); // https://github.com/nodejs/node/blob/dbd24b165128affb7468ca42f69edaf7e0d85a9a/lib/child_process.js#L633 cmd.args(["/d", "/s", "/c"]); cmd } else { - let mut cmd = spy.new_command("sh"); + let mut cmd = fspy::Command::new("sh"); cmd.args(["-c"]); cmd }; @@ -422,7 +420,7 @@ pub async fn execute_task( duration, }); } - let mut cmd = spy.new_command(&task_parsed_command.program); + let mut cmd = fspy::Command::new(&task_parsed_command.program); cmd.args(&task_parsed_command.args); cmd.envs(&resolved_command.all_envs); cmd.envs(&task_parsed_command.envs); From 8ede3cdde4a4dbd2566cac3244f49d4d9c64bade Mon Sep 17 00:00:00 2001 From: branchseer Date: Tue, 11 Nov 2025 16:22:15 +0800 Subject: [PATCH 2/6] use command_executing in track_child --- Cargo.lock | 2 +- crates/fspy/Cargo.toml | 2 +- crates/fspy/tests/rust_std.rs | 6 +++--- crates/fspy/tests/rust_tokio.rs | 6 +++--- crates/fspy/tests/test_utils.rs | 33 +++++++++++++-------------------- 5 files changed, 21 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db2ece27..bb72412c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1009,7 +1009,6 @@ dependencies = [ "bumpalo", "const_format", "csv-async", - "ctor", "derive_more", "flate2", "fspy_detours_sys", @@ -1019,6 +1018,7 @@ dependencies = [ "fspy_shared", "fspy_shared_unix", "fspy_test_bin", + "fspy_test_utils", "futures-util", "libc", "nix 0.30.1", diff --git a/crates/fspy/Cargo.toml b/crates/fspy/Cargo.toml index 82f6d73f..59aeaac1 100644 --- a/crates/fspy/Cargo.toml +++ b/crates/fspy/Cargo.toml @@ -12,6 +12,7 @@ bumpalo = { workspace = true } const_format = { workspace = true, features = ["fmt"] } derive_more = { workspace = true, features = ["debug"] } fspy_shared = { workspace = true } +fspy_test_utils = { workspace = true } futures-util = { workspace = true } libc = { workspace = true } ouroboros = { workspace = true } @@ -44,7 +45,6 @@ tempfile = { workspace = true } [dev-dependencies] anyhow = { workspace = true } csv-async = { workspace = true } -ctor = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs", "io-std"] } [target.'cfg(all(target_os = "linux", target_arch = "aarch64"))'.dev-dependencies] diff --git a/crates/fspy/tests/rust_std.rs b/crates/fspy/tests/rust_std.rs index b6c3ae7c..7f63533b 100644 --- a/crates/fspy/tests/rust_std.rs +++ b/crates/fspy/tests/rust_std.rs @@ -13,7 +13,7 @@ use test_utils::assert_contains; #[tokio::test] async fn open_read() -> anyhow::Result<()> { let accesses = track_child!({ - File::open("hello"); + let _ = File::open("hello"); }) .await?; assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read); @@ -25,7 +25,7 @@ async fn open_read() -> anyhow::Result<()> { async fn open_write() -> anyhow::Result<()> { let accesses = track_child!({ let path = format!("{}/hello", env!("CARGO_TARGET_TMPDIR")); - OpenOptions::new().write(true).open(path); + let _ = OpenOptions::new().write(true).open(path); }) .await?; assert_contains( @@ -41,7 +41,7 @@ async fn open_write() -> anyhow::Result<()> { async fn readdir() -> anyhow::Result<()> { let accesses = track_child!({ let path = format!("{}/hello", env!("CARGO_TARGET_TMPDIR")); - std::fs::read_dir(path); + let _ = std::fs::read_dir(path); }) .await?; assert_contains( diff --git a/crates/fspy/tests/rust_tokio.rs b/crates/fspy/tests/rust_tokio.rs index a07a0bfb..c73c1514 100644 --- a/crates/fspy/tests/rust_tokio.rs +++ b/crates/fspy/tests/rust_tokio.rs @@ -11,7 +11,7 @@ async fn open_read() -> anyhow::Result<()> { let accesses = track_child!({ tokio::runtime::Builder::new_current_thread().enable_io().build().unwrap().block_on( async { - tokio::fs::File::open("hello").await; + let _ = tokio::fs::File::open("hello").await; }, ); }) @@ -28,7 +28,7 @@ async fn open_write() -> anyhow::Result<()> { tokio::runtime::Builder::new_current_thread().enable_io().build().unwrap().block_on( async { - OpenOptions::new().write(true).open(path).await; + let _ = OpenOptions::new().write(true).open(path).await; }, ); }) @@ -49,7 +49,7 @@ async fn readdir() -> anyhow::Result<()> { tokio::runtime::Builder::new_current_thread().enable_io().build().unwrap().block_on( async { - tokio::fs::read_dir(path).await; + let _ = tokio::fs::read_dir(path).await; }, ); }) diff --git a/crates/fspy/tests/test_utils.rs b/crates/fspy/tests/test_utils.rs index 7adc1b40..dc8a5a8d 100644 --- a/crates/fspy/tests/test_utils.rs +++ b/crates/fspy/tests/test_utils.rs @@ -1,6 +1,9 @@ use std::path::{Path, PathBuf, StripPrefixError}; use fspy::{AccessMode, PathAccessIterable}; +#[doc(hidden)] +#[allow(unused)] +pub use fspy_test_utils::command_executing; #[track_caller] pub fn assert_contains( @@ -32,29 +35,19 @@ pub fn assert_contains( #[macro_export] macro_rules! track_child { ($body: block) => {{ - const ID: &str = - ::core::concat!(::core::file!(), ":", ::core::line!(), ":", ::core::column!()); - #[ctor::ctor] - unsafe fn init() { - let mut args = ::std::env::args(); - let Some(_) = args.next() else { - return; - }; - let Some(current_id) = args.next() else { - return; - }; - if current_id == ID { - $body; - ::std::process::exit(0); - } - } - $crate::test_utils::_spawn_with_id(ID) + let std_cmd = $crate::test_utils::command_executing!((), |(): ()| { + let _ = $body; + }); + $crate::test_utils::spawn_std(std_cmd) }}; } -pub async fn _spawn_with_id(id: &str) -> anyhow::Result { - let mut command = fspy::Command::new(::std::env::current_exe()?); - command.arg(id); +#[doc(hidden)] +#[allow(unused)] +pub async fn spawn_std(std_cmd: std::process::Command) -> anyhow::Result { + let mut command = fspy::Command::new(std_cmd.get_program()); + command.args(std_cmd.get_args()); + let termination = command.spawn().await?.wait_handle.await?; assert!(termination.status.success()); Ok(termination.path_accesses) From e48ba917893e9777213c0a23ce6d54cd8c4c0c53 Mon Sep 17 00:00:00 2001 From: branchseer Date: Tue, 11 Nov 2025 16:39:37 +0800 Subject: [PATCH 3/6] add doc --- crates/fspy/src/command.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index 034f5886..08e38acf 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -30,6 +30,9 @@ pub struct Command { } impl Command { + /// Create a new command to spy on the given program. + /// Initially, environment variables are not inherited from the parent. + /// To inherit, explicitly use `.envs(std::env::vars_os())`. pub fn new>(program: P) -> Self { Self { program: program.as_ref().to_os_string(), From 69156d7008180a645c3621b2202f8327395730ff Mon Sep 17 00:00:00 2001 From: branchseer Date: Tue, 11 Nov 2025 16:39:57 +0800 Subject: [PATCH 4/6] fix ctor not found --- Cargo.lock | 1 + Cargo.toml | 10 ++++++++-- crates/fspy/Cargo.toml | 1 + crates/fspy_test_utils/src/lib.rs | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bb72412c..4c3764ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1009,6 +1009,7 @@ dependencies = [ "bumpalo", "const_format", "csv-async", + "ctor", "derive_more", "flate2", "fspy_detours_sys", diff --git a/Cargo.toml b/Cargo.toml index 65d8aa0e..72847020 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,8 +125,14 @@ winsafe = { version = "0.0.24", features = ["kernel"] } xxhash-rust = { version = "0.8.15", features = ["const_xxh3"] } [workspace.metadata.cargo-shear] -# These are artifact dependencies. They are not directly `use`d in Rust code. -ignored = ["fspy_preload_unix", "fspy_preload_windows", "fspy_test_bin"] +ignored = [ + # These are artifact dependencies. They are not directly `use`d in Rust code. + "fspy_preload_unix", + "fspy_preload_windows", + "fspy_test_bin", + # used in a macro in crates/fspy_test_utils/src/lib.rs + "ctor", +] [profile.dev] # Disabling debug info speeds up local and CI builds, diff --git a/crates/fspy/Cargo.toml b/crates/fspy/Cargo.toml index 59aeaac1..57dae313 100644 --- a/crates/fspy/Cargo.toml +++ b/crates/fspy/Cargo.toml @@ -45,6 +45,7 @@ tempfile = { workspace = true } [dev-dependencies] anyhow = { workspace = true } csv-async = { workspace = true } +ctor = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs", "io-std"] } [target.'cfg(all(target_os = "linux", target_arch = "aarch64"))'.dev-dependencies] diff --git a/crates/fspy_test_utils/src/lib.rs b/crates/fspy_test_utils/src/lib.rs index ebfce0b5..ff7ac348 100644 --- a/crates/fspy_test_utils/src/lib.rs +++ b/crates/fspy_test_utils/src/lib.rs @@ -18,7 +18,7 @@ macro_rules! command_executing { assert_arg_type(&$arg, $f); // Register an initializer that runs the provided function when the process is started - #[ctor::ctor] + #[::ctor::ctor] unsafe fn init() { $crate::init_impl(ID, $f); } From 32ae1e6c8cc9b3d8267d7e2e91fdf0b5883b66de Mon Sep 17 00:00:00 2001 From: branchseer Date: Tue, 11 Nov 2025 16:44:42 +0800 Subject: [PATCH 5/6] update usage in static_executable.rs --- crates/fspy/tests/static_executable.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fspy/tests/static_executable.rs b/crates/fspy/tests/static_executable.rs index 705a4173..d02bc2df 100644 --- a/crates/fspy/tests/static_executable.rs +++ b/crates/fspy/tests/static_executable.rs @@ -36,7 +36,7 @@ fn test_bin_path() -> &'static Path { } async fn track_test_bin(args: &[&str], cwd: Option<&str>) -> PathAccessIterable { - let mut cmd = fspy::Spy::global().unwrap().new_command(test_bin_path()); + let mut cmd = fspy::Command::new(test_bin_path()); if let Some(cwd) = cwd { cmd.current_dir(cwd); }; From 06ea7d45c2dea8f068420a6cd0dcf2e8abdcbb09 Mon Sep 17 00:00:00 2001 From: branchseer Date: Tue, 11 Nov 2025 16:45:18 +0800 Subject: [PATCH 6/6] fix doc error --- crates/fspy/src/command.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index 08e38acf..8e882775 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -200,7 +200,7 @@ impl Command { /// /// # Safety /// - /// https://doc.rust-lang.org/1.91.1/std/os/unix/process/trait.CommandExt.html#tymethod.pre_exec + /// #[cfg(unix)] pub unsafe fn pre_exec(&mut self, f: F) -> &mut Self where