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}