1use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12use tokio::process::Command;
13
14use super::template::{CommandTemplate, SubstitutionError, TemplateError};
15use crate::activity::ActivityFailure;
16use crate::context::ActivityContext;
17use crate::process::{CancellableCommandOutput, ProcessGroupError, run_cancellable_command};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
21pub struct ShellOutcome {
22 pub exit_code: i32,
24 pub stdout: String,
27 pub stderr: String,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum FailureMode {
36 #[default]
42 Retryable,
43 Terminal,
47}
48
49const INHERITED_VARIABLE: &str = "PATH";
63
64#[derive(Debug, Clone)]
66pub struct ShellAction {
67 template: CommandTemplate,
68 failure_mode: FailureMode,
69 environment: BTreeMap<String, String>,
70 working_directory: Option<std::path::PathBuf>,
71}
72
73impl ShellAction {
74 pub fn new(command: &str) -> Result<Self, TemplateError> {
82 Ok(Self {
83 template: CommandTemplate::parse(command)?,
84 failure_mode: FailureMode::default(),
85 environment: BTreeMap::new(),
86 working_directory: None,
87 })
88 }
89
90 #[must_use]
92 pub const fn with_failure_mode(mut self, failure_mode: FailureMode) -> Self {
93 self.failure_mode = failure_mode;
94 self
95 }
96
97 #[must_use]
103 pub fn with_environment(mut self, environment: BTreeMap<String, String>) -> Self {
104 self.environment = environment;
105 self
106 }
107
108 #[must_use]
117 pub fn with_working_directory(mut self, directory: impl Into<std::path::PathBuf>) -> Self {
118 self.working_directory = Some(directory.into());
119 self
120 }
121
122 #[must_use]
127 pub fn referenced_parameters(&self) -> Vec<String> {
128 self.template.referenced_parameters()
129 }
130
131 pub async fn run(
146 &self,
147 arguments: &BTreeMap<String, serde_json::Value>,
148 context: &ActivityContext,
149 ) -> Result<ShellOutcome, ActivityFailure> {
150 let argv = self
151 .template
152 .render(arguments)
153 .map_err(|error| substitution_failure(&error))?;
154 let (program, rest) = argv
155 .split_first()
156 .ok_or_else(|| ActivityFailure::terminal("the declared command rendered no program"))?;
160
161 let mut command = Command::new(program);
162 command.args(rest);
163 command.stdin(std::process::Stdio::null());
169 command.env_clear();
173 if let Some(path) = std::env::var_os(INHERITED_VARIABLE) {
174 command.env(INHERITED_VARIABLE, path);
175 }
176 for (name, value) in &self.environment {
177 command.env(name, value);
178 }
179 if let Some(directory) = &self.working_directory {
180 command.current_dir(directory);
181 }
182
183 match run_cancellable_command(command, context.cancelled()).await {
184 Ok(CancellableCommandOutput::Completed(output)) => {
185 let outcome = ShellOutcome {
186 exit_code: output.status.code().unwrap_or(EXIT_CODE_SIGNALLED),
187 stdout: trim_trailing_newline(&String::from_utf8_lossy(&output.stdout)),
188 stderr: trim_trailing_newline(&String::from_utf8_lossy(&output.stderr)),
189 };
190 if output.status.success() {
191 Ok(outcome)
192 } else {
193 Err(self.exit_failure(program, &outcome))
194 }
195 }
196 Ok(CancellableCommandOutput::Cancelled) => Err(ActivityFailure::terminal(format!(
197 "the declared command `{program}` was cancelled and its process group was terminated"
198 ))),
199 Err(error) => Err(spawn_failure(program, &error)),
200 }
201 }
202
203 fn exit_failure(&self, program: &str, outcome: &ShellOutcome) -> ActivityFailure {
205 let message = if outcome.stderr.is_empty() {
209 format!(
210 "the declared command `{program}` exited {} with no standard error output",
211 outcome.exit_code
212 )
213 } else {
214 format!(
215 "the declared command `{program}` exited {}: {}",
216 outcome.exit_code, outcome.stderr
217 )
218 };
219 match self.failure_mode {
220 FailureMode::Retryable => ActivityFailure::retryable(message),
221 FailureMode::Terminal => ActivityFailure::terminal(message),
222 }
223 }
224}
225
226const EXIT_CODE_SIGNALLED: i32 = 137;
231
232fn substitution_failure(error: &SubstitutionError) -> ActivityFailure {
235 ActivityFailure::terminal(error.to_string())
236}
237
238fn spawn_failure(program: &str, error: &ProcessGroupError) -> ActivityFailure {
240 ActivityFailure::terminal(format!(
245 "the declared command `{program}` could not be run to completion: {error}"
246 ))
247}
248
249fn trim_trailing_newline(text: &str) -> String {
256 text.strip_suffix('\n')
257 .map_or(text, |trimmed| {
258 trimmed.strip_suffix('\r').unwrap_or(trimmed)
259 })
260 .to_owned()
261}
262
263#[cfg(test)]
264mod tests {
265 use super::{FailureMode, ShellAction, trim_trailing_newline};
266 use crate::activity::Classification;
267 use crate::context::ActivityContext;
268 use aion_core::ActivityId;
269 use std::collections::BTreeMap;
270
271 fn context() -> (ActivityContext, crate::context::ActivityCancellationHandle) {
272 ActivityContext::new(ActivityId::from_sequence_position(1), 1)
273 }
274
275 fn arguments(pairs: &[(&str, serde_json::Value)]) -> BTreeMap<String, serde_json::Value> {
276 pairs
277 .iter()
278 .map(|(name, value)| ((*name).to_owned(), value.clone()))
279 .collect()
280 }
281
282 type TestResult = Result<(), Box<dyn std::error::Error>>;
286
287 #[tokio::test]
288 async fn a_succeeding_command_returns_its_output() -> TestResult {
289 let action = ShellAction::new("echo hello")?;
290 let (context, _handle) = context();
291 let outcome = action.run(&BTreeMap::new(), &context).await?;
292 assert_eq!(outcome.exit_code, 0);
293 assert_eq!(outcome.stdout, "hello");
294 assert_eq!(outcome.stderr, "");
295 Ok(())
296 }
297
298 #[tokio::test]
299 async fn a_parameter_value_reaches_the_program_as_one_argument() -> TestResult {
300 let action = ShellAction::new("echo $greeting")?;
301 let (context, _handle) = context();
302 let outcome = action
303 .run(
304 &arguments(&[("greeting", serde_json::json!("hello there world"))]),
305 &context,
306 )
307 .await?;
308 assert_eq!(outcome.stdout, "hello there world");
309 Ok(())
310 }
311
312 #[tokio::test]
313 async fn a_hostile_value_is_inert_because_no_shell_ever_sees_it() -> TestResult {
314 let action = ShellAction::new("echo $value")?;
317 let (context, _handle) = context();
318 let outcome = action
319 .run(
320 &arguments(&[("value", serde_json::json!("hi; echo PWNED"))]),
321 &context,
322 )
323 .await?;
324 assert_eq!(outcome.stdout, "hi; echo PWNED");
325 assert!(
326 !outcome.stdout.contains("PWNED\n"),
327 "the injected command must never have run"
328 );
329 Ok(())
330 }
331
332 #[tokio::test]
333 async fn a_nonzero_exit_is_retryable_by_default_and_carries_stderr() -> TestResult {
334 let action = ShellAction::new("sh -c 'echo trouble >&2; exit 3'")?;
335 let (context, _handle) = context();
336 let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
337 return Err("a non-zero exit must fail the activity".into());
338 };
339 assert_eq!(failure.classification(), &Classification::Retryable);
340 assert!(
341 failure.message().contains("trouble"),
342 "the failure must carry the command's own words: {}",
343 failure.message()
344 );
345 assert!(failure.message().contains('3'), "the exit code is reported");
346 Ok(())
347 }
348
349 #[tokio::test]
350 async fn a_nonzero_exit_is_terminal_when_the_action_says_so() -> TestResult {
351 let action = ShellAction::new("sh -c 'exit 1'")?.with_failure_mode(FailureMode::Terminal);
352 let (context, _handle) = context();
353 let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
354 return Err("a non-zero exit must fail the activity".into());
355 };
356 assert_eq!(failure.classification(), &Classification::Terminal);
357 Ok(())
358 }
359
360 #[tokio::test]
361 async fn a_missing_parameter_fails_terminally_before_anything_runs() -> TestResult {
362 let action = ShellAction::new("echo $absent")?;
363 let (context, _handle) = context();
364 let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
365 return Err("a missing parameter must fail the activity".into());
366 };
367 assert_eq!(failure.classification(), &Classification::Terminal);
368 assert!(failure.message().contains("absent"));
369 Ok(())
370 }
371
372 #[tokio::test]
373 async fn an_absent_program_fails_terminally() -> TestResult {
374 let action = ShellAction::new("aion-no-such-program-exists-anywhere")?;
375 let (context, _handle) = context();
376 let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
377 return Err("an absent program must fail the activity".into());
378 };
379 assert_eq!(failure.classification(), &Classification::Terminal);
380 Ok(())
381 }
382
383 #[tokio::test]
384 async fn cancellation_stops_the_command_and_fails_terminally() -> TestResult {
385 let action = ShellAction::new("sleep 30")?;
386 let (context, handle) = context();
387 let run = tokio::spawn(async move {
388 let (context, _keep) = (context, ());
389 action.run(&BTreeMap::new(), &context).await
390 });
391 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
394 handle.cancel();
395 let Err(failure) = run.await? else {
396 return Err("a cancelled command must fail the activity".into());
397 };
398 assert_eq!(failure.classification(), &Classification::Terminal);
399 assert!(failure.message().contains("cancelled"));
400 Ok(())
401 }
402
403 #[tokio::test]
404 async fn the_hosts_environment_does_not_cross_into_a_declared_command() -> TestResult {
405 let Some(present) = std::env::vars_os()
415 .filter_map(|(name, _)| name.into_string().ok())
416 .find(|name| name != "PATH" && !name.is_empty() && !name.contains('='))
417 else {
418 tracing::info!(
419 "skipping: the host has no environment variable besides PATH to prove \
420 non-inheritance with"
421 );
422 return Ok(());
423 };
424 let action = ShellAction::new(&format!("sh -c 'echo \"[${{{present}:-absent}}]\"'"))?;
425 let (context, _handle) = context();
426 let outcome = action.run(&BTreeMap::new(), &context).await?;
427 assert_eq!(
428 outcome.stdout, "[absent]",
429 "the host's `{present}` leaked into a declared command"
430 );
431 Ok(())
432 }
433
434 #[tokio::test]
435 async fn an_operator_supplied_variable_does_reach_the_command() -> TestResult {
436 let mut environment = BTreeMap::new();
438 environment.insert("DECLARED_GREETING".to_owned(), "supplied".to_owned());
439 let action = ShellAction::new("sh -c 'echo \"[$DECLARED_GREETING]\"'")?
440 .with_environment(environment);
441 let (context, _handle) = context();
442 let outcome = action.run(&BTreeMap::new(), &context).await?;
443 assert_eq!(outcome.stdout, "[supplied]");
444 Ok(())
445 }
446
447 #[tokio::test]
448 async fn a_command_runs_in_its_declared_working_directory() -> TestResult {
449 let action = ShellAction::new("pwd")?.with_working_directory("/");
450 let (context, _handle) = context();
451 let outcome = action.run(&BTreeMap::new(), &context).await?;
452 assert_eq!(outcome.stdout, "/");
453 Ok(())
454 }
455
456 #[tokio::test]
457 async fn a_command_reading_stdin_gets_end_of_file_rather_than_the_hosts() -> TestResult {
458 let action = ShellAction::new("cat")?;
461 let (context, _handle) = context();
462 let outcome = action.run(&BTreeMap::new(), &context).await?;
463 assert_eq!(outcome.exit_code, 0);
464 assert_eq!(outcome.stdout, "");
465 Ok(())
466 }
467
468 #[tokio::test]
469 async fn awkward_values_survive_as_exactly_one_argument_each() -> TestResult {
470 for (value, expected) in [
474 ("two\nlines", "[two\nlines]"),
475 ("", "[]"),
476 (" leading and trailing ", "[ leading and trailing ]"),
477 ("tab\there", "[tab\there]"),
478 ("quote\"inside", "[quote\"inside]"),
479 ("single'inside", "[single'inside]"),
480 ("back\\slash", "[back\\slash]"),
481 ] {
482 let action = ShellAction::new("printf [%s] $value")?;
483 let (context, _handle) = context();
484 let outcome = action
485 .run(&arguments(&[("value", serde_json::json!(value))]), &context)
486 .await?;
487 assert_eq!(
488 outcome.stdout, expected,
489 "value {value:?} did not arrive as exactly one argument"
490 );
491 }
492 Ok(())
493 }
494
495 #[tokio::test]
496 async fn a_value_that_looks_like_a_flag_is_still_passed_as_a_value() -> TestResult {
497 let exposed = ShellAction::new("printf %s $value")?;
503 let (exposed_context, _exposed_handle) = context();
504 let outcome = exposed
505 .run(
506 &arguments(&[("value", serde_json::json!("-n"))]),
507 &exposed_context,
508 )
509 .await;
510 assert!(
513 outcome.is_ok(),
514 "the executor must deliver the value and let the program parse it"
515 );
516
517 let guarded = ShellAction::new("printf -- [%s] $value")?;
520 let (guarded_context, _guarded_handle) = context();
521 let guarded_outcome = guarded
522 .run(
523 &arguments(&[("value", serde_json::json!("-n"))]),
524 &guarded_context,
525 )
526 .await?;
527 assert_eq!(guarded_outcome.stdout, "[-n]");
528 Ok(())
529 }
530
531 #[test]
532 fn only_one_trailing_newline_is_trimmed() {
533 assert_eq!(trim_trailing_newline("hello\n"), "hello");
534 assert_eq!(trim_trailing_newline("hello\r\n"), "hello");
535 assert_eq!(trim_trailing_newline("hello\n\n"), "hello\n");
536 assert_eq!(trim_trailing_newline("hello"), "hello");
537 assert_eq!(trim_trailing_newline(""), "");
538 }
539}