use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use std::time::Duration;
use crate::runner::OutputObserver;
use rskit_util::SecretKeyMatcher;
pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
#[non_exhaustive]
pub enum EnvPolicy {
Inherit,
Empty,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ProcessSpec {
pub program: PathBuf,
pub args: Vec<OsString>,
pub dir: Option<PathBuf>,
pub env: HashMap<String, String>,
pub env_policy: EnvPolicy,
}
impl ProcessSpec {
#[must_use]
pub fn new<P: Into<PathBuf>>(program: P) -> Self {
Self {
program: program.into(),
args: Vec::new(),
dir: None,
env: HashMap::new(),
env_policy: EnvPolicy::Inherit,
}
}
#[must_use]
pub fn arg<S: Into<OsString>>(mut self, arg: S) -> Self {
self.args.push(arg.into());
self
}
#[must_use]
pub fn args<I>(mut self, args: I) -> Self
where
I: IntoIterator,
I::Item: Into<OsString>,
{
self.args.extend(args.into_iter().map(Into::into));
self
}
#[must_use]
pub fn dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
self.dir = Some(dir.into());
self
}
#[must_use]
pub fn env<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
self.env.insert(key.into(), value.into());
self
}
#[must_use]
pub fn envs<K: Into<String>, V: Into<String>, I: IntoIterator<Item = (K, V)>>(
mut self,
vars: I,
) -> Self {
for (k, v) in vars {
self.env.insert(k.into(), v.into());
}
self
}
#[must_use]
pub fn env_policy(mut self, policy: EnvPolicy) -> Self {
self.env_policy = policy;
self
}
#[must_use]
pub fn empty_env(mut self) -> Self {
self.env_policy = EnvPolicy::Empty;
self
}
}
#[must_use]
pub fn command<P: Into<PathBuf>>(program: P) -> ProcessSpec {
ProcessSpec::new(program)
}
#[derive(Debug, Clone, Eq, PartialEq, Default)]
#[non_exhaustive]
pub enum InputPolicy {
#[default]
Closed,
Bytes(Vec<u8>),
Inherit,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct OutputPolicy {
pub capture_stdout: bool,
pub capture_stderr: bool,
pub max_output_bytes: Option<usize>,
}
impl Default for OutputPolicy {
fn default() -> Self {
Self::captured()
}
}
impl OutputPolicy {
#[must_use]
pub const fn captured() -> Self {
Self {
capture_stdout: true,
capture_stderr: true,
max_output_bytes: Some(DEFAULT_MAX_OUTPUT_BYTES),
}
}
#[must_use]
pub const fn observe_only() -> Self {
Self {
capture_stdout: false,
capture_stderr: false,
max_output_bytes: Some(DEFAULT_MAX_OUTPUT_BYTES),
}
}
#[must_use]
pub fn with_max_output_bytes(mut self, bytes: usize) -> Self {
self.max_output_bytes = Some(bytes);
self
}
#[must_use]
pub fn with_unbounded_output(mut self) -> Self {
self.max_output_bytes = None;
self
}
}
#[derive(Debug, Clone, Eq, PartialEq, Default)]
pub struct CapturedIo {
pub input: InputPolicy,
pub output: OutputPolicy,
}
impl CapturedIo {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_input(mut self, input: InputPolicy) -> Self {
self.input = input;
self
}
#[must_use]
pub fn with_output(mut self, output: OutputPolicy) -> Self {
self.output = output;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ObservedIo {
pub input: InputPolicy,
pub output: OutputPolicy,
pub observer: OutputObserver,
}
impl ObservedIo {
#[must_use]
pub fn new(observer: OutputObserver) -> Self {
Self {
observer,
..Self::default()
}
}
#[must_use]
pub fn with_input(mut self, input: InputPolicy) -> Self {
self.input = input;
self
}
#[must_use]
pub fn with_output(mut self, output: OutputPolicy) -> Self {
self.output = output;
self
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct InheritedIo {
pub input: InputPolicy,
}
impl Default for InheritedIo {
fn default() -> Self {
Self {
input: InputPolicy::Inherit,
}
}
}
impl InheritedIo {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_input(mut self, input: InputPolicy) -> Self {
self.input = input;
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ProcessIo {
Captured(CapturedIo),
Observed(ObservedIo),
Inherited(InheritedIo),
}
impl Default for ProcessIo {
fn default() -> Self {
Self::Captured(CapturedIo::default())
}
}
impl ProcessIo {
#[must_use]
pub fn captured(io: CapturedIo) -> Self {
Self::Captured(io)
}
#[must_use]
pub fn observed(io: ObservedIo) -> Self {
Self::Observed(io)
}
#[must_use]
pub fn inherited(io: InheritedIo) -> Self {
Self::Inherited(io)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Default)]
pub struct ArgRedaction {
matcher: SecretKeyMatcher,
}
impl ArgRedaction {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn from_names(names: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
Self {
matcher: SecretKeyMatcher::new(names),
}
}
#[must_use]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.matcher = self.matcher.with_name(name);
self
}
#[must_use]
pub fn with_names(mut self, names: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
self.matcher = self.matcher.with_names(names);
self
}
#[must_use]
pub fn is_sensitive_arg_name(&self, name: &str) -> bool {
self.matcher.is_secret_key(name)
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
#[non_exhaustive]
pub struct SignalPolicy {
pub grace_period: Duration,
pub create_process_group: bool,
pub terminate_descendants: bool,
}
impl Default for SignalPolicy {
fn default() -> Self {
Self {
grace_period: Duration::from_secs(5),
create_process_group: true,
terminate_descendants: true,
}
}
}
impl SignalPolicy {
#[must_use]
pub fn with_grace_period(mut self, grace_period: Duration) -> Self {
self.grace_period = grace_period;
self
}
#[must_use]
pub fn with_create_process_group(mut self, create_process_group: bool) -> Self {
self.create_process_group = create_process_group;
self
}
#[must_use]
pub fn with_terminate_descendants(mut self, terminate_descendants: bool) -> Self {
self.terminate_descendants = terminate_descendants;
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ProcessConfig {
pub timeout: Option<Duration>,
pub io: ProcessIo,
pub signal: SignalPolicy,
pub arg_redaction: ArgRedaction,
}
impl Default for ProcessConfig {
fn default() -> Self {
Self {
timeout: Some(Duration::from_secs(30)),
io: ProcessIo::default(),
signal: SignalPolicy::default(),
arg_redaction: ArgRedaction::default(),
}
}
}
impl ProcessConfig {
#[must_use]
pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_io(mut self, io: ProcessIo) -> Self {
self.io = io;
self
}
#[must_use]
pub fn with_signal_policy(mut self, signal: SignalPolicy) -> Self {
self.signal = signal;
self
}
#[must_use]
pub fn with_arg_redaction(mut self, arg_redaction: ArgRedaction) -> Self {
self.arg_redaction = arg_redaction;
self
}
#[must_use]
pub fn with_sensitive_arg_name(mut self, name: impl AsRef<str>) -> Self {
self.arg_redaction = self.arg_redaction.with_name(name);
self
}
#[must_use]
pub fn with_max_output_bytes(mut self, bytes: usize) -> Self {
match &mut self.io {
ProcessIo::Captured(io) => io.output.max_output_bytes = Some(bytes),
ProcessIo::Observed(io) => io.output.max_output_bytes = Some(bytes),
ProcessIo::Inherited(_) => {}
}
self
}
#[must_use]
pub fn with_unbounded_output(mut self) -> Self {
match &mut self.io {
ProcessIo::Captured(io) => io.output.max_output_bytes = None,
ProcessIo::Observed(io) => io.output.max_output_bytes = None,
ProcessIo::Inherited(_) => {}
}
self
}
#[must_use]
pub fn with_input(mut self, input: InputPolicy) -> Self {
match &mut self.io {
ProcessIo::Captured(io) => io.input = input,
ProcessIo::Observed(io) => io.input = input,
ProcessIo::Inherited(io) => io.input = input,
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn process_spec_builders_set_command_environment_and_directory() {
let spec = command("tool")
.arg("--one")
.args([OsString::from("two"), OsString::from("three")])
.dir("work")
.env("TOKEN", "secret")
.envs([("MODE", "test"), ("REGION", "local")])
.env_policy(EnvPolicy::Inherit)
.empty_env();
assert_eq!(spec.program, PathBuf::from("tool"));
assert_eq!(
spec.args,
[
OsString::from("--one"),
OsString::from("two"),
OsString::from("three")
]
);
assert_eq!(spec.dir, Some(PathBuf::from("work")));
assert_eq!(spec.env.get("TOKEN").map(String::as_str), Some("secret"));
assert_eq!(spec.env.get("MODE").map(String::as_str), Some("test"));
assert_eq!(spec.env.get("REGION").map(String::as_str), Some("local"));
assert_eq!(spec.env_policy, EnvPolicy::Empty);
}
#[test]
fn io_policy_builders_update_nested_fields() {
let bounded = OutputPolicy::captured().with_max_output_bytes(128);
assert!(bounded.capture_stdout);
assert!(bounded.capture_stderr);
assert_eq!(bounded.max_output_bytes, Some(128));
assert_eq!(
OutputPolicy::observe_only()
.with_unbounded_output()
.max_output_bytes,
None
);
let captured = CapturedIo::new()
.with_input(InputPolicy::Bytes(b"stdin".to_vec()))
.with_output(OutputPolicy::observe_only());
assert_eq!(captured.input, InputPolicy::Bytes(b"stdin".to_vec()));
assert!(!captured.output.capture_stdout);
let observed = ObservedIo::new(OutputObserver::new())
.with_input(InputPolicy::Closed)
.with_output(OutputPolicy::captured().with_max_output_bytes(7));
assert_eq!(observed.input, InputPolicy::Closed);
assert_eq!(observed.output.max_output_bytes, Some(7));
let inherited = InheritedIo::new().with_input(InputPolicy::Closed);
assert_eq!(inherited.input, InputPolicy::Closed);
assert!(matches!(
ProcessIo::captured(captured),
ProcessIo::Captured(_)
));
assert!(matches!(
ProcessIo::observed(observed),
ProcessIo::Observed(_)
));
assert!(matches!(
ProcessIo::inherited(inherited),
ProcessIo::Inherited(_)
));
}
#[test]
fn redaction_signal_and_config_builders_are_chainable() {
let redaction = ArgRedaction::new()
.with_name("token")
.with_names(["password", "client-secret"]);
assert!(redaction.is_sensitive_arg_name("--token"));
assert!(redaction.is_sensitive_arg_name("password"));
assert!(redaction.is_sensitive_arg_name("client-secret"));
let replacement = ArgRedaction::from_names(["api-key"]);
assert!(replacement.is_sensitive_arg_name("api-key"));
assert!(!replacement.is_sensitive_arg_name("token"));
let signal = SignalPolicy::default()
.with_grace_period(Duration::from_millis(25))
.with_create_process_group(false)
.with_terminate_descendants(false);
assert_eq!(signal.grace_period, Duration::from_millis(25));
assert!(!signal.create_process_group);
assert!(!signal.terminate_descendants);
let captured = ProcessConfig::default()
.with_timeout(None)
.with_arg_redaction(redaction)
.with_sensitive_arg_name("session")
.with_signal_policy(signal)
.with_max_output_bytes(3)
.with_unbounded_output()
.with_input(InputPolicy::Bytes(vec![1, 2, 3]));
assert_eq!(captured.timeout, None);
assert!(captured.arg_redaction.is_sensitive_arg_name("session"));
assert_eq!(captured.signal, signal);
match captured.io {
ProcessIo::Captured(io) => {
assert_eq!(io.input, InputPolicy::Bytes(vec![1, 2, 3]));
assert_eq!(io.output.max_output_bytes, None);
}
ProcessIo::Observed(_) | ProcessIo::Inherited(_) => {
panic!("default process config should use captured I/O")
}
}
}
#[test]
fn config_builders_update_observed_and_inherited_io_modes() {
let observed = ProcessConfig::default()
.with_io(ProcessIo::observed(ObservedIo::default()))
.with_max_output_bytes(11)
.with_unbounded_output()
.with_input(InputPolicy::Bytes(b"observed".to_vec()));
match observed.io {
ProcessIo::Observed(io) => {
assert_eq!(io.input, InputPolicy::Bytes(b"observed".to_vec()));
assert_eq!(io.output.max_output_bytes, None);
}
ProcessIo::Captured(_) | ProcessIo::Inherited(_) => {
panic!("expected observed I/O")
}
}
let inherited = ProcessConfig::default()
.with_io(ProcessIo::inherited(InheritedIo::default()))
.with_max_output_bytes(5)
.with_unbounded_output()
.with_input(InputPolicy::Closed);
match inherited.io {
ProcessIo::Inherited(io) => assert_eq!(io.input, InputPolicy::Closed),
ProcessIo::Captured(_) | ProcessIo::Observed(_) => {
panic!("expected inherited I/O")
}
}
}
}