1use 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
11pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
13
14#[derive(Debug, Clone, Copy, Eq, PartialEq)]
16#[non_exhaustive]
17pub enum EnvPolicy {
18 Inherit,
20 Empty,
22}
23
24#[derive(Debug, Clone, Eq, PartialEq)]
26pub struct ProcessSpec {
27 pub program: PathBuf,
29 pub args: Vec<OsString>,
31 pub dir: Option<PathBuf>,
33 pub env: HashMap<String, String>,
35 pub env_policy: EnvPolicy,
37}
38
39impl ProcessSpec {
40 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
98 pub fn env_policy(mut self, policy: EnvPolicy) -> Self {
99 self.env_policy = policy;
100 self
101 }
102
103 #[must_use]
105 pub fn empty_env(mut self) -> Self {
106 self.env_policy = EnvPolicy::Empty;
107 self
108 }
109}
110
111#[must_use]
113pub fn command<P: Into<PathBuf>>(program: P) -> ProcessSpec {
114 ProcessSpec::new(program)
115}
116
117#[derive(Debug, Clone, Eq, PartialEq, Default)]
119#[non_exhaustive]
120pub enum InputPolicy {
121 #[default]
123 Closed,
124 Bytes(Vec<u8>),
126 Inherit,
128}
129
130#[derive(Debug, Clone, Eq, PartialEq)]
132pub struct OutputPolicy {
133 pub capture_stdout: bool,
135 pub capture_stderr: bool,
137 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 #[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 #[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 #[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 #[must_use]
177 pub fn with_unbounded_output(mut self) -> Self {
178 self.max_output_bytes = None;
179 self
180 }
181}
182
183#[derive(Debug, Clone, Eq, PartialEq, Default)]
185pub struct CapturedIo {
186 pub input: InputPolicy,
188 pub output: OutputPolicy,
190}
191
192impl CapturedIo {
193 #[must_use]
195 pub fn new() -> Self {
196 Self::default()
197 }
198
199 #[must_use]
201 pub fn with_input(mut self, input: InputPolicy) -> Self {
202 self.input = input;
203 self
204 }
205
206 #[must_use]
208 pub fn with_output(mut self, output: OutputPolicy) -> Self {
209 self.output = output;
210 self
211 }
212}
213
214#[derive(Debug, Clone, Default)]
216pub struct ObservedIo {
217 pub input: InputPolicy,
219 pub output: OutputPolicy,
221 pub observer: OutputObserver,
223}
224
225impl ObservedIo {
226 #[must_use]
228 pub fn new(observer: OutputObserver) -> Self {
229 Self {
230 observer,
231 ..Self::default()
232 }
233 }
234
235 #[must_use]
237 pub fn with_input(mut self, input: InputPolicy) -> Self {
238 self.input = input;
239 self
240 }
241
242 #[must_use]
244 pub fn with_output(mut self, output: OutputPolicy) -> Self {
245 self.output = output;
246 self
247 }
248}
249
250#[derive(Debug, Clone, Eq, PartialEq)]
252pub struct InheritedIo {
253 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 #[must_use]
268 pub fn new() -> Self {
269 Self::default()
270 }
271
272 #[must_use]
274 pub fn with_input(mut self, input: InputPolicy) -> Self {
275 self.input = input;
276 self
277 }
278}
279
280#[derive(Debug, Clone)]
282#[non_exhaustive]
283pub enum ProcessIo {
284 Captured(CapturedIo),
286 Observed(ObservedIo),
288 Inherited(InheritedIo),
290}
291
292impl Default for ProcessIo {
293 fn default() -> Self {
294 Self::Captured(CapturedIo::default())
295 }
296}
297
298impl ProcessIo {
299 #[must_use]
301 pub fn captured(io: CapturedIo) -> Self {
302 Self::Captured(io)
303 }
304
305 #[must_use]
307 pub fn observed(io: ObservedIo) -> Self {
308 Self::Observed(io)
309 }
310
311 #[must_use]
313 pub fn inherited(io: InheritedIo) -> Self {
314 Self::Inherited(io)
315 }
316}
317
318#[derive(Debug, Clone, Eq, PartialEq, Default)]
320pub struct ArgRedaction {
321 matcher: SecretKeyMatcher,
322}
323
324impl ArgRedaction {
325 #[must_use]
327 pub fn new() -> Self {
328 Self::default()
329 }
330
331 #[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 #[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 #[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 #[must_use]
355 pub fn is_sensitive_arg_name(&self, name: &str) -> bool {
356 self.matcher.is_secret_key(name)
357 }
358}
359
360#[derive(Debug, Clone, Copy, Eq, PartialEq)]
362#[non_exhaustive]
363pub struct SignalPolicy {
364 pub grace_period: Duration,
366 pub create_process_group: bool,
368 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 #[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 #[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 #[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#[derive(Debug, Clone)]
407#[non_exhaustive]
408pub struct ProcessConfig {
409 pub timeout: Option<Duration>,
411 pub io: ProcessIo,
413 pub signal: SignalPolicy,
415 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 #[must_use]
433 pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
434 self.timeout = timeout;
435 self
436 }
437
438 #[must_use]
440 pub fn with_io(mut self, io: ProcessIo) -> Self {
441 self.io = io;
442 self
443 }
444
445 #[must_use]
447 pub fn with_signal_policy(mut self, signal: SignalPolicy) -> Self {
448 self.signal = signal;
449 self
450 }
451
452 #[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 #[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 #[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 #[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 #[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}