use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use tokio::process::Command;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildSpec {
program: OsString,
args: Vec<OsString>,
env: Vec<(OsString, OsString)>,
current_dir: Option<PathBuf>,
inherit_env: bool,
}
impl ChildSpec {
pub fn new(program: impl Into<OsString>) -> Result<Self, ChildSpecError> {
let program = program.into();
if program.is_empty() {
return Err(ChildSpecError::EmptyProgram);
}
reject_nul("the program name", &program)?;
Ok(Self {
program,
args: Vec::new(),
env: Vec::new(),
current_dir: None,
inherit_env: true,
})
}
pub fn from_argv<I, S>(argv: I) -> Result<Self, ChildSpecError>
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let mut argv = argv.into_iter().map(Into::into);
let Some(program) = argv.next() else {
return Err(ChildSpecError::EmptyArgv);
};
Self::new(program)?.with_args(argv)
}
pub fn with_arg(mut self, arg: impl Into<OsString>) -> Result<Self, ChildSpecError> {
let arg = arg.into();
reject_nul("an argument", &arg)?;
self.args.push(arg);
Ok(self)
}
pub fn with_args<I, S>(mut self, args: I) -> Result<Self, ChildSpecError>
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
for arg in &args {
reject_nul("an argument", arg)?;
}
self.args.extend(args);
Ok(self)
}
pub fn with_env(
mut self,
key: impl Into<OsString>,
value: impl Into<OsString>,
) -> Result<Self, ChildSpecError> {
let key = key.into();
let value = value.into();
if key.is_empty() {
return Err(ChildSpecError::EmptyEnvKey);
}
if key.as_encoded_bytes().contains(&b'=') {
return Err(ChildSpecError::EnvKeyContainsSeparator);
}
reject_nul("an environment key", &key)?;
reject_nul("an environment value", &value)?;
self.env.retain(|(existing, _)| existing != &key);
self.env.push((key, value));
Ok(self)
}
#[must_use]
pub fn with_current_dir(mut self, directory: impl Into<PathBuf>) -> Self {
self.current_dir = Some(directory.into());
self
}
#[must_use]
pub const fn without_inherited_env(mut self) -> Self {
self.inherit_env = false;
self
}
#[must_use]
pub fn program(&self) -> &OsStr {
&self.program
}
#[must_use]
pub fn args(&self) -> &[OsString] {
&self.args
}
#[must_use]
pub fn env(&self) -> &[(OsString, OsString)] {
&self.env
}
#[must_use]
pub fn current_dir(&self) -> Option<&Path> {
self.current_dir.as_deref()
}
#[must_use]
pub const fn inherits_env(&self) -> bool {
self.inherit_env
}
#[must_use]
pub fn to_command(&self) -> Command {
let mut command = Command::new(&self.program);
command.args(&self.args);
if !self.inherit_env {
command.env_clear();
}
for (key, value) in &self.env {
command.env(key, value);
}
if let Some(directory) = &self.current_dir {
command.current_dir(directory);
}
command
}
}
fn reject_nul(what: &'static str, value: &OsStr) -> Result<(), ChildSpecError> {
if value.as_encoded_bytes().contains(&0) {
return Err(ChildSpecError::Unrepresentable { what });
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ChildSpecError {
#[error("the argv is empty, so there is no program to run")]
EmptyArgv,
#[error("the program name is empty")]
EmptyProgram,
#[error("an environment key is empty")]
EmptyEnvKey,
#[error("an environment key contains '=', which separates a key from its value")]
EnvKeyContainsSeparator,
#[error("{what} contains a NUL, which no process can be given")]
Unrepresentable {
what: &'static str,
},
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use assert_matches::assert_matches;
use super::{ChildSpec, ChildSpecError};
type Failure = ChildSpecError;
#[test]
fn a_program_becomes_a_spec() -> Result<(), Failure> {
let spec = ChildSpec::new("my-server")?;
assert_eq!(spec.program(), std::ffi::OsStr::new("my-server"));
assert!(spec.args().is_empty());
Ok(())
}
#[test]
fn the_environment_is_inherited_by_default() -> Result<(), Failure> {
let inherited = ChildSpec::new("my-server")?;
let cleared = ChildSpec::new("my-server")?.without_inherited_env();
assert!(inherited.inherits_env());
assert!(!cleared.inherits_env());
Ok(())
}
#[test]
fn an_argv_splits_at_the_first_element() -> Result<(), Failure> {
let spec = ChildSpec::from_argv(["my-server", "--stdio", "--quiet"])?;
assert_eq!(spec.program(), std::ffi::OsStr::new("my-server"));
assert_eq!(
spec.args(),
[OsString::from("--stdio"), OsString::from("--quiet")]
);
Ok(())
}
#[test]
fn an_empty_argv_is_refused() {
let argv: [&str; 0] = [];
assert_matches!(ChildSpec::from_argv(argv), Err(ChildSpecError::EmptyArgv));
}
#[test]
fn an_empty_program_is_refused() {
assert_matches!(ChildSpec::new(""), Err(ChildSpecError::EmptyProgram));
}
#[test]
fn a_nul_in_an_argument_is_refused() -> Result<(), Failure> {
let spec = ChildSpec::new("my-server")?;
assert_matches!(
spec.with_arg("before\0after"),
Err(ChildSpecError::Unrepresentable {
what: "an argument"
})
);
Ok(())
}
#[test]
fn an_env_key_with_a_separator_is_refused() -> Result<(), Failure> {
let spec = ChildSpec::new("my-server")?;
assert_matches!(
spec.with_env("A=B", "value"),
Err(ChildSpecError::EnvKeyContainsSeparator)
);
Ok(())
}
#[test]
fn a_repeated_env_key_is_replaced() -> Result<(), Failure> {
let spec = ChildSpec::new("my-server")?
.with_env("MODE", "first")?
.with_env("MODE", "second")?;
assert_eq!(spec.env().len(), 1);
assert_eq!(
spec.env().first(),
Some(&(OsString::from("MODE"), OsString::from("second")))
);
Ok(())
}
#[test]
fn a_nul_among_several_arguments_refuses_the_whole_list() -> Result<(), Failure> {
let spec = ChildSpec::new("my-server")?;
assert_matches!(
spec.with_args(["--stdio", "bad\0argument"]),
Err(ChildSpecError::Unrepresentable {
what: "an argument"
})
);
Ok(())
}
#[test]
fn a_command_carries_the_spec() -> Result<(), Failure> {
let spec = ChildSpec::new("my-server")?
.with_args(["--stdio"])?
.with_env("MODE", "test")?;
let command = spec.to_command();
assert_eq!(
command.as_std().get_program(),
std::ffi::OsStr::new("my-server")
);
assert_eq!(
command.as_std().get_args().collect::<Vec<_>>(),
[std::ffi::OsStr::new("--stdio")]
);
Ok(())
}
}