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