diff --git a/Cargo.lock b/Cargo.lock index f31c68a5e..b2d3463f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,7 +1243,6 @@ dependencies = [ "bumpalo", "csv-async", "ctor", - "derive_more", "flate2", "fspy_detours_sys", "fspy_preload_unix", @@ -1258,7 +1257,6 @@ dependencies = [ "materialized_artifact_build", "nix 0.31.2", "ouroboros", - "rustc-hash", "sha2 0.11.0", "subprocess_test", "tar", @@ -3572,9 +3570,7 @@ version = "0.0.0" dependencies = [ "base64", "ctor", - "fspy", "portable-pty", - "rustc-hash", "wincode", ] diff --git a/crates/fspy/Cargo.toml b/crates/fspy/Cargo.toml index f99d25241..09c41a983 100644 --- a/crates/fspy/Cargo.toml +++ b/crates/fspy/Cargo.toml @@ -9,13 +9,11 @@ publish = false wincode = { workspace = true } bstr = { workspace = true, default-features = false } bumpalo = { workspace = true } -derive_more = { workspace = true, features = ["debug"] } materialized_artifact = { workspace = true } fspy_shared = { workspace = true } futures-util = { workspace = true } libc = { workspace = true } ouroboros = { workspace = true } -rustc-hash = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["net", "process", "io-util", "sync", "rt"] } @@ -43,7 +41,7 @@ tempfile = { workspace = true } anyhow = { workspace = true } csv-async = { workspace = true } ctor = { workspace = true } -subprocess_test = { workspace = true, features = ["fspy"] } +subprocess_test = { workspace = true } test-log = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs", "io-std"] } diff --git a/crates/fspy/README.md b/crates/fspy/README.md index cf7fcba0e..59f3a8b7d 100644 --- a/crates/fspy/README.md +++ b/crates/fspy/README.md @@ -21,7 +21,7 @@ It uses [Detours](https://github.com/microsoft/Detours) to intercept file system ## Unified interface -The unified interface of `Command` is in `src/command.rs`. +The unified interface accepts a `tokio::process::Command`. On Unix, use `spawn_with` to apply stdio and other opaque settings to the final rewritten command. ## Preload Libraries diff --git a/crates/fspy/examples/cli.rs b/crates/fspy/examples/cli.rs index 4ae34790f..4b5944e06 100644 --- a/crates/fspy/examples/cli.rs +++ b/crates/fspy/examples/cli.rs @@ -15,10 +15,10 @@ async fn main() -> anyhow::Result<()> { let program = PathBuf::from(args.next().unwrap()); - let mut command = fspy::Command::new(program); + let mut command = tokio::process::Command::new(program); command.envs(std::env::vars_os()).args(args); - let child = command.spawn(tokio_util::sync::CancellationToken::new()).await?; + let child = fspy::spawn(command, tokio_util::sync::CancellationToken::new()).await?; let termination = child.wait_handle.await?; let mut path_count = 0usize; diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index fb150b26c..9d9dd7469 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -1,265 +1,42 @@ -use std::{ - ffi::{OsStr, OsString}, - path::{Path, PathBuf}, - process::Stdio, -}; - -#[cfg(unix)] -use fspy_shared_unix::exec::Exec; -use rustc_hash::FxHashMap; -use tokio::process::Command as TokioCommand; +use tokio::process::Command; use tokio_util::sync::CancellationToken; use crate::{SPY_IMPL, TrackedChild, error::SpawnError}; -#[derive(derive_more::Debug)] -pub struct Command { - program: OsString, - args: Vec, - envs: FxHashMap, - cwd: Option, - #[cfg(unix)] - arg0: Option, - - stderr: Option, - stdout: Option, - stdin: Option, - - #[cfg(unix)] - #[debug("({} pre_exec closures)", pre_exec_closures.len())] - pre_exec_closures: Vec std::io::Result<()> + Send + Sync>>, +/// Spawn the command with file system access tracking. +/// +/// # Errors +/// +/// Returns [`SpawnError`] if program resolution fails or the process cannot be spawned. +/// +/// On Unix, use [`spawn_with`] to configure stdio and other settings that `Command` +/// does not expose through getters. +/// On Windows, fspy replaces configured process creation flags with `CREATE_SUSPENDED`, +/// which is required for injection before the process starts. +pub async fn spawn( + command: Command, + cancellation_token: CancellationToken, +) -> Result { + spawn_with(command, cancellation_token, |_| {}).await } -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(), - args: Vec::new(), - envs: FxHashMap::default(), - cwd: None, - #[cfg(unix)] - arg0: None, - stderr: None, - stdout: None, - stdin: None, - #[cfg(unix)] - pre_exec_closures: Vec::new(), - } - } - - #[cfg(unix)] - #[must_use] - pub(crate) fn get_exec(&self) -> Exec { - use std::{ - iter::once, - os::unix::ffi::{OsStrExt, OsStringExt}, - }; - - use bstr::{BString, ByteSlice as _}; - let arg0 = - BString::from(self.arg0.clone().unwrap_or_else(|| self.program.clone()).into_vec()); - Exec { - program: self.program.as_bytes().into(), - args: once(arg0) - .chain(self.args.iter().map(|arg| arg.as_bytes().as_bstr().to_owned())) - .collect(), - envs: self - .envs - .iter() - .map(|(name, value)| (name.as_bytes().into(), Some(value.as_bytes().into()))) - .collect(), - } - } - - #[cfg(unix)] - pub(crate) fn set_exec(&mut self, mut exec: Exec) { - use std::os::unix::ffi::OsStringExt; - - self.program = OsString::from_vec(exec.program.into()); - self.arg0 = Some(OsString::from_vec(exec.args.remove(0).into())); - self.args = exec.args.into_iter().map(|arg| OsString::from_vec(arg.into())).collect(); - self.envs = exec - .envs - .into_iter() - .map(|(name, value)| { - ( - OsString::from_vec(name.into()), - OsString::from_vec(value.unwrap_or_default().into()), - ) - }) - .collect(); - } - - pub fn env_remove>(&mut self, key: K) -> &mut Self { - self.envs.remove(key.as_ref()); - self - } - - pub fn stderr>(&mut self, cfg: T) -> &mut Self { - self.stderr = Some(cfg.into()); - self - } - - pub fn stdout>(&mut self, cfg: T) -> &mut Self { - self.stdout = Some(cfg.into()); - self - } - - pub fn stdin>(&mut self, cfg: T) -> &mut Self { - self.stdin = Some(cfg.into()); - self - } - - pub fn env(&mut self, key: K, val: V) -> &mut Self - where - K: AsRef, - V: AsRef, - { - self.envs.insert(key.as_ref().to_os_string(), val.as_ref().to_os_string()); - self - } - - pub fn envs(&mut self, vars: I) -> &mut Self - where - I: IntoIterator, - K: AsRef, - V: AsRef, - { - self.envs.extend( - vars.into_iter() - .map(|(key, val)| (key.as_ref().to_os_string(), val.as_ref().to_os_string())), - ); - self - } - - pub fn current_dir>(&mut self, dir: P) -> &mut Self { - self.cwd = Some(dir.as_ref().to_owned()); - self - } - - pub fn arg>(&mut self, arg: S) -> &mut Self { - self.args.push(arg.as_ref().to_os_string()); - self - } - - pub fn args(&mut self, args: I) -> &mut Self - where - I: IntoIterator, - S: AsRef, - { - self.args.extend(args.into_iter().map(|arg| arg.as_ref().to_os_string())); - self - } - - #[cfg(unix)] - pub fn arg0(&mut self, arg: S) -> &mut Self - where - S: AsRef, - { - self.arg0 = Some(arg.as_ref().to_os_string()); - self - } - - /// Spawn the command with file system access tracking. - /// - /// # Errors - /// - /// Returns [`SpawnError`] if program resolution fails or the process cannot be spawned. - pub async fn spawn( - mut self, - cancellation_token: CancellationToken, - ) -> Result { - self.resolve_program()?; - SPY_IMPL.spawn(self, cancellation_token).await - } - - /// Resolve program name to full path using `PATH` and cwd. - /// - /// # Errors - /// - /// Returns [`SpawnError::Which`] if the program cannot be found in `PATH`. - /// - /// # Panics - /// - /// Panics if no `cwd` is set and `std::env::current_dir()` fails. - pub fn resolve_program(&mut self) -> Result<(), SpawnError> { - let mut path_env: Option<&OsStr> = None; - for (env_name, env_value) in &self.envs { - let Some(env_name) = env_name.to_str() else { - continue; - }; - if env_name.eq_ignore_ascii_case("path") { - path_env = Some(env_value.as_ref()); - break; - } - } - - let cwd = self - .cwd - .clone() - .unwrap_or_else(|| std::env::current_dir().expect("failed to get current dir")); - self.program = which::which_in(self.program.as_os_str(), path_env, &cwd) - .map_err(|err| SpawnError::Which { - program: self.program.clone(), - path: path_env.map(OsStr::to_owned), - cwd, - cause: err, - })? - .into_os_string(); - Ok(()) - } - - /// Schedules a closure to be run just before the exec function is invoked. - /// - /// # Safety - /// - /// - #[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. - #[must_use] - pub(crate) 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); - } - - #[cfg(unix)] - if let Some(arg0) = self.arg0 { - tokio_cmd.arg0(arg0); - } - tokio_cmd.args(self.args); - tokio_cmd.env_clear(); - tokio_cmd.envs(self.envs); - - if let Some(stdin) = self.stdin { - tokio_cmd.stdin(stdin); - } - - if let Some(stdout) = self.stdout { - tokio_cmd.stdout(stdout); - } - - if let Some(stderr) = self.stderr { - 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 - } +/// Spawn the command with file system access tracking, configuring the final command before spawn. +/// +/// On Unix, fspy rebuilds the command after resolving shebangs and injection. Use `configure` +/// only for settings that do not affect resolution, including stdio, custom `arg0`, and +/// `pre_exec` hooks that do not change the cwd or filesystem view. Configure the program, +/// arguments, environment, and cwd on the input command. +/// +/// # Errors +/// +/// Returns [`SpawnError`] if program resolution fails or the process cannot be spawned. +pub async fn spawn_with( + command: Command, + cancellation_token: CancellationToken, + configure: F, +) -> Result +where + F: FnOnce(&mut Command), +{ + SPY_IMPL.spawn(command, cancellation_token, configure).await } diff --git a/crates/fspy/src/lib.rs b/crates/fspy/src/lib.rs index 6c89414ba..a57ddb552 100644 --- a/crates/fspy/src/lib.rs +++ b/crates/fspy/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg_attr(unix, feature(command_resolved_envs))] #![cfg_attr(target_os = "windows", feature(windows_process_extensions_main_thread_handle))] pub mod error; @@ -19,7 +20,7 @@ mod command; use std::{env::temp_dir, fs::create_dir, io, process::ExitStatus, sync::LazyLock}; -pub use command::Command; +pub use command::{spawn, spawn_with}; pub use fspy_shared::ipc::{AccessMode, PathAccess}; use futures_util::future::BoxFuture; pub use os_impl::PathAccessIterable; diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index f01f63b5d..b4b54e071 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -4,29 +4,37 @@ mod syscall_handler; #[cfg(target_os = "macos")] mod macos_artifacts; -use std::{io, path::Path}; +#[cfg(not(target_env = "musl"))] +use std::ptr; +use std::{ + ffi::{OsStr, OsString}, + io, + os::unix::ffi::{OsStrExt as _, OsStringExt as _}, + path::Path, +}; +use bstr::BString; #[cfg(target_os = "linux")] use fspy_seccomp_unotify::supervisor::supervise; -use fspy_shared::ipc::PathAccess; +use fspy_shared::ipc::{AccessMode, PathAccess}; #[cfg(not(target_env = "musl"))] use fspy_shared::ipc::{NativeStr, channel::channel}; #[cfg(target_os = "macos")] use fspy_shared_unix::payload::Artifacts; use fspy_shared_unix::{ - exec::ExecResolveConfig, + exec::{Exec, ExecResolveConfig}, payload::{Payload, encode_payload}, spawn::handle_exec, }; use futures_util::FutureExt; #[cfg(target_os = "linux")] use syscall_handler::SyscallHandler; -use tokio::task::spawn_blocking; +use tokio::{process::Command, task::spawn_blocking}; use tokio_util::sync::CancellationToken; #[cfg(not(target_env = "musl"))] use crate::ipc::{OwnedReceiverLockGuard, SHM_CAPACITY}; -use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError}; +use crate::{ChildTermination, TrackedChild, arena::PathAccessArena, error::SpawnError}; #[derive(Debug)] pub struct SpyImpl { @@ -69,11 +77,15 @@ impl SpyImpl { }) } - pub(crate) async fn spawn( + pub(crate) async fn spawn( &self, - mut command: Command, + command: Command, cancellation_token: CancellationToken, - ) -> Result { + configure: F, + ) -> Result + where + F: FnOnce(&mut Command), + { #[cfg(target_os = "linux")] let supervisor = supervise::().map_err(SpawnError::Supervisor)?; @@ -97,36 +109,40 @@ impl SpyImpl { let encoded_payload = encode_payload(payload); - let mut exec = command.get_exec(); + let cwd = command.as_std().get_current_dir().map(Path::to_path_buf); + let kill_on_drop = command.get_kill_on_drop(); let mut exec_resolve_accesses = PathAccessArena::default(); + let mut exec = command_to_exec(&command, |mode, path| { + exec_resolve_accesses.add(PathAccess { mode, path: path.into() }); + })?; let pre_exec = handle_exec( &mut exec, - ExecResolveConfig::search_path_enabled(None), + ExecResolveConfig::search_path_disabled(), &encoded_payload, |mode, path| { exec_resolve_accesses.add(PathAccess { mode, path: path.into() }); }, ) .map_err(|err| SpawnError::Injection(err.into()))?; - command.set_exec(exec); - command.env("FSPY", "1"); + set_exec_env(&mut exec, b"FSPY", b"1"); + let mut command = exec_to_command(exec, cwd, kill_on_drop); + configure(&mut command); - let mut tokio_command = command.into_tokio_command(); - - // SAFETY: the pre_exec closure only calls pre_exec.run() which is safe to call in a fork context - unsafe { - tokio_command.pre_exec(move || { - if let Some(pre_exec) = pre_exec.as_ref() { + if let Some(pre_exec) = pre_exec { + // SAFETY: the pre_exec closure only calls pre_exec.run(), which is + // safe to call in a post-fork context. + unsafe { + command.pre_exec(move || { pre_exec.run()?; - } - Ok(()) - }); + Ok(()) + }); + } } - // tokio_command.spawn blocks while executing the `pre_exec` closure. + // 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()) + let mut child = spawn_blocking(move || command.spawn()) .await .map_err(|err| SpawnError::OsSpawn(err.into()))? .map_err(SpawnError::OsSpawn)?; @@ -177,6 +193,111 @@ impl SpyImpl { } } +fn exec_to_command(mut exec: Exec, cwd: Option, kill_on_drop: bool) -> Command { + let mut command = Command::new(OsString::from_vec(exec.program.into())); + command.arg0(OsString::from_vec(exec.args.remove(0).into())); + command.args(exec.args.into_iter().map(|arg| OsString::from_vec(arg.into()))); + command.env_clear(); + command.envs(exec.envs.into_iter().map(|(name, value)| { + (OsString::from_vec(name.into()), OsString::from_vec(value.unwrap_or_default().into())) + })); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + command.kill_on_drop(kill_on_drop); + command +} + +fn command_to_exec( + command: &Command, + mut on_path_access: impl FnMut(AccessMode, &Path), +) -> Result { + let command = command.as_std(); + let resolved_envs = command.get_resolved_envs().collect::>(); + let configured_path = resolved_envs + .iter() + .find_map(|(name, value)| (name == "PATH").then_some(value.as_os_str())); + + let cwd = command.get_current_dir().map_or_else( + || std::env::current_dir().expect("failed to get current dir"), + Path::to_path_buf, + ); + let cwd = std::path::absolute(cwd).expect("failed to resolve current dir"); + let search_path = configured_path.map_or_else(default_search_path, OsStr::to_os_string); + let program = resolve_program(command.get_program(), &search_path, &cwd, &mut on_path_access) + .map_err(|cause| SpawnError::Which { + program: command.get_program().to_os_string(), + path: Some(search_path), + cwd, + cause, + })?; + + let args = std::iter::once(command.get_program()) + .chain(command.get_args()) + .map(|arg| BString::from(arg.as_bytes().to_vec())) + .collect(); + let envs = resolved_envs + .into_iter() + .map(|(name, value)| { + (BString::from(name.into_vec()), Some(BString::from(value.into_vec()))) + }) + .collect(); + + Ok(Exec { program: BString::from(program.into_os_string().into_vec()), args, envs }) +} + +fn resolve_program( + program: &OsStr, + search_path: &OsStr, + cwd: &Path, + mut on_path_access: impl FnMut(AccessMode, &Path), +) -> Result { + if program.as_bytes().contains(&b'/') { + return which::which_in(program, Option::<&OsStr>::None, cwd); + } + + for directory in std::env::split_paths(search_path) { + let directory = if directory.is_absolute() { directory } else { cwd.join(directory) }; + let candidate = directory.join(program); + on_path_access(AccessMode::READ, &candidate); + if let Ok(program) = which::which_in(candidate, Option::<&OsStr>::None, cwd) { + return Ok(program); + } + } + Err(which::Error::CannotFindBinaryPath) +} + +#[cfg(target_env = "musl")] +fn default_search_path() -> OsString { + OsString::from("/usr/local/bin:/bin:/usr/bin") +} + +#[cfg(not(target_env = "musl"))] +fn default_search_path() -> OsString { + // SAFETY: A null buffer asks `confstr` for the required allocation size. + let size = unsafe { libc::confstr(libc::_CS_PATH, ptr::null_mut(), 0) }; + if size == 0 { + return OsString::from("/bin:/usr/bin"); + } + + let mut bytes = vec![0; size]; + // SAFETY: `bytes` has the size returned by the preceding `confstr` call. + let written = unsafe { libc::confstr(libc::_CS_PATH, bytes.as_mut_ptr().cast(), bytes.len()) }; + if written == 0 { + return OsString::from("/bin:/usr/bin"); + } + bytes.truncate(written.saturating_sub(1)); + OsString::from_vec(bytes) +} + +fn set_exec_env(exec: &mut Exec, name: &[u8], value: &[u8]) { + if let Some((_, existing_value)) = exec.envs.iter_mut().find(|(env_name, _)| env_name == name) { + *existing_value = Some(BString::from(value.to_vec())); + } else { + exec.envs.push((BString::from(name.to_vec()), Some(BString::from(value.to_vec())))); + } +} + pub struct PathAccessIterable { arenas: Vec, #[cfg(not(target_env = "musl"))] diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index 8081e1298..33abea105 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -13,6 +13,7 @@ use fspy_shared::{ }; use futures_util::FutureExt; use materialized_artifact::{Artifact, artifact}; +use tokio::process::Command; use tokio_util::sync::CancellationToken; use winapi::{ shared::minwindef::TRUE, @@ -22,7 +23,6 @@ use winsafe::co::{CP, WC}; use crate::{ ChildTermination, TrackedChild, - command::Command, error::SpawnError, ipc::{OwnedReceiverLockGuard, SHM_CAPACITY}, }; @@ -66,15 +66,23 @@ impl SpyImpl { Ok(Self { ansi_dll_path_with_nul: ansi_dll_path_with_nul.into() }) } - #[expect(clippy::unused_async, reason = "async signature required by SpyImpl trait")] - pub(crate) async fn spawn( + #[expect( + clippy::unused_async, + clippy::unused_async_trait_impl, + reason = "async signature matches the Unix implementation" + )] + pub(crate) async fn spawn( &self, mut command: Command, cancellation_token: CancellationToken, - ) -> Result { + configure: F, + ) -> Result + where + F: FnOnce(&mut Command), + { let ansi_dll_path_with_nul = Arc::clone(&self.ansi_dll_path_with_nul); + configure(&mut command); command.env("FSPY", "1"); - let mut command = command.into_tokio_command(); command.creation_flags(CREATE_SUSPENDED); diff --git a/crates/fspy/tests/cancellation.rs b/crates/fspy/tests/cancellation.rs index ff65cf2c0..88c23dadf 100644 --- a/crates/fspy/tests/cancellation.rs +++ b/crates/fspy/tests/cancellation.rs @@ -14,9 +14,11 @@ async fn cancellation_kills_tracked_child() -> anyhow::Result<()> { let _ = std::io::stdin().read_line(&mut String::new()); }); let token = CancellationToken::new(); - let mut fspy_cmd = fspy::Command::from(cmd); - fspy_cmd.stdout(Stdio::piped()).stdin(Stdio::piped()); - let mut child = fspy_cmd.spawn(token.clone()).await?; + let cmd = tokio::process::Command::from(cmd); + let mut child = fspy::spawn_with(cmd, token.clone(), |cmd| { + cmd.stdout(Stdio::piped()).stdin(Stdio::piped()); + }) + .await?; // Wait for child to signal readiness let mut stdout = child.stdout.take().unwrap(); @@ -30,3 +32,51 @@ async fn cancellation_kills_tracked_child() -> anyhow::Result<()> { assert!(!termination.status.success()); Ok(()) } + +#[test_log::test(tokio::test)] +async fn preserves_configured_environment() -> anyhow::Result<()> { + let cmd = subprocess_test::command_for_fn!((), |()| { + assert_eq!(std::env::var_os("FSPY_TEST_ENV").as_deref(), Some(std::ffi::OsStr::new("set"))); + assert_eq!(std::env::var_os("PATH"), None); + }); + let mut cmd = tokio::process::Command::from(cmd); + cmd.env("FSPY_TEST_ENV", "set").env_remove("PATH"); + + let child = fspy::spawn(cmd, CancellationToken::new()).await?; + let termination = child.wait_handle.await?; + assert!(termination.status.success()); + Ok(()) +} + +#[cfg(unix)] +#[test_log::test(tokio::test)] +async fn resolves_relative_path_from_child_cwd() -> anyhow::Result<()> { + use std::os::unix::fs::symlink; + + let child_command = subprocess_test::command_for_fn!((), |()| {}); + let temp = tempfile::tempdir()?; + let cwd = temp.path().join("child:cwd"); + let bin_dir = cwd.join("bin"); + std::fs::create_dir(&cwd)?; + std::fs::create_dir(&bin_dir)?; + symlink(std::env::current_exe()?, bin_dir.join("child"))?; + + let mut command = tokio::process::Command::new("child"); + command.args(child_command.get_args()).env("PATH", "bin").current_dir(cwd); + let child = fspy::spawn(command, CancellationToken::new()).await?; + let termination = child.wait_handle.await?; + assert!(termination.status.success()); + Ok(()) +} + +#[cfg(unix)] +#[test_log::test(tokio::test)] +async fn resolves_program_with_default_path() -> anyhow::Result<()> { + let mut command = tokio::process::Command::new("sh"); + command.env_clear().args(["-c", "test \"$0\" = sh"]); + + let child = fspy::spawn(command, CancellationToken::new()).await?; + let termination = child.wait_handle.await?; + assert!(termination.status.success()); + Ok(()) +} diff --git a/crates/fspy/tests/node_fs.rs b/crates/fspy/tests/node_fs.rs index 077495ac2..09cae6d20 100644 --- a/crates/fspy/tests/node_fs.rs +++ b/crates/fspy/tests/node_fs.rs @@ -10,13 +10,13 @@ use test_log::test; use test_utils::assert_contains; async fn track_node_script(script: &str, args: &[&OsStr]) -> anyhow::Result { - let mut command = fspy::Command::new("node"); + let mut command = tokio::process::Command::new("node"); command .arg("-e") .envs(vars_os()) // https://github.com/jdx/mise/discussions/5968 .arg(script) .args(args); - let child = command.spawn(tokio_util::sync::CancellationToken::new()).await?; + let child = fspy::spawn(command, tokio_util::sync::CancellationToken::new()).await?; let termination = child.wait_handle.await?; assert!(termination.status.success()); Ok(termination.path_accesses) diff --git a/crates/fspy/tests/oxlint.rs b/crates/fspy/tests/oxlint.rs index fe4a96291..c9ca83a68 100644 --- a/crates/fspy/tests/oxlint.rs +++ b/crates/fspy/tests/oxlint.rs @@ -30,7 +30,7 @@ fn find_oxlint() -> std::path::PathBuf { async fn track_oxlint(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { let oxlint_path = find_oxlint(); - let mut command = fspy::Command::new(&oxlint_path); + let mut command = tokio::process::Command::new(&oxlint_path); // Build PATH with packages/tools/.bin prepended so oxlint can find tsgolint let tools_dir = tools_bin_dir(); @@ -48,7 +48,7 @@ async fn track_oxlint(dir: &std::path::Path, args: &[&str]) -> anyhow::Result &'static Path { } async fn track_test_bin(args: &[&str], cwd: Option<&str>) -> PathAccessIterable { - let mut cmd = fspy::Command::new(test_bin_path()); + let mut cmd = tokio::process::Command::new(test_bin_path()); if let Some(cwd) = cwd { cmd.current_dir(cwd); } cmd.args(args); - let tracked_child = cmd.spawn(tokio_util::sync::CancellationToken::new()).await.unwrap(); + let tracked_child = fspy::spawn(cmd, tokio_util::sync::CancellationToken::new()).await.unwrap(); let termination = tracked_child.wait_handle.await.unwrap(); assert!(termination.status.success()); diff --git a/crates/fspy/tests/test_utils/mod.rs b/crates/fspy/tests/test_utils/mod.rs index cfa46c4a9..717ff1160 100644 --- a/crates/fspy/tests/test_utils/mod.rs +++ b/crates/fspy/tests/test_utils/mod.rs @@ -77,12 +77,12 @@ macro_rules! track_fn { reason = "allow attribute required for conditionally-used helper" )] #[allow(dead_code, reason = "used by track_fn! macro; not all test files use this macro")] -pub async fn spawn_command(cmd: subprocess_test::Command) -> anyhow::Result { - let termination = fspy::Command::from(cmd) - .spawn(tokio_util::sync::CancellationToken::new()) - .await? - .wait_handle - .await?; +pub async fn spawn_command(cmd: std::process::Command) -> anyhow::Result { + let termination = + fspy::spawn(tokio::process::Command::from(cmd), tokio_util::sync::CancellationToken::new()) + .await? + .wait_handle + .await?; assert!(termination.status.success()); Ok(termination.path_accesses) } diff --git a/crates/fspy_e2e/src/main.rs b/crates/fspy_e2e/src/main.rs index 9d6a30525..e8ace82ab 100644 --- a/crates/fspy_e2e/src/main.rs +++ b/crates/fspy_e2e/src/main.rs @@ -75,17 +75,16 @@ async fn main() { continue; } println!("Running case `{}` in dir `{}`", name, case.dir); - let mut cmd = fspy::Command::new(case.cmd[0].clone()); + let mut cmd = tokio::process::Command::new(case.cmd[0].clone()); let dir = manifest_dir.join(&case.dir); - cmd.args(&case.cmd[1..]) - .envs(env::vars_os()) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .current_dir(&dir); + cmd.args(&case.cmd[1..]).envs(env::vars_os()).current_dir(&dir); let mut tracked_child = - cmd.spawn(tokio_util::sync::CancellationToken::new()).await.unwrap(); + fspy::spawn_with(cmd, tokio_util::sync::CancellationToken::new(), |cmd| { + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + }) + .await + .unwrap(); let mut stdout_bytes = Vec::::new(); tracked_child.stdout.take().unwrap().read_to_end(&mut stdout_bytes).await.unwrap(); diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index eb0738129..279f31e62 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -228,13 +228,13 @@ mod tests { #[test] fn smoke() { let (conf, receiver) = channel(100).unwrap(); - let cmd = command_for_fn!(conf, |conf: ChannelConf| { + let mut cmd = command_for_fn!(conf, |conf: ChannelConf| { let sender = conf.sender().unwrap(); let frame_size = NonZeroUsize::new(2).unwrap(); let mut frame = sender.claim_frame(frame_size).unwrap(); frame.copy_from_slice(&[4, 2]); }); - assert!(std::process::Command::from(cmd).status().unwrap().success()); + assert!(cmd.status().unwrap().success()); let lock = receiver.lock().unwrap(); let mut frames = lock.iter_frames(); @@ -251,10 +251,10 @@ mod tests { let (conf, receiver) = channel(42).unwrap(); let _lock = receiver.lock().unwrap(); - let cmd = command_for_fn!(conf, |conf: ChannelConf| { + let mut cmd = command_for_fn!(conf, |conf: ChannelConf| { print!("{}", conf.sender().is_ok()); }); - let output = std::process::Command::from(cmd).output().unwrap(); + let output = cmd.output().unwrap(); assert_eq!(B(&output.stdout), B("false")); } @@ -264,10 +264,10 @@ mod tests { let (conf, receiver) = channel(42).unwrap(); drop(receiver); - let cmd = command_for_fn!(conf, |conf: ChannelConf| { + let mut cmd = command_for_fn!(conf, |conf: ChannelConf| { print!("{}", conf.sender().is_ok()); }); - let output = std::process::Command::from(cmd).output().unwrap(); + let output = cmd.output().unwrap(); assert_eq!(B(&output.stdout), B("false")); } @@ -275,7 +275,7 @@ mod tests { fn concurrent_senders() { let (conf, receiver) = channel(8192).unwrap(); for i in 0u16..200 { - let cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| { + let mut cmd = command_for_fn!((conf.clone(), i), |(conf, i): (ChannelConf, u16)| { let sender = conf.sender().unwrap(); let data_to_send = i.to_string(); sender @@ -283,7 +283,7 @@ mod tests { .unwrap() .copy_from_slice(data_to_send.as_bytes()); }); - let output = std::process::Command::from(cmd).output().unwrap(); + let output = cmd.output().unwrap(); assert!( output.status.success(), "Failed to send in iteration {}: {:?}", diff --git a/crates/fspy_shared/src/ipc/channel/shm_io.rs b/crates/fspy_shared/src/ipc/channel/shm_io.rs index 7890043de..9efd06ecd 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io.rs @@ -320,11 +320,7 @@ impl> ShmReader { #[cfg(test)] mod tests { - use std::{ - process::{Child, Command}, - sync::Arc, - thread, - }; + use std::{process::Child, sync::Arc, thread}; use assert2::assert; use bstr::BStr; @@ -676,7 +672,7 @@ mod tests { let children: Vec = (0..CHILD_COUNT) .map(|child_index| { - let cmd = command_for_fn!( + let mut cmd = command_for_fn!( (shm_name.clone(), child_index), |(shm_name, child_index): (String, usize)| { let shm = ShmemConf::new().os_id(shm_name).open().unwrap(); @@ -690,7 +686,7 @@ mod tests { } } ); - Command::from(cmd).spawn().unwrap() + cmd.spawn().unwrap() }) .collect(); diff --git a/crates/pty_terminal/tests/terminal.rs b/crates/pty_terminal/tests/terminal.rs index 17bf0f7ac..c3ae2ec64 100644 --- a/crates/pty_terminal/tests/terminal.rs +++ b/crates/pty_terminal/tests/terminal.rs @@ -4,15 +4,14 @@ use std::{ }; use ntest::timeout; -use portable_pty::CommandBuilder; use pty_terminal::{geo::ScreenSize, terminal::Terminal}; -use subprocess_test::command_for_fn; +use subprocess_test::{command_for_fn, portable_pty_command_builder}; #[test] #[timeout(5000)] #[expect(clippy::print_stdout, reason = "subprocess test output")] fn is_terminal() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + let cmd = portable_pty_command_builder(&command_for_fn!((), |(): ()| { println!("{} {} {}", stdin().is_terminal(), stdout().is_terminal(), stderr().is_terminal()); })); @@ -29,7 +28,7 @@ fn is_terminal() { #[timeout(5000)] #[expect(clippy::print_stdout, reason = "subprocess test output")] fn write_basic_echo() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + let cmd = portable_pty_command_builder(&command_for_fn!((), |(): ()| { use std::io::{BufRead, Write, stdin, stdout}; let stdin = stdin(); let mut stdout = stdout(); @@ -58,7 +57,7 @@ fn write_basic_echo() { #[timeout(5000)] #[expect(clippy::print_stdout, reason = "subprocess test output")] fn write_multiple_lines() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + let cmd = portable_pty_command_builder(&command_for_fn!((), |(): ()| { use std::io::{BufRead, Write, stdin, stdout}; let stdin = stdin(); let mut stdout = stdout(); @@ -109,7 +108,7 @@ fn write_multiple_lines() { #[timeout(5000)] #[expect(clippy::print_stdout, reason = "subprocess test output")] fn write_after_exit() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + let cmd = portable_pty_command_builder(&command_for_fn!((), |(): ()| { print!("exiting"); })); @@ -137,7 +136,7 @@ fn write_after_exit() { #[timeout(5000)] #[expect(clippy::print_stdout, reason = "subprocess test output")] fn write_interactive_prompt() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + let cmd = portable_pty_command_builder(&command_for_fn!((), |(): ()| { use std::io::{Write, stdin, stdout}; let mut stdout = stdout(); // Use "Name:\n" instead of "Name: " so the test can synchronize with @@ -178,7 +177,7 @@ fn write_interactive_prompt() { #[timeout(5000)] #[expect(clippy::print_stdout, reason = "subprocess test output")] fn resize_terminal() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + let cmd = portable_pty_command_builder(&command_for_fn!((), |(): ()| { use std::io::{Write, stdin, stdout}; #[cfg(unix)] use std::sync::Arc; @@ -275,7 +274,7 @@ fn resize_terminal() { #[timeout(5000)] #[expect(clippy::print_stdout, reason = "subprocess test output")] fn send_ctrl_c_interrupts_process() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + let cmd = portable_pty_command_builder(&command_for_fn!((), |(): ()| { use std::io::{Write, stdout}; // On Linux, use signalfd to wait for SIGINT without signal handlers or @@ -376,7 +375,7 @@ fn send_ctrl_c_interrupts_process() { #[timeout(5000)] #[expect(clippy::print_stdout, reason = "subprocess test output")] fn read_to_end_returns_exit_status_success() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + let cmd = portable_pty_command_builder(&command_for_fn!((), |(): ()| { println!("success"); })); @@ -392,7 +391,7 @@ fn read_to_end_returns_exit_status_success() { #[test] #[timeout(5000)] fn read_to_end_returns_exit_status_nonzero() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + let cmd = portable_pty_command_builder(&command_for_fn!((), |(): ()| { std::process::exit(42); })); diff --git a/crates/pty_terminal_test/tests/milestone.rs b/crates/pty_terminal_test/tests/milestone.rs index e878e0690..6174d4d03 100644 --- a/crates/pty_terminal_test/tests/milestone.rs +++ b/crates/pty_terminal_test/tests/milestone.rs @@ -1,15 +1,14 @@ use std::io::Write; use ntest::timeout; -use portable_pty::CommandBuilder; use pty_terminal::geo::ScreenSize; use pty_terminal_test::TestTerminal; -use subprocess_test::command_for_fn; +use subprocess_test::{command_for_fn, portable_pty_command_builder}; #[test] #[timeout(5000)] fn milestone_raw_mode_keystrokes() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + let cmd = portable_pty_command_builder(&command_for_fn!((), |(): ()| { use std::io::{Read, Write, stdout}; // Enable raw mode (cross-platform via crossterm) @@ -77,7 +76,7 @@ fn milestone_raw_mode_keystrokes() { #[test] #[timeout(5000)] fn milestone_does_not_pollute_screen() { - let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| { + let cmd = portable_pty_command_builder(&command_for_fn!((), |(): ()| { use std::io::{Read, Write, stdout}; crossterm::terminal::enable_raw_mode().unwrap(); diff --git a/crates/subprocess_test/Cargo.toml b/crates/subprocess_test/Cargo.toml index 720099a6a..02a1305b4 100644 --- a/crates/subprocess_test/Cargo.toml +++ b/crates/subprocess_test/Cargo.toml @@ -11,13 +11,10 @@ rust-version.workspace = true base64 = { workspace = true } wincode = { workspace = true } ctor = { workspace = true } -fspy = { workspace = true, optional = true } portable-pty = { workspace = true, optional = true } -rustc-hash = { workspace = true } [features] default = [] -fspy = ["dep:fspy"] portable-pty = ["dep:portable-pty"] [lints] diff --git a/crates/subprocess_test/README.md b/crates/subprocess_test/README.md index 461abd617..44f359d0f 100644 --- a/crates/subprocess_test/README.md +++ b/crates/subprocess_test/README.md @@ -19,10 +19,9 @@ Then use the macro in your tests: ```rust use subprocess_test::command_for_fn; -let cmd = command_for_fn!(42u32, |arg: u32| { +let mut cmd = command_for_fn!(42u32, |arg: u32| { println!("{}", arg); }); -// Convert to std::process::Command and execute -let output = std::process::Command::from(cmd).output().unwrap(); +let output = cmd.output().unwrap(); ``` diff --git a/crates/subprocess_test/src/lib.rs b/crates/subprocess_test/src/lib.rs index f9f81b970..18b8e889d 100644 --- a/crates/subprocess_test/src/lib.rs +++ b/crates/subprocess_test/src/lib.rs @@ -1,54 +1,33 @@ -use std::{env::current_exe, ffi::OsString, path::PathBuf, process::Command as StdCommand}; +#![cfg_attr(feature = "portable-pty", feature(command_resolved_envs))] + +use std::{env::current_exe, ffi::OsString, process::Command}; use base64::{Engine, prelude::BASE64_STANDARD_NO_PAD}; -use rustc_hash::FxHashMap; use wincode::{SchemaReadOwned, SchemaWrite, config::DefaultConfig}; -/// A command configuration that can be converted to `std::process::Command` -/// or `fspy::Command` for execution. -#[derive(Debug, Clone)] -pub struct Command { - pub program: OsString, - pub args: Vec, - pub envs: FxHashMap, - pub cwd: PathBuf, -} - -impl From for StdCommand { - fn from(cmd: Command) -> Self { - let mut std_cmd = Self::new(cmd.program); - std_cmd.args(cmd.args); - std_cmd.env_clear().envs(cmd.envs); - std_cmd.current_dir(cmd.cwd); - std_cmd - } -} - -#[cfg(feature = "fspy")] -impl From for fspy::Command { - fn from(cmd: Command) -> Self { - let mut fspy_cmd = Self::new(cmd.program); - fspy_cmd.args(cmd.args).envs(cmd.envs); - fspy_cmd.current_dir(cmd.cwd); - fspy_cmd - } -} - +/// Copies the program, ordinary arguments, resolved environment, and cwd into a PTY command. +/// Windows raw arguments are not preserved because [`Command`] does not expose them. +/// +/// # Panics +/// +/// Panics if the current directory cannot be read when the command does not configure one. #[cfg(feature = "portable-pty")] -impl From for portable_pty::CommandBuilder { - fn from(cmd: Command) -> Self { - let mut cmd_builder = Self::new(cmd.program); - cmd_builder.args(cmd.args); - cmd_builder.env_clear(); - for (key, value) in cmd.envs { - cmd_builder.env(key, value); - } - cmd_builder.cwd(cmd.cwd); - cmd_builder +#[must_use] +pub fn portable_pty_command_builder(command: &Command) -> portable_pty::CommandBuilder { + let mut builder = portable_pty::CommandBuilder::new(command.get_program()); + builder.args(command.get_args()); + builder.env_clear(); + for (key, value) in command.get_resolved_envs() { + builder.env(key, value); } + builder.cwd(command.get_current_dir().map_or_else( + || std::env::current_dir().expect("failed to get current dir"), + std::path::Path::to_path_buf, + )); + builder } -/// Creates a `subprocess_test::Command` that only executes the provided function. +/// Creates a [`std::process::Command`] that only executes the provided function. /// /// - $arg: The argument to pass to the function, must implement `SchemaWrite` and `SchemaReadOwned`. /// - $f: The function to run in the separate process, takes one argument of the type of $arg. @@ -140,27 +119,57 @@ pub fn create_command>(id: &str, arg: T) let arg_bytes = wincode::serialize(&arg).expect("Failed to encode arg"); let arg_base64 = BASE64_STANDARD_NO_PAD.encode(&arg_bytes); - let args = vec![OsString::from(id), OsString::from(arg_base64)]; - let envs: FxHashMap = std::env::vars_os().collect(); - let cwd = std::env::current_dir().unwrap(); - - Command { program, args, envs, cwd } + let mut command = Command::new(program); + command.args([OsString::from(id), OsString::from(arg_base64)]); + command.env_clear().envs(std::env::vars_os()); + command.current_dir(std::env::current_dir().unwrap()); + command } #[cfg(test)] mod tests { use std::str::from_utf8; - use crate::StdCommand; - #[test] #[expect(clippy::print_stdout, reason = "test diagnostics")] fn test_command_for_fn() { - let command = command_for_fn!(42u32, |arg: u32| { + let mut command = command_for_fn!(42u32, |arg: u32| { print!("{arg}"); }); - let output = StdCommand::from(command).output().unwrap(); + let output = command.output().unwrap(); assert_eq!(from_utf8(&output.stdout), Ok("42")); assert!(output.status.success()); } + + #[cfg(feature = "portable-pty")] + #[test] + fn converts_std_command_to_portable_pty() { + use std::{ffi::OsStr, process::Command}; + + let cwd = std::env::current_dir().unwrap(); + let mut command = Command::new("program"); + command + .args(["first", "second"]) + .env_clear() + .env("KEEP", "value") + .env("REMOVE", "unused") + .env_remove("REMOVE") + .current_dir(&cwd); + + let builder = crate::portable_pty_command_builder(&command); + assert_eq!(builder.get_argv(), &["program", "first", "second"]); + assert_eq!(builder.get_env("KEEP"), Some(OsStr::new("value"))); + assert_eq!(builder.get_env("REMOVE"), None); + assert_eq!(builder.get_cwd(), Some(&cwd.into_os_string())); + } + + #[cfg(feature = "portable-pty")] + #[test] + fn resolves_inherited_environment_and_cwd_for_portable_pty() { + let command = std::process::Command::new("program"); + let builder = crate::portable_pty_command_builder(&command); + + assert_eq!(builder.get_env("PATH"), std::env::var_os("PATH").as_deref(),); + assert_eq!(builder.get_cwd(), Some(&std::env::current_dir().unwrap().into_os_string()),); + } } diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 2e1dc049b..939637ea9 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -70,60 +70,34 @@ where K: AsRef, V: AsRef, { - #[cfg(fspy)] - if fspy { - return spawn_fspy(cmd, stdio, cancellation_token, extra_envs).await; - } - #[cfg(not(fspy))] - let _ = fspy; - let mut tokio_cmd = tokio::process::Command::new(cmd.program_path.as_path()); tokio_cmd.args(cmd.args.iter().map(vite_str::Str::as_str)); tokio_cmd.env_clear(); tokio_cmd.envs(cmd.spawn_envs.iter()); tokio_cmd.envs(extra_envs); tokio_cmd.current_dir(&*cmd.cwd); + + #[cfg(fspy)] + if fspy { + return spawn_fspy(tokio_cmd, stdio, cancellation_token).await; + } + #[cfg(not(fspy))] + let _ = fspy; + apply_stdio(&mut tokio_cmd, stdio); spawn_tokio(tokio_cmd, cancellation_token) } #[cfg(fspy)] -async fn spawn_fspy( - cmd: &SpawnCommand, +async fn spawn_fspy( + tokio_cmd: tokio::process::Command, stdio: SpawnStdio, cancellation_token: CancellationToken, - extra_envs: E, -) -> anyhow::Result -where - E: IntoIterator, - K: AsRef, - V: AsRef, -{ - let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path()); - fspy_cmd.args(cmd.args.iter().map(vite_str::Str::as_str)); - fspy_cmd.envs(cmd.spawn_envs.iter()); - fspy_cmd.envs(extra_envs); - fspy_cmd.current_dir(&*cmd.cwd); - - match stdio { - SpawnStdio::Inherited => { - fspy_cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit()); - // libuv (used by Node.js) marks stdin/stdout/stderr as close-on-exec; - // without this fix the child reopens fds 0-2 as /dev/null after exec. - // See: https://github.com/libuv/libuv/issues/2062 - // SAFETY: the pre_exec closure only performs fcntl operations on - // stdio fds, which is safe in a post-fork context. - #[cfg(unix)] - unsafe { - fspy_cmd.pre_exec(clear_stdio_cloexec); - } - } - SpawnStdio::Piped => { - fspy_cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); - } - } - - let mut tracked = fspy_cmd.spawn(cancellation_token).await?; +) -> anyhow::Result { + let mut tracked = fspy::spawn_with(tokio_cmd, cancellation_token, |cmd| { + apply_stdio(cmd, stdio); + }) + .await?; // On Windows, assign the child to a Job Object so that killing the child // also kills all descendant processes (e.g., node.exe via a .cmd shim). diff --git a/rust-toolchain.toml b/rust-toolchain.toml index c42b17fcc..a293029a8 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,6 +1,7 @@ [toolchain] # Needed nightly features: # - cargo `Z-bindeps` to build and embed preload shared libraries as dependencies of fspy +# - `command_resolved_envs` to pass configured commands through fspy and PTY test helpers # - `windows_process_extensions_main_thread_handle` to get the main thread handle for Detours injection channel = "nightly-2026-06-07" profile = "default"