Skip to content

Commit ceedc0a

Browse files
committed
feat: run command with fspy
1 parent 8931b8b commit ceedc0a

4 files changed

Lines changed: 302 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 14 additions & 0 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
@@ -41,6 +41,7 @@ criterion = { version = "0.7", features = ["html_reports"] }
4141
crossterm = { version = "0.29.0", features = ["event-stream"] }
4242
directories = "6.0.0"
4343
flate2 = "1.0.35"
44+
fspy = { git = "https://github.com/voidzero-dev/vite-task", rev = "d66556ab090fb5c5d85ecc7798a0fe9b6f1f26da" }
4445
futures-util = "0.3.31"
4546
hex = "0.4.3"
4647
httpmock = "0.7"

crates/vite_command/Cargo.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[package]
2+
name = "vite_command"
3+
version = "0.0.0"
4+
authors.workspace = true
5+
edition.workspace = true
6+
license.workspace = true
7+
rust-version.workspace = true
8+
9+
[dependencies]
10+
fspy = { workspace = true }
11+
tokio = { workspace = true }
12+
tracing = { workspace = true }
13+
vite_error = { workspace = true }
14+
vite_path = { workspace = true }
15+
which = { workspace = true, features = ["tracing"] }
16+
17+
[target.'cfg(not(target_os = "windows"))'.dependencies]
18+
nix = { workspace = true }
19+
20+
[dev-dependencies]
21+
tempfile = { workspace = true }
22+
23+
[lints]
24+
workspace = true

crates/vite_command/src/lib.rs

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
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

Comments
 (0)