|
| 1 | +use std::{ |
| 2 | + collections::HashMap, |
| 3 | + process::{ExitStatus, Stdio}, |
| 4 | +}; |
| 5 | + |
| 6 | +use fspy::AccessMode; |
| 7 | +use tokio::process::Command; |
| 8 | +use vite_error::Error; |
| 9 | +use vite_path::AbsolutePath; |
| 10 | + |
| 11 | +/// Result of running a command with fspy tracking. |
| 12 | +#[derive(Debug)] |
| 13 | +pub struct FspyCommandResult { |
| 14 | + /// The termination status of the command. |
| 15 | + pub status: ExitStatus, |
| 16 | + /// The filtered list of path accesses (path, mode). |
| 17 | + pub path_accesses: Vec<(String, AccessMode)>, |
| 18 | +} |
| 19 | + |
| 20 | +/// Run a command with the given bin name, arguments, environment variables, and current working directory. |
| 21 | +/// |
| 22 | +/// # Arguments |
| 23 | +/// |
| 24 | +/// * `bin_name`: The name of the binary to run. |
| 25 | +/// * `args`: The arguments to pass to the binary. |
| 26 | +/// * `envs`: The custom environment variables to set for the command, will be merged with the system environment variables. |
| 27 | +/// * `cwd`: The current working directory for the command. |
| 28 | +/// |
| 29 | +/// # Returns |
| 30 | +/// |
| 31 | +/// Returns the exit status of the command. |
| 32 | +pub async fn run_command( |
| 33 | + bin_name: &str, |
| 34 | + args: &[&str], |
| 35 | + envs: &HashMap<String, String>, |
| 36 | + cwd: impl AsRef<AbsolutePath>, |
| 37 | +) -> Result<ExitStatus, Error> { |
| 38 | + tracing::debug!("Running: {} {}", bin_name, args.join(" ")); |
| 39 | + |
| 40 | + // Resolve the command path using which crate |
| 41 | + // If PATH is provided in envs, use which_in to search in custom paths |
| 42 | + // Otherwise, use which to search in system PATH |
| 43 | + let paths = envs.get("PATH"); |
| 44 | + let cwd = cwd.as_ref(); |
| 45 | + let bin_path = which::which_in(bin_name, paths, cwd) |
| 46 | + .map_err(|_| Error::CannotFindBinaryPath(bin_name.into()))?; |
| 47 | + |
| 48 | + let mut cmd = Command::new(bin_path); |
| 49 | + cmd.args(args) |
| 50 | + .envs(envs) |
| 51 | + .current_dir(cwd) |
| 52 | + .stdin(Stdio::inherit()) |
| 53 | + .stdout(Stdio::inherit()) |
| 54 | + .stderr(Stdio::inherit()); |
| 55 | + |
| 56 | + // fix stdio streams on unix |
| 57 | + #[cfg(unix)] |
| 58 | + unsafe { |
| 59 | + cmd.pre_exec(|| { |
| 60 | + fix_stdio_streams(); |
| 61 | + Ok(()) |
| 62 | + }); |
| 63 | + } |
| 64 | + |
| 65 | + let status = cmd.status().await?; |
| 66 | + Ok(status) |
| 67 | +} |
| 68 | + |
| 69 | +/// Run a command with fspy tracking and filter the path accesses. |
| 70 | +/// |
| 71 | +/// # Arguments |
| 72 | +/// |
| 73 | +/// * `bin_name`: The name of the binary to run. |
| 74 | +/// * `args`: The arguments to pass to the binary. |
| 75 | +/// * `envs`: The custom environment variables to set for the command. |
| 76 | +/// * `cwd`: The current working directory for the command. |
| 77 | +/// * `filter`: A function that takes path string and access mode and returns true if it should be included. |
| 78 | +/// |
| 79 | +/// # Returns |
| 80 | +/// |
| 81 | +/// Returns a FspyCommandResult containing the exit status and filtered path accesses. |
| 82 | +pub async fn run_command_with_fspy<F>( |
| 83 | + bin_name: &str, |
| 84 | + args: &[&str], |
| 85 | + envs: &HashMap<String, String>, |
| 86 | + cwd: impl AsRef<AbsolutePath>, |
| 87 | + filter: F, |
| 88 | +) -> Result<FspyCommandResult, Error> |
| 89 | +where |
| 90 | + F: Fn(&str, AccessMode) -> bool, |
| 91 | +{ |
| 92 | + let mut cmd = fspy::Spy::global()?.new_command(bin_name); |
| 93 | + cmd.args(args) |
| 94 | + .envs(envs) |
| 95 | + .current_dir(cwd.as_ref()) |
| 96 | + .stdin(Stdio::inherit()) |
| 97 | + .stdout(Stdio::inherit()) |
| 98 | + .stderr(Stdio::inherit()); |
| 99 | + |
| 100 | + let child = cmd.spawn().await.map_err(|e| Error::Anyhow(e.into()))?; |
| 101 | + let termination = child.wait_handle.await?; |
| 102 | + |
| 103 | + // Filter path accesses using the provided filter function |
| 104 | + let path_accesses: Vec<(String, AccessMode)> = termination |
| 105 | + .path_accesses |
| 106 | + .iter() |
| 107 | + .filter_map(|access| { |
| 108 | + tracing::debug!("Path access: {:?}", access); |
| 109 | + // Convert NativeStr to OsStr to String |
| 110 | + let path_os_str = access.path.to_cow_os_str(); |
| 111 | + let path_str = path_os_str.to_string_lossy(); |
| 112 | + |
| 113 | + if filter(&path_str, access.mode) { |
| 114 | + Some((path_str.into_owned(), access.mode)) |
| 115 | + } else { |
| 116 | + None |
| 117 | + } |
| 118 | + }) |
| 119 | + .collect(); |
| 120 | + |
| 121 | + Ok(FspyCommandResult { status: termination.status, path_accesses }) |
| 122 | +} |
| 123 | + |
| 124 | +#[cfg(unix)] |
| 125 | +fn fix_stdio_streams() { |
| 126 | + // libuv may mark stdin/stdout/stderr as close-on-exec, which interferes with Rust's subprocess spawning. |
| 127 | + // As a workaround, we clear the FD_CLOEXEC flag on these file descriptors to prevent them from being closed when spawning child processes. |
| 128 | + // |
| 129 | + // For details see https://github.com/libuv/libuv/issues/2062 |
| 130 | + // Fixed by reference from https://github.com/electron/electron/pull/15555 |
| 131 | + |
| 132 | + use std::os::fd::BorrowedFd; |
| 133 | + |
| 134 | + use nix::{ |
| 135 | + fcntl::{FcntlArg, FdFlag, fcntl}, |
| 136 | + libc::{STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO}, |
| 137 | + }; |
| 138 | + |
| 139 | + // Safe function to clear FD_CLOEXEC flag |
| 140 | + fn clear_cloexec(fd: BorrowedFd<'_>) { |
| 141 | + // Borrow RawFd as BorrowedFd to satisfy AsFd constraint |
| 142 | + if let Ok(flags) = fcntl(fd, FcntlArg::F_GETFD) { |
| 143 | + let mut fd_flags = FdFlag::from_bits_retain(flags); |
| 144 | + if fd_flags.contains(FdFlag::FD_CLOEXEC) { |
| 145 | + fd_flags.remove(FdFlag::FD_CLOEXEC); |
| 146 | + // Ignore errors: some fd may be closed |
| 147 | + let _ = fcntl(fd, FcntlArg::F_SETFD(fd_flags)); |
| 148 | + } |
| 149 | + } |
| 150 | + } |
| 151 | + |
| 152 | + // Clear FD_CLOEXEC on stdin, stdout, stderr |
| 153 | + clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDIN_FILENO) }); |
| 154 | + clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDOUT_FILENO) }); |
| 155 | + clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDERR_FILENO) }); |
| 156 | +} |
| 157 | + |
| 158 | +#[cfg(test)] |
| 159 | +mod tests { |
| 160 | + use tempfile::{TempDir, tempdir}; |
| 161 | + use vite_path::AbsolutePathBuf; |
| 162 | + |
| 163 | + use super::*; |
| 164 | + |
| 165 | + fn create_temp_dir() -> TempDir { |
| 166 | + tempdir().expect("Failed to create temp directory") |
| 167 | + } |
| 168 | + |
| 169 | + mod run_command_tests { |
| 170 | + |
| 171 | + use super::*; |
| 172 | + |
| 173 | + #[tokio::test] |
| 174 | + async fn test_run_command_and_find_binary_path() { |
| 175 | + let temp_dir = create_temp_dir(); |
| 176 | + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); |
| 177 | + let envs = HashMap::from([( |
| 178 | + "PATH".to_string(), |
| 179 | + std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(), |
| 180 | + )]); |
| 181 | + let result = run_command("npm", &["--version"], &envs, &temp_dir_path).await; |
| 182 | + assert!(result.is_ok(), "Should run command successfully, but got error: {:?}", result); |
| 183 | + } |
| 184 | + |
| 185 | + #[tokio::test] |
| 186 | + async fn test_run_command_and_not_find_binary_path() { |
| 187 | + let temp_dir = create_temp_dir(); |
| 188 | + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); |
| 189 | + let envs = HashMap::from([( |
| 190 | + "PATH".to_string(), |
| 191 | + std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(), |
| 192 | + )]); |
| 193 | + let result = run_command("npm-not-exists", &["--version"], &envs, &temp_dir_path).await; |
| 194 | + assert!(result.is_err(), "Should not find binary path, but got: {:?}", result); |
| 195 | + assert_eq!( |
| 196 | + result.unwrap_err().to_string(), |
| 197 | + "Cannot find binary path for command 'npm-not-exists'" |
| 198 | + ); |
| 199 | + } |
| 200 | + } |
| 201 | + |
| 202 | + mod run_command_with_fspy_tests { |
| 203 | + use super::*; |
| 204 | + |
| 205 | + #[tokio::test] |
| 206 | + async fn test_run_command_with_fspy_with_path_filter() { |
| 207 | + let temp_dir = create_temp_dir(); |
| 208 | + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); |
| 209 | + let envs = HashMap::from([( |
| 210 | + "PATH".to_string(), |
| 211 | + std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(), |
| 212 | + )]); |
| 213 | + // Test with a filter that only accepts paths containing "foo" |
| 214 | + let result = run_command_with_fspy( |
| 215 | + "node", |
| 216 | + &["-e", "require('fs').mkdirSync('foo/bar/baz', { recursive: true }); require('fs').writeFileSync('foo/bar/baz/package.json', '{}');" ], |
| 217 | + &envs, |
| 218 | + &temp_dir_path, |
| 219 | + |path, _| path.contains("foo/bar/baz/package.json"), |
| 220 | + ) |
| 221 | + .await; |
| 222 | + assert!(result.is_ok(), "Should run command successfully, but got error: {:?}", result); |
| 223 | + let cmd_result = result.unwrap(); |
| 224 | + assert!(cmd_result.status.success()); |
| 225 | + // Verify that only paths containing "foo" are included |
| 226 | + for (path, _) in &cmd_result.path_accesses { |
| 227 | + assert!( |
| 228 | + path.contains("foo/bar/baz/package.json"), |
| 229 | + "Path {} should contain 'foo/bar/baz/package.json'", |
| 230 | + path |
| 231 | + ); |
| 232 | + } |
| 233 | + println!("cmd_result: {:?}", cmd_result); |
| 234 | + assert_eq!(cmd_result.path_accesses.len(), 1); |
| 235 | + } |
| 236 | + |
| 237 | + #[tokio::test] |
| 238 | + async fn test_run_command_with_fspy_and_not_find_binary_path() { |
| 239 | + let temp_dir = create_temp_dir(); |
| 240 | + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); |
| 241 | + let envs = HashMap::from([( |
| 242 | + "PATH".to_string(), |
| 243 | + std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(), |
| 244 | + )]); |
| 245 | + let result = run_command_with_fspy( |
| 246 | + "npm-not-exists", |
| 247 | + &["--version"], |
| 248 | + &envs, |
| 249 | + &temp_dir_path, |
| 250 | + |_, _| true, |
| 251 | + ) |
| 252 | + .await; |
| 253 | + assert!(result.is_err(), "Should not find binary path, but got: {:?}", result); |
| 254 | + assert!( |
| 255 | + result |
| 256 | + .err() |
| 257 | + .unwrap() |
| 258 | + .to_string() |
| 259 | + .contains("could not resolve the full path of program '\"npm-not-exists\"'") |
| 260 | + ); |
| 261 | + } |
| 262 | + } |
| 263 | +} |
0 commit comments