Skip to main content

qubit_command/
command.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2026 Haixing Hu.
4 *
5 *    SPDX-License-Identifier: Apache-2.0
6 *
7 *    Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10use std::{
11    ffi::{
12        OsStr,
13        OsString,
14    },
15    fmt,
16    path::{
17        Path,
18        PathBuf,
19    },
20};
21
22use qubit_sanitize::{
23    ArgvSanitizer,
24    EnvSanitizer,
25    FieldSanitizer,
26    NameMatchMode,
27};
28
29use crate::command_env::env_key_eq;
30use crate::command_stdin::CommandStdin;
31
32const COMMAND_LOG_MATCH_MODE: NameMatchMode = NameMatchMode::ExactOrSuffix;
33const SHELL_COMMAND_REPLACEMENT: &str = "<shell command>";
34
35/// Structured description of an external command to run.
36///
37/// `Command` stores a program and argument vector instead of parsing a
38/// shell-like command line. This avoids quoting ambiguity and accidental shell
39/// injection. Use [`Self::shell`] only when shell parsing, redirection,
40/// expansion, or pipes are intentionally required.
41///
42#[derive(Clone, PartialEq, Eq)]
43pub struct Command {
44    /// Program executable name or path.
45    program: OsString,
46    /// Positional arguments passed to the program.
47    args: Vec<OsString>,
48    /// Working directory override for this command.
49    working_directory: Option<PathBuf>,
50    /// Whether the command should clear inherited environment variables.
51    clear_environment: bool,
52    /// Environment variables added or overridden for this command.
53    envs: Vec<(OsString, OsString)>,
54    /// Environment variables removed for this command.
55    removed_envs: Vec<OsString>,
56    /// Standard input configuration for this command.
57    stdin: CommandStdin,
58}
59
60impl fmt::Debug for Command {
61    /// Formats this command without exposing sensitive log values.
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        let field_sanitizer = FieldSanitizer::default();
64        formatter
65            .debug_struct("Command")
66            .field("argv", &self.sanitized_argv(&field_sanitizer))
67            .field("working_directory", &self.working_directory)
68            .field("clear_environment", &self.clear_environment)
69            .field("env", &self.sanitized_environment_assignments(&field_sanitizer))
70            .field("unset", &self.removed_environment_names())
71            .field("stdin", &StdinDisplay(&self.stdin))
72            .finish()
73    }
74}
75
76impl Command {
77    /// Creates a command from a program name or path.
78    ///
79    /// # Parameters
80    ///
81    /// * `program` - Executable name or path to run.
82    ///
83    /// # Returns
84    ///
85    /// A command with no arguments or per-command overrides.
86    #[inline]
87    pub fn new(program: &str) -> Self {
88        Self::new_os(program)
89    }
90
91    /// Creates a command from a program name or path that may not be UTF-8.
92    ///
93    /// # Parameters
94    ///
95    /// * `program` - Executable name or path to run.
96    ///
97    /// # Returns
98    ///
99    /// A command with no arguments or per-command overrides.
100    #[inline]
101    pub fn new_os<S>(program: S) -> Self
102    where
103        S: AsRef<OsStr>,
104    {
105        Self {
106            program: program.as_ref().to_owned(),
107            args: Vec::new(),
108            working_directory: None,
109            clear_environment: false,
110            envs: Vec::new(),
111            removed_envs: Vec::new(),
112            stdin: CommandStdin::Null,
113        }
114    }
115
116    /// Creates a command executed through the platform shell.
117    ///
118    /// On Unix-like platforms this creates `sh -c <command_line>`. On Windows
119    /// this creates `cmd /C <command_line>`. Prefer [`Self::new`] with explicit
120    /// arguments when shell parsing is not required.
121    ///
122    /// # Parameters
123    ///
124    /// * `command_line` - Shell command line to execute.
125    ///
126    /// # Returns
127    ///
128    /// A command that invokes the platform shell.
129    #[cfg(not(windows))]
130    #[inline]
131    pub fn shell(command_line: &str) -> Self {
132        Self::new("sh").arg("-c").arg(command_line)
133    }
134
135    /// Creates a command executed through the platform shell.
136    ///
137    /// On Windows this creates `cmd /C <command_line>`. Prefer [`Self::new`]
138    /// with explicit arguments when shell parsing is not required.
139    ///
140    /// # Parameters
141    ///
142    /// * `command_line` - Shell command line to execute.
143    ///
144    /// # Returns
145    ///
146    /// A command that invokes the platform shell.
147    #[cfg(windows)]
148    #[inline]
149    pub fn shell(command_line: &str) -> Self {
150        Self::new("cmd").arg("/C").arg(command_line)
151    }
152
153    /// Adds one positional argument.
154    ///
155    /// # Parameters
156    ///
157    /// * `arg` - Argument to append.
158    ///
159    /// # Returns
160    ///
161    /// The updated command.
162    #[inline]
163    pub fn arg(mut self, arg: &str) -> Self {
164        self.args.push(OsString::from(arg));
165        self
166    }
167
168    /// Adds one positional argument that may not be UTF-8.
169    ///
170    /// # Parameters
171    ///
172    /// * `arg` - Argument to append.
173    ///
174    /// # Returns
175    ///
176    /// The updated command.
177    #[inline]
178    pub fn arg_os<S>(mut self, arg: S) -> Self
179    where
180        S: AsRef<OsStr>,
181    {
182        self.args.push(arg.as_ref().to_owned());
183        self
184    }
185
186    /// Adds multiple positional arguments.
187    ///
188    /// # Parameters
189    ///
190    /// * `args` - Arguments to append in order.
191    ///
192    /// # Returns
193    ///
194    /// The updated command.
195    #[inline]
196    pub fn args(mut self, args: &[&str]) -> Self {
197        self.args.extend(args.iter().map(OsString::from));
198        self
199    }
200
201    /// Adds multiple positional arguments that may not be UTF-8.
202    ///
203    /// # Parameters
204    ///
205    /// * `args` - Arguments to append in order.
206    ///
207    /// # Returns
208    ///
209    /// The updated command.
210    pub fn args_os<I, S>(mut self, args: I) -> Self
211    where
212        I: IntoIterator<Item = S>,
213        S: AsRef<OsStr>,
214    {
215        self.args.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
216        self
217    }
218
219    /// Sets a per-command working directory.
220    ///
221    /// # Parameters
222    ///
223    /// * `working_directory` - Directory used as the child process working
224    ///   directory.
225    ///
226    /// # Returns
227    ///
228    /// The updated command.
229    #[inline]
230    pub fn working_directory<P>(mut self, working_directory: P) -> Self
231    where
232        P: Into<PathBuf>,
233    {
234        self.working_directory = Some(working_directory.into());
235        self
236    }
237
238    /// Adds or overrides an environment variable for this command.
239    ///
240    /// # Parameters
241    ///
242    /// * `key` - Environment variable name.
243    /// * `value` - Environment variable value.
244    ///
245    /// # Returns
246    ///
247    /// The updated command.
248    #[inline]
249    pub fn env(mut self, key: &str, value: &str) -> Self {
250        self = self.env_os(key, value);
251        self
252    }
253
254    /// Adds or overrides an environment variable that may not be UTF-8.
255    ///
256    /// # Parameters
257    ///
258    /// * `key` - Environment variable name.
259    /// * `value` - Environment variable value.
260    ///
261    /// # Returns
262    ///
263    /// The updated command.
264    pub fn env_os<K, V>(mut self, key: K, value: V) -> Self
265    where
266        K: AsRef<OsStr>,
267        V: AsRef<OsStr>,
268    {
269        let key = key.as_ref().to_owned();
270        let value = value.as_ref().to_owned();
271        self.removed_envs.retain(|removed| !env_key_eq(removed, &key));
272        self.envs.retain(|(existing_key, _)| !env_key_eq(existing_key, &key));
273        self.envs.push((key, value));
274        self
275    }
276
277    /// Removes an inherited or previously configured environment variable.
278    ///
279    /// # Parameters
280    ///
281    /// * `key` - Environment variable name to remove.
282    ///
283    /// # Returns
284    ///
285    /// The updated command.
286    #[inline]
287    pub fn env_remove(mut self, key: &str) -> Self {
288        self = self.env_remove_os(key);
289        self
290    }
291
292    /// Removes an environment variable whose name may not be UTF-8.
293    ///
294    /// # Parameters
295    ///
296    /// * `key` - Environment variable name to remove.
297    ///
298    /// # Returns
299    ///
300    /// The updated command.
301    pub fn env_remove_os<S>(mut self, key: S) -> Self
302    where
303        S: AsRef<OsStr>,
304    {
305        let key = key.as_ref().to_owned();
306        self.envs.retain(|(existing_key, _)| !env_key_eq(existing_key, &key));
307        self.removed_envs.retain(|removed| !env_key_eq(removed, &key));
308        self.removed_envs.push(key);
309        self
310    }
311
312    /// Clears all inherited environment variables for this command.
313    ///
314    /// Environment variables added after this call are still passed to the child
315    /// process.
316    ///
317    /// # Returns
318    ///
319    /// The updated command.
320    pub fn env_clear(mut self) -> Self {
321        self.clear_environment = true;
322        self.envs.clear();
323        self.removed_envs.clear();
324        self
325    }
326
327    /// Connects the command stdin to null input.
328    ///
329    /// # Returns
330    ///
331    /// The updated command.
332    pub fn stdin_null(mut self) -> Self {
333        self.stdin = CommandStdin::Null;
334        self
335    }
336
337    /// Inherits stdin from the parent process.
338    ///
339    /// # Returns
340    ///
341    /// The updated command.
342    pub fn stdin_inherit(mut self) -> Self {
343        self.stdin = CommandStdin::Inherit;
344        self
345    }
346
347    /// Writes bytes to the child process stdin.
348    ///
349    /// The runner writes the bytes on a helper thread after spawning the child
350    /// process, then closes stdin so the child can observe EOF.
351    ///
352    /// # Parameters
353    ///
354    /// * `bytes` - Bytes to send to stdin.
355    ///
356    /// # Returns
357    ///
358    /// The updated command.
359    pub fn stdin_bytes<B>(mut self, bytes: B) -> Self
360    where
361        B: Into<Vec<u8>>,
362    {
363        self.stdin = CommandStdin::Bytes(bytes.into());
364        self
365    }
366
367    /// Reads child process stdin from a file.
368    ///
369    /// # Parameters
370    ///
371    /// * `path` - File path to open and connect to stdin.
372    ///
373    /// # Returns
374    ///
375    /// The updated command.
376    pub fn stdin_file<P>(mut self, path: P) -> Self
377    where
378        P: Into<PathBuf>,
379    {
380        self.stdin = CommandStdin::File(path.into());
381        self
382    }
383
384    /// Returns the executable name or path.
385    ///
386    /// # Returns
387    ///
388    /// Program executable name or path as an [`OsStr`].
389    #[inline]
390    pub fn program(&self) -> &OsStr {
391        &self.program
392    }
393
394    /// Returns the configured argument list.
395    ///
396    /// # Returns
397    ///
398    /// Borrowed argument list in submission order.
399    #[inline]
400    pub fn arguments(&self) -> &[OsString] {
401        &self.args
402    }
403
404    /// Returns the per-command working directory override.
405    ///
406    /// # Returns
407    ///
408    /// `Some(path)` when the command has a working directory override, or
409    /// `None` when the runner default should be used.
410    #[inline]
411    pub fn working_directory_override(&self) -> Option<&Path> {
412        self.working_directory.as_deref()
413    }
414
415    /// Returns environment variable overrides.
416    ///
417    /// # Returns
418    ///
419    /// Borrowed environment variable entries in insertion order.
420    #[inline]
421    pub fn environment(&self) -> &[(OsString, OsString)] {
422        &self.envs
423    }
424
425    /// Returns environment variable removals.
426    ///
427    /// # Returns
428    ///
429    /// Borrowed environment variable names removed before spawning the command.
430    #[inline]
431    pub fn removed_environment(&self) -> &[OsString] {
432        &self.removed_envs
433    }
434
435    /// Returns whether the inherited environment is cleared.
436    ///
437    /// # Returns
438    ///
439    /// `true` when the command should start from an empty environment.
440    #[inline]
441    pub const fn clears_environment(&self) -> bool {
442        self.clear_environment
443    }
444
445    /// Consumes the command and returns the configured stdin behavior.
446    ///
447    /// # Returns
448    ///
449    /// Owned stdin configuration used by the runner.
450    #[inline]
451    pub(crate) fn into_stdin_configuration(self) -> CommandStdin {
452        self.stdin
453    }
454
455    /// Formats this command for diagnostics.
456    ///
457    /// # Returns
458    ///
459    /// A sanitized command string suitable for logs and errors.
460    pub(crate) fn display_command(&self, field_sanitizer: &FieldSanitizer) -> String {
461        let argv = self.sanitized_argv(field_sanitizer);
462        if self.envs.is_empty() && self.removed_envs.is_empty() {
463            return format!("{argv:?}");
464        }
465
466        let env = self.sanitized_environment_assignments(field_sanitizer);
467        let unset = self.removed_environment_names();
468        format!("Command {{ env: {env:?}, unset: {unset:?}, argv: {argv:?} }}")
469    }
470
471    /// Builds sanitized argv tokens for diagnostics.
472    ///
473    /// # Returns
474    ///
475    /// Sanitized argv tokens with secret-looking values masked.
476    fn sanitized_argv(&self, field_sanitizer: &FieldSanitizer) -> Vec<String> {
477        ArgvSanitizer::new(field_sanitizer.clone()).sanitize_argv(self.argv_for_display(), COMMAND_LOG_MATCH_MODE)
478    }
479
480    /// Builds argv tokens with opaque shell payloads hidden.
481    ///
482    /// # Returns
483    ///
484    /// Owned argv tokens suitable for structured sanitization.
485    fn argv_for_display(&self) -> Vec<OsString> {
486        let shell_payload_index = self.shell_payload_arg_index();
487        let mut argv = Vec::with_capacity(self.args.len() + 1);
488        argv.push(self.program.clone());
489        for (index, arg) in self.args.iter().enumerate() {
490            if Some(index) == shell_payload_index {
491                argv.push(OsString::from(SHELL_COMMAND_REPLACEMENT));
492            } else {
493                argv.push(arg.clone());
494            }
495        }
496        argv
497    }
498
499    /// Locates the shell script argument generated by [`Self::shell`].
500    ///
501    /// # Returns
502    ///
503    /// `Some(index)` for the argument containing shell script text, or `None`
504    /// when this command is not a recognized shell invocation.
505    fn shell_payload_arg_index(&self) -> Option<usize> {
506        if self.args.len() < 2 {
507            return None;
508        }
509        let first_arg = self.args.first()?;
510        if self.program.as_os_str() == OsStr::new("sh") && first_arg == OsStr::new("-c") {
511            return Some(1);
512        }
513
514        let program = self.program.to_string_lossy();
515        let first_arg = first_arg.to_string_lossy();
516        if (program.eq_ignore_ascii_case("cmd") || program.eq_ignore_ascii_case("cmd.exe"))
517            && first_arg.eq_ignore_ascii_case("/C")
518        {
519            return Some(1);
520        }
521        None
522    }
523
524    /// Builds sanitized environment assignments for diagnostics.
525    ///
526    /// # Returns
527    ///
528    /// Sanitized `KEY=value` entries for explicit environment overrides.
529    fn sanitized_environment_assignments(&self, field_sanitizer: &FieldSanitizer) -> Vec<String> {
530        let sanitizer = EnvSanitizer::new(field_sanitizer.clone());
531        self.envs
532            .iter()
533            .map(|(key, value)| {
534                let (key, value) = sanitizer.sanitize_os_pair(key, value, COMMAND_LOG_MATCH_MODE);
535                format!("{key}={value}")
536            })
537            .collect()
538    }
539
540    /// Builds display names for removed environment variables.
541    ///
542    /// # Returns
543    ///
544    /// Environment variable names rendered lossily for diagnostics.
545    fn removed_environment_names(&self) -> Vec<String> {
546        self.removed_envs
547            .iter()
548            .map(|key| key.to_string_lossy().into_owned())
549            .collect()
550    }
551}
552
553/// Sanitized diagnostic wrapper for command stdin configuration.
554struct StdinDisplay<'a>(&'a CommandStdin);
555
556impl fmt::Debug for StdinDisplay<'_> {
557    /// Formats stdin configuration without exposing inline bytes.
558    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
559        match self.0 {
560            CommandStdin::Null => formatter.write_str("Null"),
561            CommandStdin::Inherit => formatter.write_str("Inherit"),
562            CommandStdin::Bytes(bytes) => write!(formatter, "Bytes({} bytes)", bytes.len()),
563            CommandStdin::File(path) => formatter.debug_tuple("File").field(path).finish(),
564        }
565    }
566}