use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use super::template::{CommandTemplate, SubstitutionError, TemplateError};
use crate::activity::ActivityFailure;
use crate::context::ActivityContext;
use crate::process::{CancellableCommandOutput, ProcessGroupError, run_cancellable_command};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ShellOutcome {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FailureMode {
#[default]
Retryable,
Terminal,
}
const INHERITED_VARIABLE: &str = "PATH";
#[derive(Debug, Clone)]
pub struct ShellAction {
template: CommandTemplate,
failure_mode: FailureMode,
environment: BTreeMap<String, String>,
working_directory: Option<std::path::PathBuf>,
}
impl ShellAction {
pub fn new(command: &str) -> Result<Self, TemplateError> {
Ok(Self {
template: CommandTemplate::parse(command)?,
failure_mode: FailureMode::default(),
environment: BTreeMap::new(),
working_directory: None,
})
}
#[must_use]
pub const fn with_failure_mode(mut self, failure_mode: FailureMode) -> Self {
self.failure_mode = failure_mode;
self
}
#[must_use]
pub fn with_environment(mut self, environment: BTreeMap<String, String>) -> Self {
self.environment = environment;
self
}
#[must_use]
pub fn with_working_directory(mut self, directory: impl Into<std::path::PathBuf>) -> Self {
self.working_directory = Some(directory.into());
self
}
#[must_use]
pub fn referenced_parameters(&self) -> Vec<String> {
self.template.referenced_parameters()
}
pub async fn run(
&self,
arguments: &BTreeMap<String, serde_json::Value>,
context: &ActivityContext,
) -> Result<ShellOutcome, ActivityFailure> {
let argv = self
.template
.render(arguments)
.map_err(|error| substitution_failure(&error))?;
let (program, rest) = argv
.split_first()
.ok_or_else(|| ActivityFailure::terminal("the declared command rendered no program"))?;
let mut command = Command::new(program);
command.args(rest);
command.stdin(std::process::Stdio::null());
command.env_clear();
if let Some(path) = std::env::var_os(INHERITED_VARIABLE) {
command.env(INHERITED_VARIABLE, path);
}
for (name, value) in &self.environment {
command.env(name, value);
}
if let Some(directory) = &self.working_directory {
command.current_dir(directory);
}
match run_cancellable_command(command, context.cancelled()).await {
Ok(CancellableCommandOutput::Completed(output)) => {
let outcome = ShellOutcome {
exit_code: output.status.code().unwrap_or(EXIT_CODE_SIGNALLED),
stdout: trim_trailing_newline(&String::from_utf8_lossy(&output.stdout)),
stderr: trim_trailing_newline(&String::from_utf8_lossy(&output.stderr)),
};
if output.status.success() {
Ok(outcome)
} else {
Err(self.exit_failure(program, &outcome))
}
}
Ok(CancellableCommandOutput::Cancelled) => Err(ActivityFailure::terminal(format!(
"the declared command `{program}` was cancelled and its process group was terminated"
))),
Err(error) => Err(spawn_failure(program, &error)),
}
}
fn exit_failure(&self, program: &str, outcome: &ShellOutcome) -> ActivityFailure {
let message = if outcome.stderr.is_empty() {
format!(
"the declared command `{program}` exited {} with no standard error output",
outcome.exit_code
)
} else {
format!(
"the declared command `{program}` exited {}: {}",
outcome.exit_code, outcome.stderr
)
};
match self.failure_mode {
FailureMode::Retryable => ActivityFailure::retryable(message),
FailureMode::Terminal => ActivityFailure::terminal(message),
}
}
}
const EXIT_CODE_SIGNALLED: i32 = 137;
fn substitution_failure(error: &SubstitutionError) -> ActivityFailure {
ActivityFailure::terminal(error.to_string())
}
fn spawn_failure(program: &str, error: &ProcessGroupError) -> ActivityFailure {
ActivityFailure::terminal(format!(
"the declared command `{program}` could not be run to completion: {error}"
))
}
fn trim_trailing_newline(text: &str) -> String {
text.strip_suffix('\n')
.map_or(text, |trimmed| {
trimmed.strip_suffix('\r').unwrap_or(trimmed)
})
.to_owned()
}
#[cfg(test)]
mod tests {
use super::{FailureMode, ShellAction, trim_trailing_newline};
use crate::activity::Classification;
use crate::context::ActivityContext;
use aion_core::ActivityId;
use std::collections::BTreeMap;
fn context() -> (ActivityContext, crate::context::ActivityCancellationHandle) {
ActivityContext::new(ActivityId::from_sequence_position(1), 1)
}
fn arguments(pairs: &[(&str, serde_json::Value)]) -> BTreeMap<String, serde_json::Value> {
pairs
.iter()
.map(|(name, value)| ((*name).to_owned(), value.clone()))
.collect()
}
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[tokio::test]
async fn a_succeeding_command_returns_its_output() -> TestResult {
let action = ShellAction::new("echo hello")?;
let (context, _handle) = context();
let outcome = action.run(&BTreeMap::new(), &context).await?;
assert_eq!(outcome.exit_code, 0);
assert_eq!(outcome.stdout, "hello");
assert_eq!(outcome.stderr, "");
Ok(())
}
#[tokio::test]
async fn a_parameter_value_reaches_the_program_as_one_argument() -> TestResult {
let action = ShellAction::new("echo $greeting")?;
let (context, _handle) = context();
let outcome = action
.run(
&arguments(&[("greeting", serde_json::json!("hello there world"))]),
&context,
)
.await?;
assert_eq!(outcome.stdout, "hello there world");
Ok(())
}
#[tokio::test]
async fn a_hostile_value_is_inert_because_no_shell_ever_sees_it() -> TestResult {
let action = ShellAction::new("echo $value")?;
let (context, _handle) = context();
let outcome = action
.run(
&arguments(&[("value", serde_json::json!("hi; echo PWNED"))]),
&context,
)
.await?;
assert_eq!(outcome.stdout, "hi; echo PWNED");
assert!(
!outcome.stdout.contains("PWNED\n"),
"the injected command must never have run"
);
Ok(())
}
#[tokio::test]
async fn a_nonzero_exit_is_retryable_by_default_and_carries_stderr() -> TestResult {
let action = ShellAction::new("sh -c 'echo trouble >&2; exit 3'")?;
let (context, _handle) = context();
let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
return Err("a non-zero exit must fail the activity".into());
};
assert_eq!(failure.classification(), &Classification::Retryable);
assert!(
failure.message().contains("trouble"),
"the failure must carry the command's own words: {}",
failure.message()
);
assert!(failure.message().contains('3'), "the exit code is reported");
Ok(())
}
#[tokio::test]
async fn a_nonzero_exit_is_terminal_when_the_action_says_so() -> TestResult {
let action = ShellAction::new("sh -c 'exit 1'")?.with_failure_mode(FailureMode::Terminal);
let (context, _handle) = context();
let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
return Err("a non-zero exit must fail the activity".into());
};
assert_eq!(failure.classification(), &Classification::Terminal);
Ok(())
}
#[tokio::test]
async fn a_missing_parameter_fails_terminally_before_anything_runs() -> TestResult {
let action = ShellAction::new("echo $absent")?;
let (context, _handle) = context();
let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
return Err("a missing parameter must fail the activity".into());
};
assert_eq!(failure.classification(), &Classification::Terminal);
assert!(failure.message().contains("absent"));
Ok(())
}
#[tokio::test]
async fn an_absent_program_fails_terminally() -> TestResult {
let action = ShellAction::new("aion-no-such-program-exists-anywhere")?;
let (context, _handle) = context();
let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
return Err("an absent program must fail the activity".into());
};
assert_eq!(failure.classification(), &Classification::Terminal);
Ok(())
}
#[tokio::test]
async fn cancellation_stops_the_command_and_fails_terminally() -> TestResult {
let action = ShellAction::new("sleep 30")?;
let (context, handle) = context();
let run = tokio::spawn(async move {
let (context, _keep) = (context, ());
action.run(&BTreeMap::new(), &context).await
});
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
handle.cancel();
let Err(failure) = run.await? else {
return Err("a cancelled command must fail the activity".into());
};
assert_eq!(failure.classification(), &Classification::Terminal);
assert!(failure.message().contains("cancelled"));
Ok(())
}
#[tokio::test]
async fn the_hosts_environment_does_not_cross_into_a_declared_command() -> TestResult {
let Some(present) = std::env::vars_os()
.filter_map(|(name, _)| name.into_string().ok())
.find(|name| name != "PATH" && !name.is_empty() && !name.contains('='))
else {
tracing::info!(
"skipping: the host has no environment variable besides PATH to prove \
non-inheritance with"
);
return Ok(());
};
let action = ShellAction::new(&format!("sh -c 'echo \"[${{{present}:-absent}}]\"'"))?;
let (context, _handle) = context();
let outcome = action.run(&BTreeMap::new(), &context).await?;
assert_eq!(
outcome.stdout, "[absent]",
"the host's `{present}` leaked into a declared command"
);
Ok(())
}
#[tokio::test]
async fn an_operator_supplied_variable_does_reach_the_command() -> TestResult {
let mut environment = BTreeMap::new();
environment.insert("DECLARED_GREETING".to_owned(), "supplied".to_owned());
let action = ShellAction::new("sh -c 'echo \"[$DECLARED_GREETING]\"'")?
.with_environment(environment);
let (context, _handle) = context();
let outcome = action.run(&BTreeMap::new(), &context).await?;
assert_eq!(outcome.stdout, "[supplied]");
Ok(())
}
#[tokio::test]
async fn a_command_runs_in_its_declared_working_directory() -> TestResult {
let action = ShellAction::new("pwd")?.with_working_directory("/");
let (context, _handle) = context();
let outcome = action.run(&BTreeMap::new(), &context).await?;
assert_eq!(outcome.stdout, "/");
Ok(())
}
#[tokio::test]
async fn a_command_reading_stdin_gets_end_of_file_rather_than_the_hosts() -> TestResult {
let action = ShellAction::new("cat")?;
let (context, _handle) = context();
let outcome = action.run(&BTreeMap::new(), &context).await?;
assert_eq!(outcome.exit_code, 0);
assert_eq!(outcome.stdout, "");
Ok(())
}
#[tokio::test]
async fn awkward_values_survive_as_exactly_one_argument_each() -> TestResult {
for (value, expected) in [
("two\nlines", "[two\nlines]"),
("", "[]"),
(" leading and trailing ", "[ leading and trailing ]"),
("tab\there", "[tab\there]"),
("quote\"inside", "[quote\"inside]"),
("single'inside", "[single'inside]"),
("back\\slash", "[back\\slash]"),
] {
let action = ShellAction::new("printf [%s] $value")?;
let (context, _handle) = context();
let outcome = action
.run(&arguments(&[("value", serde_json::json!(value))]), &context)
.await?;
assert_eq!(
outcome.stdout, expected,
"value {value:?} did not arrive as exactly one argument"
);
}
Ok(())
}
#[tokio::test]
async fn a_value_that_looks_like_a_flag_is_still_passed_as_a_value() -> TestResult {
let exposed = ShellAction::new("printf %s $value")?;
let (exposed_context, _exposed_handle) = context();
let outcome = exposed
.run(
&arguments(&[("value", serde_json::json!("-n"))]),
&exposed_context,
)
.await;
assert!(
outcome.is_ok(),
"the executor must deliver the value and let the program parse it"
);
let guarded = ShellAction::new("printf -- [%s] $value")?;
let (guarded_context, _guarded_handle) = context();
let guarded_outcome = guarded
.run(
&arguments(&[("value", serde_json::json!("-n"))]),
&guarded_context,
)
.await?;
assert_eq!(guarded_outcome.stdout, "[-n]");
Ok(())
}
#[test]
fn only_one_trailing_newline_is_trimmed() {
assert_eq!(trim_trailing_newline("hello\n"), "hello");
assert_eq!(trim_trailing_newline("hello\r\n"), "hello");
assert_eq!(trim_trailing_newline("hello\n\n"), "hello\n");
assert_eq!(trim_trailing_newline("hello"), "hello");
assert_eq!(trim_trailing_newline(""), "");
}
}