1use 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
13pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
15
16#[derive(Debug, Clone, Copy, Eq, PartialEq)]
18#[non_exhaustive]
19pub enum EnvPolicy {
20 Inherit,
22 Empty,
24}
25
26#[derive(Debug, Clone, Eq, PartialEq)]
28pub struct ProcessSpec {
29 pub program: PathBuf,
31 pub args: Vec<OsString>,
33 pub dir: Option<PathBuf>,
35 pub env: HashMap<String, String>,
37 pub env_policy: EnvPolicy,
39}
40
41impl ProcessSpec {
42 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
100 pub fn env_policy(mut self, policy: EnvPolicy) -> Self {
101 self.env_policy = policy;
102 self
103 }
104
105 #[must_use]
107 pub fn empty_env(mut self) -> Self {
108 self.env_policy = EnvPolicy::Empty;
109 self
110 }
111}
112
113#[must_use]
115pub fn command<P: Into<PathBuf>>(program: P) -> ProcessSpec {
116 ProcessSpec::new(program)
117}
118
119#[derive(Debug, Clone, Eq, PartialEq, Default)]
121#[non_exhaustive]
122pub enum InputPolicy {
123 #[default]
125 Closed,
126 Bytes(Vec<u8>),
128 Inherit,
130}
131
132#[derive(Debug, Clone, Eq, PartialEq)]
134pub struct OutputPolicy {
135 pub capture_stdout: bool,
137 pub capture_stderr: bool,
139 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 #[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 #[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 #[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 #[must_use]
179 pub fn with_unbounded_output(mut self) -> Self {
180 self.max_output_bytes = None;
181 self
182 }
183}
184
185#[derive(Debug, Clone, Eq, PartialEq, Default)]
187pub struct CapturedIo {
188 pub input: InputPolicy,
190 pub output: OutputPolicy,
192}
193
194impl CapturedIo {
195 #[must_use]
197 pub fn new() -> Self {
198 Self::default()
199 }
200
201 #[must_use]
203 pub fn with_input(mut self, input: InputPolicy) -> Self {
204 self.input = input;
205 self
206 }
207
208 #[must_use]
210 pub fn with_output(mut self, output: OutputPolicy) -> Self {
211 self.output = output;
212 self
213 }
214}
215
216#[derive(Debug, Clone, Default)]
218pub struct ObservedIo {
219 pub input: InputPolicy,
221 pub output: OutputPolicy,
223 pub observer: OutputObserver,
225}
226
227impl ObservedIo {
228 #[must_use]
230 pub fn new(observer: OutputObserver) -> Self {
231 Self {
232 observer,
233 ..Self::default()
234 }
235 }
236
237 #[must_use]
239 pub fn with_input(mut self, input: InputPolicy) -> Self {
240 self.input = input;
241 self
242 }
243
244 #[must_use]
246 pub fn with_output(mut self, output: OutputPolicy) -> Self {
247 self.output = output;
248 self
249 }
250}
251
252#[derive(Debug, Clone, Eq, PartialEq)]
254pub struct InheritedIo {
255 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 #[must_use]
270 pub fn new() -> Self {
271 Self::default()
272 }
273
274 #[must_use]
276 pub fn with_input(mut self, input: InputPolicy) -> Self {
277 self.input = input;
278 self
279 }
280}
281
282#[derive(Debug, Clone)]
284#[non_exhaustive]
285pub enum ProcessIo {
286 Captured(CapturedIo),
288 Observed(ObservedIo),
290 Inherited(InheritedIo),
292 #[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 #[must_use]
308 pub fn captured(io: CapturedIo) -> Self {
309 Self::Captured(io)
310 }
311
312 #[must_use]
314 pub fn observed(io: ObservedIo) -> Self {
315 Self::Observed(io)
316 }
317
318 #[must_use]
320 pub fn inherited(io: InheritedIo) -> Self {
321 Self::Inherited(io)
322 }
323
324 #[cfg(unix)]
326 #[must_use]
327 pub fn pty(io: PtyIo) -> Self {
328 Self::Pty(io)
329 }
330}
331
332#[derive(Debug, Clone, Eq, PartialEq, Default)]
334pub struct ArgRedaction {
335 matcher: SecretKeyMatcher,
336}
337
338impl ArgRedaction {
339 #[must_use]
341 pub fn new() -> Self {
342 Self::default()
343 }
344
345 #[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 #[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 #[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 #[must_use]
369 pub fn is_sensitive_arg_name(&self, name: &str) -> bool {
370 self.matcher.is_secret_key(name)
371 }
372}
373
374#[derive(Debug, Clone, Copy, Eq, PartialEq)]
376#[non_exhaustive]
377pub struct SignalPolicy {
378 pub grace_period: Duration,
380 pub create_process_group: bool,
382 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 #[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 #[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 #[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#[derive(Debug, Clone)]
421#[non_exhaustive]
422pub struct ProcessConfig {
423 pub timeout: Option<Duration>,
425 pub io: ProcessIo,
427 pub signal: SignalPolicy,
429 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 #[must_use]
447 pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
448 self.timeout = timeout;
449 self
450 }
451
452 #[must_use]
454 pub fn with_io(mut self, io: ProcessIo) -> Self {
455 self.io = io;
456 self
457 }
458
459 #[must_use]
461 pub fn with_signal_policy(mut self, signal: SignalPolicy) -> Self {
462 self.signal = signal;
463 self
464 }
465
466 #[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 #[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 #[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 #[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 #[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
671 #[cfg(unix)]
672 #[test]
673 fn config_builders_update_pty_io_mode() {
674 let config = ProcessConfig::default()
675 .with_io(ProcessIo::pty(PtyIo::default()))
676 .with_max_output_bytes(9)
677 .with_unbounded_output()
678 .with_input(InputPolicy::Bytes(b"pty".to_vec()));
679
680 match config.io {
681 ProcessIo::Pty(io) => {
682 assert_eq!(io.input, InputPolicy::Bytes(b"pty".to_vec()));
683 assert_eq!(io.output.max_output_bytes, None);
684 }
685 ProcessIo::Captured(_) | ProcessIo::Observed(_) | ProcessIo::Inherited(_) => {
686 panic!("expected pty I/O")
687 }
688 }
689 }
690}