Skip to main content

rskit_process/
command.rs

1//! Process specification, I/O modes, and execution policy.
2
3use std::collections::HashMap;
4use std::ffi::OsString;
5use std::path::PathBuf;
6use std::time::Duration;
7
8use crate::runner::OutputObserver;
9use rskit_util::SecretKeyMatcher;
10
11/// Default maximum retained bytes for each captured output stream.
12pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
13
14/// Command environment policy.
15#[derive(Debug, Clone, Copy, Eq, PartialEq)]
16#[non_exhaustive]
17pub enum EnvPolicy {
18    /// Inherit parent environment variables, then apply explicit overrides.
19    Inherit,
20    /// Start from an empty environment, then apply explicit variables.
21    Empty,
22}
23
24/// What to execute as a subprocess.
25#[derive(Debug, Clone, Eq, PartialEq)]
26pub struct ProcessSpec {
27    /// Program name or path to execute.
28    pub program: PathBuf,
29    /// Command-line arguments.
30    pub args: Vec<OsString>,
31    /// Working directory for the process.
32    pub dir: Option<PathBuf>,
33    /// Environment variables to set.
34    pub env: HashMap<String, String>,
35    /// Environment inheritance policy.
36    pub env_policy: EnvPolicy,
37}
38
39impl ProcessSpec {
40    /// Create a new process spec with just a program name.
41    #[must_use]
42    pub fn new<P: Into<PathBuf>>(program: P) -> Self {
43        Self {
44            program: program.into(),
45            args: Vec::new(),
46            dir: None,
47            env: HashMap::new(),
48            env_policy: EnvPolicy::Inherit,
49        }
50    }
51
52    /// Add a command-line argument.
53    #[must_use]
54    pub fn arg<S: Into<OsString>>(mut self, arg: S) -> Self {
55        self.args.push(arg.into());
56        self
57    }
58
59    /// Add multiple command-line arguments.
60    #[must_use]
61    pub fn args<I>(mut self, args: I) -> Self
62    where
63        I: IntoIterator,
64        I::Item: Into<OsString>,
65    {
66        self.args.extend(args.into_iter().map(Into::into));
67        self
68    }
69
70    /// Set the working directory.
71    #[must_use]
72    pub fn dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
73        self.dir = Some(dir.into());
74        self
75    }
76
77    /// Set an environment variable.
78    #[must_use]
79    pub fn env<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
80        self.env.insert(key.into(), value.into());
81        self
82    }
83
84    /// Set multiple environment variables.
85    #[must_use]
86    pub fn envs<K: Into<String>, V: Into<String>, I: IntoIterator<Item = (K, V)>>(
87        mut self,
88        vars: I,
89    ) -> Self {
90        for (k, v) in vars {
91            self.env.insert(k.into(), v.into());
92        }
93        self
94    }
95
96    /// Set the environment policy.
97    #[must_use]
98    pub fn env_policy(mut self, policy: EnvPolicy) -> Self {
99        self.env_policy = policy;
100        self
101    }
102
103    /// Start the process with an empty environment.
104    #[must_use]
105    pub fn empty_env(mut self) -> Self {
106        self.env_policy = EnvPolicy::Empty;
107        self
108    }
109}
110
111/// Create a subprocess specification.
112#[must_use]
113pub fn command<P: Into<PathBuf>>(program: P) -> ProcessSpec {
114    ProcessSpec::new(program)
115}
116
117/// Standard input policy.
118#[derive(Debug, Clone, Eq, PartialEq, Default)]
119#[non_exhaustive]
120pub enum InputPolicy {
121    /// Close stdin for the child process.
122    #[default]
123    Closed,
124    /// Pipe predefined bytes to stdin, then close it.
125    Bytes(Vec<u8>),
126    /// Inherit stdin from the parent process.
127    Inherit,
128}
129
130/// Output capture policy for pipe-backed modes.
131#[derive(Debug, Clone, Eq, PartialEq)]
132pub struct OutputPolicy {
133    /// Whether stdout should be retained in `ProcessResult`.
134    pub capture_stdout: bool,
135    /// Whether stderr should be retained in `ProcessResult`.
136    pub capture_stderr: bool,
137    /// Maximum retained bytes for each captured stream. `None` means unbounded.
138    pub max_output_bytes: Option<usize>,
139}
140
141impl Default for OutputPolicy {
142    fn default() -> Self {
143        Self::captured()
144    }
145}
146
147impl OutputPolicy {
148    /// Capture stdout and stderr with the default bounds.
149    #[must_use]
150    pub const fn captured() -> Self {
151        Self {
152            capture_stdout: true,
153            capture_stderr: true,
154            max_output_bytes: Some(DEFAULT_MAX_OUTPUT_BYTES),
155        }
156    }
157
158    /// Do not retain stdout or stderr.
159    #[must_use]
160    pub const fn observe_only() -> Self {
161        Self {
162            capture_stdout: false,
163            capture_stderr: false,
164            max_output_bytes: Some(DEFAULT_MAX_OUTPUT_BYTES),
165        }
166    }
167
168    /// Set the maximum retained bytes for each captured output stream.
169    #[must_use]
170    pub fn with_max_output_bytes(mut self, bytes: usize) -> Self {
171        self.max_output_bytes = Some(bytes);
172        self
173    }
174
175    /// Disable output capture bounds.
176    #[must_use]
177    pub fn with_unbounded_output(mut self) -> Self {
178        self.max_output_bytes = None;
179        self
180    }
181}
182
183/// Pipe-backed deterministic capture mode.
184#[derive(Debug, Clone, Eq, PartialEq, Default)]
185pub struct CapturedIo {
186    /// Standard input policy.
187    pub input: InputPolicy,
188    /// Output capture policy.
189    pub output: OutputPolicy,
190}
191
192impl CapturedIo {
193    /// Create capture mode with default closed stdin and bounded stdout/stderr capture.
194    #[must_use]
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// Set the stdin policy.
200    #[must_use]
201    pub fn with_input(mut self, input: InputPolicy) -> Self {
202        self.input = input;
203        self
204    }
205
206    /// Set the output policy.
207    #[must_use]
208    pub fn with_output(mut self, output: OutputPolicy) -> Self {
209        self.output = output;
210        self
211    }
212}
213
214/// Pipe-backed live observation mode with optional capture.
215#[derive(Debug, Clone, Default)]
216pub struct ObservedIo {
217    /// Standard input policy.
218    pub input: InputPolicy,
219    /// Output capture policy.
220    pub output: OutputPolicy,
221    /// Output callbacks.
222    pub observer: OutputObserver,
223}
224
225impl ObservedIo {
226    /// Create observed mode with the provided callbacks.
227    #[must_use]
228    pub fn new(observer: OutputObserver) -> Self {
229        Self {
230            observer,
231            ..Self::default()
232        }
233    }
234
235    /// Set the stdin policy.
236    #[must_use]
237    pub fn with_input(mut self, input: InputPolicy) -> Self {
238        self.input = input;
239        self
240    }
241
242    /// Set the output policy.
243    #[must_use]
244    pub fn with_output(mut self, output: OutputPolicy) -> Self {
245        self.output = output;
246        self
247    }
248}
249
250/// Inherited terminal stdio mode.
251#[derive(Debug, Clone, Eq, PartialEq)]
252pub struct InheritedIo {
253    /// Standard input policy. Defaults to inheriting parent stdin.
254    pub input: InputPolicy,
255}
256
257impl Default for InheritedIo {
258    fn default() -> Self {
259        Self {
260            input: InputPolicy::Inherit,
261        }
262    }
263}
264
265impl InheritedIo {
266    /// Create inherited stdio mode.
267    #[must_use]
268    pub fn new() -> Self {
269        Self::default()
270    }
271
272    /// Set the stdin policy while stdout/stderr remain inherited.
273    #[must_use]
274    pub fn with_input(mut self, input: InputPolicy) -> Self {
275        self.input = input;
276        self
277    }
278}
279
280/// Process I/O strategy.
281#[derive(Debug, Clone)]
282#[non_exhaustive]
283pub enum ProcessIo {
284    /// Capture stdout/stderr separately through pipes.
285    Captured(CapturedIo),
286    /// Observe stdout/stderr live through pipes with optional capture.
287    Observed(ObservedIo),
288    /// Inherit parent terminal stdio.
289    Inherited(InheritedIo),
290}
291
292impl Default for ProcessIo {
293    fn default() -> Self {
294        Self::Captured(CapturedIo::default())
295    }
296}
297
298impl ProcessIo {
299    /// Create captured I/O mode.
300    #[must_use]
301    pub fn captured(io: CapturedIo) -> Self {
302        Self::Captured(io)
303    }
304
305    /// Create observed I/O mode.
306    #[must_use]
307    pub fn observed(io: ObservedIo) -> Self {
308        Self::Observed(io)
309    }
310
311    /// Create inherited I/O mode.
312    #[must_use]
313    pub fn inherited(io: InheritedIo) -> Self {
314        Self::Inherited(io)
315    }
316}
317
318/// Redaction policy for command-line arguments emitted in process spawn logs.
319#[derive(Debug, Clone, Eq, PartialEq, Default)]
320pub struct ArgRedaction {
321    matcher: SecretKeyMatcher,
322}
323
324impl ArgRedaction {
325    /// Create a policy with the default secret-bearing argument names.
326    #[must_use]
327    pub fn new() -> Self {
328        Self::default()
329    }
330
331    /// Replace the default secret-bearing names with the provided names.
332    #[must_use]
333    pub fn from_names(names: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
334        Self {
335            matcher: SecretKeyMatcher::new(names),
336        }
337    }
338
339    /// Add a secret-bearing argument name.
340    #[must_use]
341    pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
342        self.matcher = self.matcher.with_name(name);
343        self
344    }
345
346    /// Add multiple secret-bearing argument names.
347    #[must_use]
348    pub fn with_names(mut self, names: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
349        self.matcher = self.matcher.with_names(names);
350        self
351    }
352
353    /// Return true when an argument name should have its value redacted.
354    #[must_use]
355    pub fn is_sensitive_arg_name(&self, name: &str) -> bool {
356        self.matcher.is_secret_key(name)
357    }
358}
359
360/// Signal and process-tree termination policy.
361#[derive(Debug, Clone, Copy, Eq, PartialEq)]
362#[non_exhaustive]
363pub struct SignalPolicy {
364    /// Grace period to wait after graceful termination before killing.
365    pub grace_period: Duration,
366    /// Create a new process group/session where supported.
367    pub create_process_group: bool,
368    /// Terminate the process group rather than only the immediate child where supported.
369    pub terminate_descendants: bool,
370}
371
372impl Default for SignalPolicy {
373    fn default() -> Self {
374        Self {
375            grace_period: Duration::from_secs(5),
376            create_process_group: true,
377            terminate_descendants: true,
378        }
379    }
380}
381
382impl SignalPolicy {
383    /// Set the graceful termination period before kill escalation.
384    #[must_use]
385    pub fn with_grace_period(mut self, grace_period: Duration) -> Self {
386        self.grace_period = grace_period;
387        self
388    }
389
390    /// Set whether processes are spawned into a new process group where supported.
391    #[must_use]
392    pub fn with_create_process_group(mut self, create_process_group: bool) -> Self {
393        self.create_process_group = create_process_group;
394        self
395    }
396
397    /// Set whether termination targets descendants through the process group.
398    #[must_use]
399    pub fn with_terminate_descendants(mut self, terminate_descendants: bool) -> Self {
400        self.terminate_descendants = terminate_descendants;
401        self
402    }
403}
404
405/// Configuration for subprocess execution behavior.
406#[derive(Debug, Clone)]
407#[non_exhaustive]
408pub struct ProcessConfig {
409    /// Overall timeout for the process. None means no timeout.
410    pub timeout: Option<Duration>,
411    /// Explicit I/O strategy.
412    pub io: ProcessIo,
413    /// Signal and process-tree termination policy.
414    pub signal: SignalPolicy,
415    /// Redaction policy for command-line arguments emitted in spawn logs.
416    pub arg_redaction: ArgRedaction,
417}
418
419impl Default for ProcessConfig {
420    fn default() -> Self {
421        Self {
422            timeout: Some(Duration::from_secs(30)),
423            io: ProcessIo::default(),
424            signal: SignalPolicy::default(),
425            arg_redaction: ArgRedaction::default(),
426        }
427    }
428}
429
430impl ProcessConfig {
431    /// Set the process timeout.
432    #[must_use]
433    pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
434        self.timeout = timeout;
435        self
436    }
437
438    /// Set the I/O strategy.
439    #[must_use]
440    pub fn with_io(mut self, io: ProcessIo) -> Self {
441        self.io = io;
442        self
443    }
444
445    /// Set the signal policy.
446    #[must_use]
447    pub fn with_signal_policy(mut self, signal: SignalPolicy) -> Self {
448        self.signal = signal;
449        self
450    }
451
452    /// Set the command-line argument redaction policy used for spawn logs.
453    #[must_use]
454    pub fn with_arg_redaction(mut self, arg_redaction: ArgRedaction) -> Self {
455        self.arg_redaction = arg_redaction;
456        self
457    }
458
459    /// Add a custom secret-bearing command-line argument name for spawn-log redaction.
460    #[must_use]
461    pub fn with_sensitive_arg_name(mut self, name: impl AsRef<str>) -> Self {
462        self.arg_redaction = self.arg_redaction.with_name(name);
463        self
464    }
465
466    /// Set the maximum retained bytes for captured or observed output.
467    #[must_use]
468    pub fn with_max_output_bytes(mut self, bytes: usize) -> Self {
469        match &mut self.io {
470            ProcessIo::Captured(io) => io.output.max_output_bytes = Some(bytes),
471            ProcessIo::Observed(io) => io.output.max_output_bytes = Some(bytes),
472            ProcessIo::Inherited(_) => {}
473        }
474        self
475    }
476
477    /// Disable output capture bounds for captured or observed output.
478    #[must_use]
479    pub fn with_unbounded_output(mut self) -> Self {
480        match &mut self.io {
481            ProcessIo::Captured(io) => io.output.max_output_bytes = None,
482            ProcessIo::Observed(io) => io.output.max_output_bytes = None,
483            ProcessIo::Inherited(_) => {}
484        }
485        self
486    }
487
488    /// Set stdin for the configured I/O mode.
489    #[must_use]
490    pub fn with_input(mut self, input: InputPolicy) -> Self {
491        match &mut self.io {
492            ProcessIo::Captured(io) => io.input = input,
493            ProcessIo::Observed(io) => io.input = input,
494            ProcessIo::Inherited(io) => io.input = input,
495        }
496        self
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn process_spec_builders_set_command_environment_and_directory() {
506        let spec = command("tool")
507            .arg("--one")
508            .args([OsString::from("two"), OsString::from("three")])
509            .dir("work")
510            .env("TOKEN", "secret")
511            .envs([("MODE", "test"), ("REGION", "local")])
512            .env_policy(EnvPolicy::Inherit)
513            .empty_env();
514
515        assert_eq!(spec.program, PathBuf::from("tool"));
516        assert_eq!(
517            spec.args,
518            [
519                OsString::from("--one"),
520                OsString::from("two"),
521                OsString::from("three")
522            ]
523        );
524        assert_eq!(spec.dir, Some(PathBuf::from("work")));
525        assert_eq!(spec.env.get("TOKEN").map(String::as_str), Some("secret"));
526        assert_eq!(spec.env.get("MODE").map(String::as_str), Some("test"));
527        assert_eq!(spec.env.get("REGION").map(String::as_str), Some("local"));
528        assert_eq!(spec.env_policy, EnvPolicy::Empty);
529    }
530
531    #[test]
532    fn io_policy_builders_update_nested_fields() {
533        let bounded = OutputPolicy::captured().with_max_output_bytes(128);
534        assert!(bounded.capture_stdout);
535        assert!(bounded.capture_stderr);
536        assert_eq!(bounded.max_output_bytes, Some(128));
537        assert_eq!(
538            OutputPolicy::observe_only()
539                .with_unbounded_output()
540                .max_output_bytes,
541            None
542        );
543
544        let captured = CapturedIo::new()
545            .with_input(InputPolicy::Bytes(b"stdin".to_vec()))
546            .with_output(OutputPolicy::observe_only());
547        assert_eq!(captured.input, InputPolicy::Bytes(b"stdin".to_vec()));
548        assert!(!captured.output.capture_stdout);
549
550        let observed = ObservedIo::new(OutputObserver::new())
551            .with_input(InputPolicy::Closed)
552            .with_output(OutputPolicy::captured().with_max_output_bytes(7));
553        assert_eq!(observed.input, InputPolicy::Closed);
554        assert_eq!(observed.output.max_output_bytes, Some(7));
555
556        let inherited = InheritedIo::new().with_input(InputPolicy::Closed);
557        assert_eq!(inherited.input, InputPolicy::Closed);
558        assert!(matches!(
559            ProcessIo::captured(captured),
560            ProcessIo::Captured(_)
561        ));
562        assert!(matches!(
563            ProcessIo::observed(observed),
564            ProcessIo::Observed(_)
565        ));
566        assert!(matches!(
567            ProcessIo::inherited(inherited),
568            ProcessIo::Inherited(_)
569        ));
570    }
571
572    #[test]
573    fn redaction_signal_and_config_builders_are_chainable() {
574        let redaction = ArgRedaction::new()
575            .with_name("token")
576            .with_names(["password", "client-secret"]);
577        assert!(redaction.is_sensitive_arg_name("--token"));
578        assert!(redaction.is_sensitive_arg_name("password"));
579        assert!(redaction.is_sensitive_arg_name("client-secret"));
580
581        let replacement = ArgRedaction::from_names(["api-key"]);
582        assert!(replacement.is_sensitive_arg_name("api-key"));
583        assert!(!replacement.is_sensitive_arg_name("token"));
584
585        let signal = SignalPolicy::default()
586            .with_grace_period(Duration::from_millis(25))
587            .with_create_process_group(false)
588            .with_terminate_descendants(false);
589        assert_eq!(signal.grace_period, Duration::from_millis(25));
590        assert!(!signal.create_process_group);
591        assert!(!signal.terminate_descendants);
592
593        let captured = ProcessConfig::default()
594            .with_timeout(None)
595            .with_arg_redaction(redaction)
596            .with_sensitive_arg_name("session")
597            .with_signal_policy(signal)
598            .with_max_output_bytes(3)
599            .with_unbounded_output()
600            .with_input(InputPolicy::Bytes(vec![1, 2, 3]));
601        assert_eq!(captured.timeout, None);
602        assert!(captured.arg_redaction.is_sensitive_arg_name("session"));
603        assert_eq!(captured.signal, signal);
604        match captured.io {
605            ProcessIo::Captured(io) => {
606                assert_eq!(io.input, InputPolicy::Bytes(vec![1, 2, 3]));
607                assert_eq!(io.output.max_output_bytes, None);
608            }
609            ProcessIo::Observed(_) | ProcessIo::Inherited(_) => {
610                panic!("default process config should use captured I/O")
611            }
612        }
613    }
614
615    #[test]
616    fn config_builders_update_observed_and_inherited_io_modes() {
617        let observed = ProcessConfig::default()
618            .with_io(ProcessIo::observed(ObservedIo::default()))
619            .with_max_output_bytes(11)
620            .with_unbounded_output()
621            .with_input(InputPolicy::Bytes(b"observed".to_vec()));
622        match observed.io {
623            ProcessIo::Observed(io) => {
624                assert_eq!(io.input, InputPolicy::Bytes(b"observed".to_vec()));
625                assert_eq!(io.output.max_output_bytes, None);
626            }
627            ProcessIo::Captured(_) | ProcessIo::Inherited(_) => {
628                panic!("expected observed I/O")
629            }
630        }
631
632        let inherited = ProcessConfig::default()
633            .with_io(ProcessIo::inherited(InheritedIo::default()))
634            .with_max_output_bytes(5)
635            .with_unbounded_output()
636            .with_input(InputPolicy::Closed);
637        match inherited.io {
638            ProcessIo::Inherited(io) => assert_eq!(io.input, InputPolicy::Closed),
639            ProcessIo::Captured(_) | ProcessIo::Observed(_) => {
640                panic!("expected inherited I/O")
641            }
642        }
643    }
644}