Skip to main content

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&ampersands");
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&ampersands"));
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}