use std::collections::BTreeMap;
use aion_core::{ActivityId, RunId, WorkflowId};
use aion_package::{
ArgvSlot, CommandLineContract, CommandParameterContract, DeclaredCommandContract,
EnvBindingContract, FillPiece, FillTemplate,
};
use serde_json::json;
use super::DeclaredCommandAction;
use crate::activity::Classification;
use crate::context::{ActivityCancellationHandle, ActivityContext};
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn context() -> (ActivityContext, ActivityCancellationHandle) {
ActivityContext::new(
WorkflowId::new_v4(),
RunId::new_v4(),
ActivityId::from_sequence_position(1),
1,
)
}
fn hole(parameter: &str) -> FillTemplate {
FillTemplate {
pieces: vec![FillPiece::Hole {
parameter: parameter.to_owned(),
}],
}
}
fn slot(fill: FillTemplate, label: &str) -> ArgvSlot {
ArgvSlot {
fill,
label: label.to_owned(),
admits_leading_dash: true,
}
}
fn line(program: &[&str], args: Vec<ArgvSlot>) -> CommandLineContract {
let mut slots: Vec<ArgvSlot> = program
.iter()
.map(|word| slot(FillTemplate::literal((*word).to_owned()), word))
.collect();
slots.extend(args);
CommandLineContract { slots }
}
fn contract(program: &[&str], args: Vec<ArgvSlot>) -> DeclaredCommandContract {
DeclaredCommandContract {
name: "probe".to_owned(),
parameters: Vec::new(),
lines: vec![line(program, args)],
env: Vec::new(),
cwd: None,
prior_form_refusal: None,
}
}
fn parameter(name: &str) -> CommandParameterContract {
CommandParameterContract {
name: name.to_owned(),
default: None,
}
}
fn arguments(pairs: &[(&str, serde_json::Value)]) -> BTreeMap<String, serde_json::Value> {
pairs
.iter()
.map(|(name, value)| ((*name).to_owned(), value.clone()))
.collect()
}
#[tokio::test]
async fn a_declared_command_runs_and_returns_its_output() -> TestResult {
let mut command = contract(&["echo"], vec![slot(hole("who"), "who")]);
command.parameters = vec![parameter("who")];
let action = DeclaredCommandAction::new(command);
let (context, _handle) = context();
let outcome = action
.run(&arguments(&[("who", json!("world"))]), &context)
.await?;
assert_eq!(outcome.exit_code, 0);
assert_eq!(outcome.stdout, "world");
Ok(())
}
#[tokio::test]
async fn a_hostile_fill_arrives_as_exactly_one_literal_argv_word() -> TestResult {
let mut command = contract(
&["printf"],
vec![
slot(FillTemplate::literal("[%s]"), "format"),
slot(hole("value"), "value"),
],
);
command.parameters = vec![parameter("value")];
let action = DeclaredCommandAction::new(command);
let (context, _handle) = context();
let outcome = action
.run(
&arguments(&[("value", json!("$(boom); rm -rf / && echo PWNED"))]),
&context,
)
.await?;
assert_eq!(outcome.stdout, "[$(boom); rm -rf / && echo PWNED]");
assert!(
!outcome.stdout.contains("PWNED\n"),
"the injected command must never have run"
);
Ok(())
}
#[tokio::test]
async fn a_multi_line_body_runs_in_order_and_concatenates_stdout() -> TestResult {
let mut command = contract(
&["echo"],
vec![slot(FillTemplate::literal("first"), "word")],
);
command.lines.push(line(
&["echo"],
vec![slot(FillTemplate::literal("second"), "word")],
));
command.lines.push(line(
&["echo"],
vec![slot(FillTemplate::literal("third"), "word")],
));
let action = DeclaredCommandAction::new(command);
let (context, _handle) = context();
let outcome = action.run(&BTreeMap::new(), &context).await?;
assert_eq!(outcome.exit_code, 0);
assert_eq!(outcome.stdout, "first\nsecond\nthird");
Ok(())
}
#[tokio::test]
async fn the_first_nonzero_exit_stops_the_body() -> TestResult {
let directory = tempfile::tempdir()?;
let scratch = directory.path().join("witness");
let mut command = contract(
&["sh"],
vec![
slot(FillTemplate::literal("-c"), "-c"),
slot(FillTemplate::literal("echo before; exit 3"), "body"),
],
);
command.lines.push(line(
&["touch"],
vec![slot(
FillTemplate::literal(scratch.display().to_string()),
"witness",
)],
));
let action = DeclaredCommandAction::new(command);
let (context, _handle) = context();
let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
return Err("a non-zero exit must fail the command".into());
};
assert_eq!(failure.classification(), &Classification::Retryable);
assert!(failure.message().contains('3'), "{}", failure.message());
assert!(
!scratch.exists(),
"the line after a failing line must never run"
);
Ok(())
}
#[tokio::test]
async fn a_surplus_action_parameter_is_not_a_refusal() -> TestResult {
let mut command = contract(&["echo"], vec![slot(hole("who"), "who")]);
command.parameters = vec![parameter("who")];
let action = DeclaredCommandAction::new(command);
let (context, _handle) = context();
let outcome = action
.run(
&arguments(&[("who", json!("world")), ("unused", json!("ignored"))]),
&context,
)
.await?;
assert_eq!(outcome.stdout, "world");
Ok(())
}
#[tokio::test]
async fn a_declared_env_binding_reaches_the_child() -> TestResult {
let mut command = contract(
&["sh"],
vec![
slot(FillTemplate::literal("-c"), "-c"),
slot(
FillTemplate::literal("echo \"[$DECLARED_GREETING]\""),
"body",
),
],
);
command.env = vec![EnvBindingContract {
name: "DECLARED_GREETING".to_owned(),
value: "exported".to_owned(),
}];
let action = DeclaredCommandAction::new(command);
let (context, _handle) = context();
let outcome = action.run(&BTreeMap::new(), &context).await?;
assert_eq!(outcome.stdout, "[exported]");
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 command = contract(
&["sh"],
vec![
slot(FillTemplate::literal("-c"), "-c"),
slot(
FillTemplate::literal(format!("echo \"[${{{present}:-absent}}]\"")),
"body",
),
],
);
let action = DeclaredCommandAction::new(command);
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_exported_path_wins_over_the_inherited_one() -> TestResult {
let mut command = contract(
&["/bin/sh"],
vec![
slot(FillTemplate::literal("-c"), "-c"),
slot(FillTemplate::literal("echo \"$PATH\""), "body"),
],
);
command.env = vec![EnvBindingContract {
name: "PATH".to_owned(),
value: "/usr/bin:/bin".to_owned(),
}];
let action = DeclaredCommandAction::new(command);
let (context, _handle) = context();
let outcome = action.run(&BTreeMap::new(), &context).await?;
assert_eq!(outcome.stdout, "/usr/bin:/bin");
Ok(())
}
#[tokio::test]
async fn a_command_runs_in_the_working_directory_it_was_given() -> TestResult {
let command = contract(&["pwd"], Vec::new());
let action = DeclaredCommandAction::new(command).with_working_directory("/");
let (context, _handle) = context();
let outcome = action.run(&BTreeMap::new(), &context).await?;
assert_eq!(outcome.stdout, "/");
Ok(())
}
#[test]
fn the_declared_working_directory_is_handed_back_unexpanded() {
let mut command = contract(&["pwd"], Vec::new());
command.cwd = Some("{workspace_root}/clones".to_owned());
let action = DeclaredCommandAction::new(command);
assert_eq!(
action.declared_working_directory(),
Some("{workspace_root}/clones"),
"resolving the placeholder is the executing host's act, not this crate's"
);
}
#[tokio::test]
async fn a_nonzero_exit_is_retryable_and_carries_the_exit_code_and_stderr() -> TestResult {
let command = contract(
&["sh"],
vec![
slot(FillTemplate::literal("-c"), "-c"),
slot(FillTemplate::literal("echo trouble >&2; exit 3"), "body"),
],
);
let action = DeclaredCommandAction::new(command);
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"),
"stderr must ride the failure: {}",
failure.message()
);
assert!(
failure.message().contains('3'),
"the exit code is reported: {}",
failure.message()
);
Ok(())
}
#[tokio::test]
async fn a_body_with_no_lines_refuses_rather_than_reporting_success() -> TestResult {
let mut command = contract(&["echo"], Vec::new());
command.lines.clear();
let action = DeclaredCommandAction::new(command);
let (context, _handle) = context();
let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
return Err("a command with no lines must refuse rather than succeed".into());
};
assert_eq!(failure.classification(), &Classification::Terminal);
assert!(
failure.message().contains("probe"),
"the refusal must name the command: {}",
failure.message()
);
assert!(
failure.message().contains("no line"),
"the refusal must say what is wrong with it: {}",
failure.message()
);
Ok(())
}
#[tokio::test]
async fn a_failing_line_carries_the_output_the_earlier_lines_produced() -> TestResult {
let mut command = contract(
&["sh"],
vec![
slot(FillTemplate::literal("-c"), "-c"),
slot(
FillTemplate::literal("echo groundwork done; echo early trouble >&2"),
"body",
),
],
);
command.lines.push(line(
&["sh"],
vec![
slot(FillTemplate::literal("-c"), "-c"),
slot(FillTemplate::literal("echo detail >&2; exit 4"), "body"),
],
));
let action = DeclaredCommandAction::new(command);
let (context, _handle) = context();
let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
return Err("a non-zero exit must fail the command".into());
};
let message = failure.message();
assert_eq!(failure.classification(), &Classification::Retryable);
assert!(
message.contains("What the command had printed before it stopped: groundwork done"),
"the output of the lines that ran before the failure must ride the failure: {message}"
);
assert!(
message.contains("That line wrote to standard error: detail"),
"the FAILING line's own standard error is quoted as its own: {message}"
);
assert!(
message.contains("The line before it wrote to standard error: early trouble"),
"an earlier line's standard error is attributed to that line: {message}"
);
assert!(
!message.contains("That line wrote to standard error: early trouble"),
"the earlier line's words must NOT be put in the failing program's mouth: {message}"
);
assert!(
message.contains("exited 4"),
"the failing line's exit code must ride the failure: {message}"
);
assert!(
message.contains("line 2 of 2"),
"the failure must say WHICH line stopped the body: {message}"
);
Ok(())
}
#[tokio::test]
async fn several_earlier_lines_stderr_is_carried_and_attributed_to_them() -> TestResult {
let mut command = contract(
&["sh"],
vec![
slot(FillTemplate::literal("-c"), "-c"),
slot(FillTemplate::literal("echo first-warning >&2"), "body"),
],
);
command.lines.push(line(
&["sh"],
vec![
slot(FillTemplate::literal("-c"), "-c"),
slot(FillTemplate::literal("echo second-warning >&2"), "body"),
],
));
command.lines.push(line(
&["sh"],
vec![
slot(FillTemplate::literal("-c"), "-c"),
slot(FillTemplate::literal("exit 5"), "body"),
],
));
let action = DeclaredCommandAction::new(command);
let (context, _handle) = context();
let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
return Err("a non-zero exit must fail the command".into());
};
let message = failure.message();
assert!(
message.contains("line 3 of 3"),
"the failure names the line that stopped the body: {message}"
);
assert!(
message.contains("That line wrote nothing to standard error"),
"the failing line wrote nothing, and the failure says so of THAT line: {message}"
);
assert!(
message.contains(
"The 2 lines before it wrote to standard error: first-warning\n\
second-warning"
),
"both earlier lines' standard error is carried, attributed to them: {message}"
);
Ok(())
}
#[tokio::test]
async fn a_cancelled_body_carries_the_stderr_its_finished_lines_produced() -> TestResult {
let mut command = contract(
&["sh"],
vec![
slot(FillTemplate::literal("-c"), "-c"),
slot(
FillTemplate::literal("echo groundwork done; echo warned >&2"),
"body",
),
],
);
command.lines.push(line(
&["sleep"],
vec![slot(FillTemplate::literal("30"), "seconds")],
));
let action = DeclaredCommandAction::new(command);
let (context, handle) = context();
let run = tokio::spawn(async move { action.run(&BTreeMap::new(), &context).await });
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
handle.cancel();
let Err(failure) = run.await? else {
return Err("a cancelled command must fail the activity".into());
};
let message = failure.message();
assert_eq!(failure.classification(), &Classification::Terminal);
assert!(
message.contains("line 2 of 2"),
"the failure names the line that was running: {message}"
);
assert!(
message.contains("What the line that had finished printed: groundwork done"),
"the finished line's output rides the cancellation: {message}"
);
assert!(
message.contains("That line wrote to standard error: warned"),
"the finished line's standard error rides the cancellation: {message}"
);
assert!(
message.contains("The cancelled line's own output is not captured here"),
"the report says what it does NOT carry rather than implying it carries everything: \
{message}"
);
Ok(())
}
#[tokio::test]
async fn a_line_ended_by_a_signal_names_that_signal_rather_than_a_fixed_code() -> TestResult {
let command = contract(
&["sh"],
vec![
slot(FillTemplate::literal("-c"), "-c"),
slot(FillTemplate::literal("kill -SEGV $$"), "body"),
],
);
let action = DeclaredCommandAction::new(command);
let (context, _handle) = context();
let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
return Err("a line killed by a signal must fail the command".into());
};
let message = failure.message();
assert!(
message.contains("signal 11"),
"the signal that ended the line must be named: {message}"
);
assert!(
message.contains("SIGSEGV"),
"the signal must be named in words an operator recognises: {message}"
);
assert!(
!message.contains("exited 137") && !message.contains("exited 139"),
"a signal death must not be reported as an exit: {message}"
);
Ok(())
}
#[tokio::test]
async fn cancellation_stops_the_command_and_fails_terminally() -> TestResult {
let command = contract(
&["sleep"],
vec![slot(FillTemplate::literal("30"), "seconds")],
);
let action = DeclaredCommandAction::new(command);
let (context, handle) = context();
let run = tokio::spawn(async move { 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"),
"a cancellation must be reported as one: {}",
failure.message()
);
Ok(())
}
#[tokio::test]
async fn cancelling_a_multi_line_body_leaves_the_later_lines_unrun() -> TestResult {
let directory = tempfile::tempdir()?;
let witness = directory.path().join("witness");
let mut command = contract(
&["sleep"],
vec![slot(FillTemplate::literal("30"), "seconds")],
);
command.lines.push(line(
&["touch"],
vec![slot(
FillTemplate::literal(witness.display().to_string()),
"witness",
)],
));
let action = DeclaredCommandAction::new(command);
let (context, handle) = context();
let run = tokio::spawn(async move { action.run(&BTreeMap::new(), &context).await });
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
handle.cancel();
let outcome = run.await?;
let ran_later_line = witness.exists();
let Err(failure) = outcome else {
return Err("a cancelled command must fail the activity".into());
};
assert_eq!(failure.classification(), &Classification::Terminal);
assert!(
failure.message().contains("cancelled"),
"a cancellation must be reported as one: {}",
failure.message()
);
assert!(
!ran_later_line,
"the line after the cancelled one must never start"
);
Ok(())
}
#[tokio::test]
async fn a_cancellation_already_standing_runs_no_line_of_the_body() -> TestResult {
let directory = tempfile::tempdir()?;
let first = directory.path().join("first");
let second = directory.path().join("second");
let mut command = contract(
&["touch"],
vec![slot(
FillTemplate::literal(first.display().to_string()),
"witness",
)],
);
command.lines.push(line(
&["touch"],
vec![slot(
FillTemplate::literal(second.display().to_string()),
"witness",
)],
));
let action = DeclaredCommandAction::new(command);
let (context, handle) = context();
handle.cancel();
let outcome = action.run(&BTreeMap::new(), &context).await;
let ran_first = first.exists();
let ran_second = second.exists();
let Err(failure) = outcome else {
return Err("a cancelled command must fail the activity".into());
};
assert_eq!(failure.classification(), &Classification::Terminal);
assert!(
failure.message().contains("cancelled") && failure.message().contains("probe"),
"the failure must say the command was cancelled and name it: {}",
failure.message()
);
assert!(!ran_first, "the first line ran despite a standing cancel");
assert!(!ran_second, "the second line ran despite a standing cancel");
Ok(())
}
#[tokio::test]
async fn a_missing_parameter_fails_terminally_before_anything_runs() -> TestResult {
let mut command = contract(&["echo"], vec![slot(hole("who"), "who")]);
command.parameters = vec![parameter("who")];
let action = DeclaredCommandAction::new(command);
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("who"), "{}", failure.message());
Ok(())
}
#[test]
fn a_capture_decides_the_result_and_only_the_result() -> TestResult {
use aion_package::contract::CommandBodyCapture;
let outcome = super::ShellOutcome {
exit_code: 0,
stdout: "{\"name\":\"world\"}".to_owned(),
stderr: String::new(),
};
assert_eq!(
super::shape_command_result("greet", CommandBodyCapture::Text, outcome.clone())?,
json!("{\"name\":\"world\"}")
);
assert_eq!(
super::shape_command_result("greet", CommandBodyCapture::Json, outcome)?,
json!({ "name": "world" })
);
Ok(())
}
#[test]
fn a_json_capture_over_output_that_is_not_json_refuses_terminally() -> TestResult {
use aion_package::contract::CommandBodyCapture;
let outcome = super::ShellOutcome {
exit_code: 0,
stdout: "not json".to_owned(),
stderr: String::new(),
};
let Err(failure) = super::shape_command_result("greet", CommandBodyCapture::Json, outcome)
else {
return Err("output that is not JSON must refuse a `json` capture".into());
};
assert_eq!(failure.classification(), &Classification::Terminal);
assert!(failure.message().contains("greet"), "{}", failure.message());
Ok(())
}