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