Skip to main content

aion_worker/shell/
action.rs

1//! A declared command executed as an activity.
2//!
3//! [`ShellAction`] turns a command declared in an `.awl` worker block into a
4//! runnable activity, so an action can do real work without a hand-written
5//! Rust crate. The command is parsed once at construction and executed as a
6//! direct `execve` with no shell interposed — see [`super::template`] for why
7//! that is the security property rather than a convenience.
8
9use 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/// What a declared action produces when its command succeeds.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
21pub struct ShellOutcome {
22    /// The command's exit status code.
23    pub exit_code: i32,
24    /// Everything the command wrote to standard output, trailing newline
25    /// trimmed so a one-line result is usable directly.
26    pub stdout: String,
27    /// Everything the command wrote to standard error, trailing newline
28    /// trimmed. Captured even on success, because a command that succeeds
29    /// while warning is a thing an operator needs to see.
30    pub stderr: String,
31}
32
33/// How a declared action treats a non-zero exit.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum FailureMode {
36    /// A non-zero exit is retryable: the engine may run the command again
37    /// under the action's retry policy. This is the default because the
38    /// common causes of a failing command — a busy resource, an unreachable
39    /// host, a transient permission state — are the ones that succeed on a
40    /// second attempt.
41    #[default]
42    Retryable,
43    /// A non-zero exit is terminal: the command will never be retried. Use
44    /// when re-running the command could not possibly change the outcome, or
45    /// when running it twice would itself be harmful.
46    Terminal,
47}
48
49/// The one environment variable a declared command inherits from its host.
50///
51/// A declared command runs with the host's environment CLEARED. That is a
52/// security boundary, not tidiness: the host process legitimately holds
53/// credentials — store URLs, signing keys, cloud tokens — and a deployed
54/// package must never be able to read them by declaring `run "env"`. Nothing
55/// crosses unless an operator names it.
56///
57/// `PATH` is the sole exception, and only because resolving a bare program
58/// name requires it: `run "echo hi"` must find `echo`. Its value is the
59/// host's, unmodified. An action that will not tolerate even that names its
60/// program by absolute path and passes an environment that overrides `PATH`
61/// to whatever it wants.
62const INHERITED_VARIABLE: &str = "PATH";
63
64/// A declared command, ready to run as an activity.
65#[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    /// Parse `command` into a runnable action.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`TemplateError`] when the command is empty, has an unterminated
79    /// quote or `${`, contains a `$` naming no parameter, or puts a parameter
80    /// in program position.
81    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    /// Set how a non-zero exit is classified.
91    #[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    /// Supply the environment the command runs with.
98    ///
99    /// Additive to nothing: the host's environment is cleared regardless, so
100    /// these entries plus [`INHERITED_VARIABLE`] are the command's whole
101    /// environment. An entry named `PATH` here replaces the inherited one.
102    #[must_use]
103    pub fn with_environment(mut self, environment: BTreeMap<String, String>) -> Self {
104        self.environment = environment;
105        self
106    }
107
108    /// Run the command in `directory` instead of wherever the host happens to
109    /// be.
110    ///
111    /// Worth setting whenever a command names a relative path. Absent, the
112    /// command inherits the host process's working directory, which is a
113    /// property of how the host was launched and not something a package
114    /// author can see — so a relative path in a declared command means
115    /// something the author cannot predict.
116    #[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    /// The parameter names this command references, sorted.
123    ///
124    /// A caller can compare these against an action's declared parameters to
125    /// catch a reference to a parameter that does not exist.
126    #[must_use]
127    pub fn referenced_parameters(&self) -> Vec<String> {
128        self.template.referenced_parameters()
129    }
130
131    /// Run the command with `arguments` bound to its parameter references.
132    ///
133    /// Cancellation is tree-wide: the command runs in its own process group,
134    /// and cancelling the activity terminates that whole group rather than
135    /// only the direct child, so a command that spawned children does not
136    /// leave them running.
137    ///
138    /// # Errors
139    ///
140    /// Returns an [`ActivityFailure`] when a referenced parameter is missing or
141    /// unrepresentable (terminal — re-running cannot supply it), when the
142    /// command cannot be spawned or observed (terminal — the program is absent
143    /// or unrunnable), when the command is cancelled (terminal), or when it
144    /// exits non-zero (classified by the action's [`FailureMode`]).
145    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            // `CommandTemplate::parse` refuses an empty command, so a rendered
157            // argv always has a program. Handled rather than indexed so a
158            // future change to the parser cannot turn this into a panic.
159            .ok_or_else(|| ActivityFailure::terminal("the declared command rendered no program"))?;
160
161        let mut command = Command::new(program);
162        command.args(rest);
163        // stdin is CLOSED, never inherited. A declared command that reads
164        // standard input would otherwise be reading the host process's — a
165        // terminal on an interactive boot, or another activity's leftovers —
166        // and would block the activity forever waiting for a line nobody is
167        // going to type.
168        command.stdin(std::process::Stdio::null());
169        // The host's environment does not cross into a declared command. See
170        // `INHERITED_VARIABLE`: only PATH does, and only so a bare program
171        // name resolves.
172        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    /// Build the failure for a command that ran and exited non-zero.
204    fn exit_failure(&self, program: &str, outcome: &ShellOutcome) -> ActivityFailure {
205        // The message carries stderr because a failing command's own words are
206        // the most useful thing an operator can be handed; without it the
207        // failure says only that something exited non-zero.
208        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
226/// Exit code reported when a command was ended by a signal and so has none.
227///
228/// Matches the long-standing shell convention of `128 + SIGKILL`, which is the
229/// number an operator reading the result will already recognise.
230const EXIT_CODE_SIGNALLED: i32 = 137;
231
232/// Classify a substitution failure. All are terminal: the same call with the
233/// same arguments would fail identically however many times it is retried.
234fn substitution_failure(error: &SubstitutionError) -> ActivityFailure {
235    ActivityFailure::terminal(error.to_string())
236}
237
238/// Classify a spawn or observation failure.
239fn spawn_failure(program: &str, error: &ProcessGroupError) -> ActivityFailure {
240    // Terminal in every arm: a program that is absent, unrunnable, or whose
241    // containment could not be established will be exactly as absent on the
242    // next attempt, and retrying a command whose termination could NOT be
243    // confirmed risks running it twice concurrently.
244    ActivityFailure::terminal(format!(
245        "the declared command `{program}` could not be run to completion: {error}"
246    ))
247}
248
249/// Trim exactly one trailing newline, and its carriage return if present.
250///
251/// A command's output almost always ends in a newline that belongs to the
252/// terminal rather than to the value, so `echo hello` yields `hello` and not
253/// `hello\n`. Only one is removed: a command that deliberately emits blank
254/// trailing lines keeps them.
255fn 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    /// What a test returns. Every fallible step is carried rather than
283    /// unwrapped, because the workspace denies panicking accessors in test
284    /// code as firmly as in library code.
285    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        // If a shell were interposed, this would execute a second command.
315        // Because argv goes straight to execve, it is printed as text.
316        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        // Give the command a moment to actually be running before cancelling,
392        // so this exercises termination rather than a pre-start short circuit.
393        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        // A host process legitimately holds credentials. A deployed package
406        // must not be able to read them by declaring `run "env"`. This is the
407        // test that catches a regression to inherited environment.
408        //
409        // It reads a variable the host ALREADY has rather than setting one:
410        // `std::env::set_var` is `unsafe` (process-global, racy against every
411        // other thread) and the workspace denies unsafe outright. Any existing
412        // variable other than the deliberately-inherited PATH proves the same
413        // property.
414        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        // The other half: clearing must not make the environment unusable.
437        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        // Inherited stdin would hang the activity forever on an interactive
459        // boot. Closed stdin makes `cat` finish immediately with nothing.
460        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        // Cally's misparse question, not the injection one: a value that a
471        // careless argv build would split, truncate, or lose entirely. Each is
472        // printed back with a delimiter so a silent split is visible.
473        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        // A leading `-` is the misparse Apollo's law points at: the ARGV is
498        // correct (one element), but the PROGRAM may read it as an option.
499        // The executor cannot fix that — only the author can, by writing
500        // `--` in the declaration. Both halves are pinned here so the
501        // behaviour is recorded rather than discovered.
502        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        // `printf %s -n` — the program's own parse decides; the executor
511        // delivered exactly what was declared either way.
512        assert!(
513            outcome.is_ok(),
514            "the executor must deliver the value and let the program parse it"
515        );
516
517        // With `--` the author has told the program where options end, and the
518        // value is unambiguously a value.
519        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}