xchecker_runner/command_spec.rs
1use std::collections::HashMap;
2use std::ffi::OsString;
3use std::path::PathBuf;
4use std::process::Command;
5use tokio::process::Command as TokioCommand;
6
7// ============================================================================
8// CommandSpec - Secure Process Execution Specification
9// ============================================================================
10
11/// Specification for a command to execute.
12///
13/// All process execution goes through this type to ensure argv-style invocation.
14/// This prevents shell injection attacks by ensuring arguments are passed as
15/// discrete elements rather than shell strings.
16///
17/// # Security
18///
19/// `CommandSpec` enforces that:
20/// - Arguments are `Vec<OsString>`, NOT shell strings
21/// - No shell string evaluation (`sh -c`, `cmd /C`) is used
22/// - Arguments cross trust boundaries as discrete elements
23///
24/// # Example
25///
26/// ```rust
27/// use xchecker_utils::runner::CommandSpec;
28/// use std::ffi::OsString;
29///
30/// let cmd = CommandSpec::new("claude")
31/// .arg("--print")
32/// .arg("--output-format")
33/// .arg("json")
34/// .cwd("/path/to/workspace");
35///
36/// assert_eq!(cmd.program, OsString::from("claude"));
37/// assert_eq!(cmd.args.len(), 3);
38/// ```
39#[derive(Debug, Clone)]
40pub struct CommandSpec {
41 /// The program to execute
42 pub program: OsString,
43 /// Arguments as discrete elements (NOT shell strings)
44 pub args: Vec<OsString>,
45 /// Optional working directory
46 pub cwd: Option<PathBuf>,
47 /// Optional environment overrides
48 pub env: Option<HashMap<OsString, OsString>>,
49}
50
51impl CommandSpec {
52 /// Create a new `CommandSpec` with the given program.
53 ///
54 /// # Arguments
55 ///
56 /// * `program` - The program to execute. Can be any type that converts to `OsString`.
57 ///
58 /// # Example
59 ///
60 /// ```rust
61 /// use xchecker_utils::runner::CommandSpec;
62 ///
63 /// let cmd = CommandSpec::new("claude");
64 /// ```
65 #[must_use]
66 pub fn new(program: impl Into<OsString>) -> Self {
67 Self {
68 program: program.into(),
69 args: Vec::new(),
70 cwd: None,
71 env: None,
72 }
73 }
74
75 /// Add a single argument to the command.
76 ///
77 /// Arguments are stored as discrete `OsString` elements, ensuring no shell
78 /// interpretation occurs.
79 ///
80 /// # Arguments
81 ///
82 /// * `arg` - The argument to add. Can be any type that converts to `OsString`.
83 ///
84 /// # Example
85 ///
86 /// ```rust
87 /// use xchecker_utils::runner::CommandSpec;
88 ///
89 /// let cmd = CommandSpec::new("claude")
90 /// .arg("--print")
91 /// .arg("--verbose");
92 /// ```
93 #[must_use]
94 pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
95 self.args.push(arg.into());
96 self
97 }
98
99 /// Add multiple arguments to the command.
100 ///
101 /// Arguments are stored as discrete `OsString` elements, ensuring no shell
102 /// interpretation occurs.
103 ///
104 /// # Arguments
105 ///
106 /// * `args` - An iterator of arguments to add.
107 ///
108 /// # Example
109 ///
110 /// ```rust
111 /// use xchecker_utils::runner::CommandSpec;
112 ///
113 /// let cmd = CommandSpec::new("claude")
114 /// .args(["--print", "--output-format", "json"]);
115 /// ```
116 #[must_use]
117 pub fn args<I, S>(mut self, args: I) -> Self
118 where
119 I: IntoIterator<Item = S>,
120 S: Into<OsString>,
121 {
122 self.args.extend(args.into_iter().map(Into::into));
123 self
124 }
125
126 /// Set the working directory for the command.
127 ///
128 /// # Arguments
129 ///
130 /// * `cwd` - The working directory path.
131 ///
132 /// # Example
133 ///
134 /// ```rust
135 /// use xchecker_utils::runner::CommandSpec;
136 ///
137 /// let cmd = CommandSpec::new("claude")
138 /// .cwd("/path/to/workspace");
139 /// ```
140 #[must_use]
141 pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
142 self.cwd = Some(cwd.into());
143 self
144 }
145
146 /// Set an environment variable for the command.
147 ///
148 /// # Arguments
149 ///
150 /// * `key` - The environment variable name.
151 /// * `value` - The environment variable value.
152 ///
153 /// # Example
154 ///
155 /// ```rust
156 /// use xchecker_utils::runner::CommandSpec;
157 ///
158 /// let cmd = CommandSpec::new("claude")
159 /// .env("CLAUDE_API_KEY", "sk-...")
160 /// .env("DEBUG", "1");
161 /// ```
162 #[must_use]
163 pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
164 self.env
165 .get_or_insert_with(HashMap::new)
166 .insert(key.into(), value.into());
167 self
168 }
169
170 /// Set multiple environment variables for the command.
171 ///
172 /// # Arguments
173 ///
174 /// * `envs` - An iterator of (key, value) pairs.
175 ///
176 /// # Example
177 ///
178 /// ```rust
179 /// use xchecker_utils::runner::CommandSpec;
180 ///
181 /// let cmd = CommandSpec::new("claude")
182 /// .envs([("DEBUG", "1"), ("VERBOSE", "true")]);
183 /// ```
184 #[must_use]
185 pub fn envs<I, K, V>(mut self, envs: I) -> Self
186 where
187 I: IntoIterator<Item = (K, V)>,
188 K: Into<OsString>,
189 V: Into<OsString>,
190 {
191 let env_map = self.env.get_or_insert_with(HashMap::new);
192 for (key, value) in envs {
193 env_map.insert(key.into(), value.into());
194 }
195 self
196 }
197
198 /// Convert this `CommandSpec` into a `std::process::Command`.
199 ///
200 /// This is the primary way to execute a `CommandSpec`. The resulting `Command`
201 /// uses argv-style argument passing, ensuring no shell injection is possible.
202 ///
203 /// # Example
204 ///
205 /// ```rust,no_run
206 /// use xchecker_utils::runner::CommandSpec;
207 ///
208 /// let cmd = CommandSpec::new("echo")
209 /// .arg("hello")
210 /// .arg("world");
211 ///
212 /// let output = cmd.to_command().output().expect("failed to execute");
213 /// ```
214 #[must_use]
215 pub fn to_command(&self) -> Command {
216 let mut cmd = Command::new(&self.program);
217 cmd.args(&self.args);
218
219 if let Some(ref cwd) = self.cwd {
220 cmd.current_dir(cwd);
221 }
222
223 if let Some(ref env) = self.env {
224 for (key, value) in env {
225 cmd.env(key, value);
226 }
227 }
228
229 cmd
230 }
231
232 /// Convert this `CommandSpec` into a `tokio::process::Command`.
233 ///
234 /// This is used for async execution with timeout support.
235 ///
236 /// # Example
237 ///
238 /// ```rust,no_run
239 /// use xchecker_utils::runner::CommandSpec;
240 ///
241 /// # async fn example() {
242 /// let cmd = CommandSpec::new("echo")
243 /// .arg("hello");
244 ///
245 /// let output = cmd.to_tokio_command().output().await.expect("failed to execute");
246 /// # }
247 /// ```
248 #[must_use]
249 pub fn to_tokio_command(&self) -> TokioCommand {
250 let mut cmd = TokioCommand::new(&self.program);
251 cmd.args(&self.args);
252
253 if let Some(ref cwd) = self.cwd {
254 cmd.current_dir(cwd);
255 }
256
257 if let Some(ref env) = self.env {
258 for (key, value) in env {
259 cmd.env(key, value);
260 }
261 }
262
263 cmd
264 }
265}
266
267impl Default for CommandSpec {
268 fn default() -> Self {
269 Self {
270 program: OsString::new(),
271 args: Vec::new(),
272 cwd: None,
273 env: None,
274 }
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use std::path::PathBuf;
282
283 #[test]
284 fn test_command_spec_new() {
285 let cmd = CommandSpec::new("claude");
286 assert_eq!(cmd.program, OsString::from("claude"));
287 assert!(cmd.args.is_empty());
288 assert!(cmd.cwd.is_none());
289 assert!(cmd.env.is_none());
290 }
291
292 #[test]
293 fn test_command_spec_arg() {
294 let cmd = CommandSpec::new("claude").arg("--print").arg("--verbose");
295 assert_eq!(cmd.args.len(), 2);
296 assert_eq!(cmd.args[0], OsString::from("--print"));
297 assert_eq!(cmd.args[1], OsString::from("--verbose"));
298 }
299
300 #[test]
301 fn test_command_spec_args() {
302 let cmd = CommandSpec::new("claude").args(["--print", "--output-format", "json"]);
303 assert_eq!(cmd.args.len(), 3);
304 assert_eq!(cmd.args[0], OsString::from("--print"));
305 assert_eq!(cmd.args[1], OsString::from("--output-format"));
306 assert_eq!(cmd.args[2], OsString::from("json"));
307 }
308
309 #[test]
310 fn test_command_spec_cwd() {
311 let cmd = CommandSpec::new("claude").cwd("/path/to/workspace");
312 assert_eq!(cmd.cwd, Some(PathBuf::from("/path/to/workspace")));
313 }
314
315 #[test]
316 fn test_command_spec_env() {
317 let cmd = CommandSpec::new("claude")
318 .env("DEBUG", "1")
319 .env("VERBOSE", "true");
320 let env = cmd.env.as_ref().unwrap();
321 assert_eq!(env.len(), 2);
322 assert_eq!(
323 env.get(&OsString::from("DEBUG")),
324 Some(&OsString::from("1"))
325 );
326 assert_eq!(
327 env.get(&OsString::from("VERBOSE")),
328 Some(&OsString::from("true"))
329 );
330 }
331
332 #[test]
333 fn test_command_spec_envs() {
334 let cmd = CommandSpec::new("claude").envs([("DEBUG", "1"), ("VERBOSE", "true")]);
335 let env = cmd.env.as_ref().unwrap();
336 assert_eq!(env.len(), 2);
337 assert_eq!(
338 env.get(&OsString::from("DEBUG")),
339 Some(&OsString::from("1"))
340 );
341 assert_eq!(
342 env.get(&OsString::from("VERBOSE")),
343 Some(&OsString::from("true"))
344 );
345 }
346
347 #[test]
348 fn test_command_spec_builder_chain() {
349 let cmd = CommandSpec::new("claude")
350 .arg("--print")
351 .args(["--output-format", "json"])
352 .cwd("/workspace")
353 .env("DEBUG", "1")
354 .envs([("VERBOSE", "true")]);
355
356 assert_eq!(cmd.program, OsString::from("claude"));
357 assert_eq!(cmd.args.len(), 3);
358 assert_eq!(cmd.cwd, Some(PathBuf::from("/workspace")));
359 let env = cmd.env.as_ref().unwrap();
360 assert_eq!(env.len(), 2);
361 }
362
363 #[test]
364 fn test_command_spec_default() {
365 let cmd = CommandSpec::default();
366 assert_eq!(cmd.program, OsString::new());
367 assert!(cmd.args.is_empty());
368 assert!(cmd.cwd.is_none());
369 assert!(cmd.env.is_none());
370 }
371
372 #[test]
373 fn test_command_spec_clone() {
374 let cmd = CommandSpec::new("claude")
375 .arg("--print")
376 .cwd("/workspace")
377 .env("DEBUG", "1");
378 let cloned = cmd.clone();
379
380 assert_eq!(cloned.program, cmd.program);
381 assert_eq!(cloned.args, cmd.args);
382 assert_eq!(cloned.cwd, cmd.cwd);
383 assert_eq!(cloned.env, cmd.env);
384 }
385
386 #[test]
387 fn test_command_spec_to_command() {
388 let cmd = CommandSpec::new("echo").arg("hello").arg("world");
389
390 // Verify we can create a std::process::Command
391 let std_cmd = cmd.to_command();
392 // We can't easily inspect the Command, but we can verify it doesn't panic
393 assert!(std::mem::size_of_val(&std_cmd) > 0);
394 }
395
396 #[test]
397 fn test_command_spec_to_tokio_command() {
398 let cmd = CommandSpec::new("echo").arg("hello");
399
400 // Verify we can create a tokio::process::Command
401 let tokio_cmd = cmd.to_tokio_command();
402 // We can't easily inspect the Command, but we can verify it doesn't panic
403 assert!(std::mem::size_of_val(&tokio_cmd) > 0);
404 }
405
406 #[test]
407 fn test_command_spec_osstring_args() {
408 // Test that we can use OsString directly
409 let cmd = CommandSpec::new(OsString::from("claude")).arg(OsString::from("--print"));
410 assert_eq!(cmd.program, OsString::from("claude"));
411 assert_eq!(cmd.args[0], OsString::from("--print"));
412 }
413
414 #[test]
415 fn test_command_spec_args_are_vec_osstring() {
416 // Verify args are stored as Vec<OsString>, not shell strings
417 let cmd = CommandSpec::new("claude")
418 .arg("arg with spaces")
419 .arg("arg;with;semicolons")
420 .arg("arg|with|pipes")
421 .arg("arg&with&ersands");
422
423 // Each argument should be stored as a discrete OsString element
424 assert_eq!(cmd.args.len(), 4);
425 assert_eq!(cmd.args[0], OsString::from("arg with spaces"));
426 assert_eq!(cmd.args[1], OsString::from("arg;with;semicolons"));
427 assert_eq!(cmd.args[2], OsString::from("arg|with|pipes"));
428 assert_eq!(cmd.args[3], OsString::from("arg&with&ersands"));
429 }
430
431 #[test]
432 fn test_command_spec_shell_metacharacters_preserved() {
433 // Verify that shell metacharacters are preserved as-is (not interpreted)
434 // This is critical for security - we don't want shell injection
435 let cmd = CommandSpec::new("echo")
436 .arg("$(whoami)")
437 .arg("`id`")
438 .arg("${HOME}")
439 .arg("$PATH");
440
441 // These should be stored literally, not expanded
442 assert_eq!(cmd.args[0], OsString::from("$(whoami)"));
443 assert_eq!(cmd.args[1], OsString::from("`id`"));
444 assert_eq!(cmd.args[2], OsString::from("${HOME}"));
445 assert_eq!(cmd.args[3], OsString::from("$PATH"));
446 }
447}