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}