aion-worker 0.27.1

Rust remote-worker SDK for executing Aion activities over the gRPC worker protocol.
Documentation
//! The WORLD a declared command's process runs in, stated once.
//!
//! A declared command is spawned by more than one executor — the activity
//! executor in [`super::action`], the declared-body executor in
//! [`super::declared`], and the `aion awl recipe fire` runner in the CLI, which
//! runs a document's emitted argv against a working tree. Two executors that
//! agreed about the argv and disagreed about the world the process runs in
//! would be two different bodies wearing one declaration, so the world is
//! established HERE and every executor is handed it by the same function.
//!
//! # What the world is
//!
//! Three properties, in this order:
//!
//! 1. **Standard input is closed.** A declared command that reads standard
//!    input would otherwise read the host process's — a terminal on an
//!    interactive boot, or another activity's leftovers — and block forever
//!    waiting for a line nobody is going to type.
//! 2. **The host's environment is cleared**, then [`INHERITED_VARIABLE`] alone
//!    is put back.
//! 3. **The document's exported bindings are applied in declared order**, so a
//!    binding a document states twice ends on the later one, and a document
//!    that exports `PATH` overrides the inherited value rather than being
//!    silently overridden by it.
//!
//! Everything else about a spawn — the working directory, whether the process
//! is contained in its own group, whether its output is captured or reaches a
//! terminal — belongs to the executor, because those differ by what the
//! executor is FOR, not by what a declaration means.

use std::ffi::OsStr;

/// The one environment variable a declared command inherits from its host.
///
/// A declared command runs with the host's environment CLEARED. That is a
/// security boundary, not tidiness: the host process legitimately holds
/// credentials — store URLs, signing keys, cloud tokens — and a deployed
/// package must never be able to read them by declaring `run "env"`. Nothing
/// crosses unless an operator names it.
///
/// `PATH` is the sole exception, and only because resolving a bare program
/// name requires it: `run "echo hi"` must find `echo`. Its value is the
/// host's, unmodified. An author who will not tolerate even that names the
/// program by absolute path and exports a `PATH` of their own, which replaces
/// the inherited one because exports are applied after it.
pub const INHERITED_VARIABLE: &str = "PATH";

/// A process builder that can be placed in the declared-command world.
///
/// Implemented for both process builders this workspace spawns declared
/// commands with — [`std::process::Command`] for the synchronous CLI runner and
/// [`tokio::process::Command`] for the activity executors — so
/// [`place_in_declared_world`] is one function rather than one per builder.
/// The trait carries only what the world is made of; nothing here can express
/// a working directory or an argument, which is what stops it growing into a
/// second command builder.
pub trait DeclaredCommandWorld {
    /// Close standard input: the child reads end-of-file immediately.
    fn close_standard_input(&mut self);

    /// Remove every variable the host holds.
    fn clear_environment(&mut self);

    /// Bind `name` to `value`, replacing any binding of that name already set.
    fn set_variable(&mut self, name: &OsStr, value: &OsStr);
}

impl DeclaredCommandWorld for std::process::Command {
    fn close_standard_input(&mut self) {
        self.stdin(std::process::Stdio::null());
    }

    fn clear_environment(&mut self) {
        self.env_clear();
    }

    fn set_variable(&mut self, name: &OsStr, value: &OsStr) {
        self.env(name, value);
    }
}

impl DeclaredCommandWorld for tokio::process::Command {
    fn close_standard_input(&mut self) {
        self.stdin(std::process::Stdio::null());
    }

    fn clear_environment(&mut self) {
        self.env_clear();
    }

    fn set_variable(&mut self, name: &OsStr, value: &OsStr) {
        self.env(name, value);
    }
}

/// Place `command` in the world every declared command runs in, binding
/// `exports` — the document's exported environment, in DECLARED ORDER.
///
/// Order is the contract, not an implementation detail: the inherited
/// [`INHERITED_VARIABLE`] is set first and the exports after it, so a document
/// that writes `export PATH := …` gets the `PATH` it wrote, and two exports of
/// one name end on the one the document states last.
pub fn place_in_declared_world<'binding, C, E>(command: &mut C, exports: E)
where
    C: DeclaredCommandWorld + ?Sized,
    E: IntoIterator<Item = (&'binding str, &'binding str)>,
{
    command.close_standard_input();
    command.clear_environment();
    if let Some(path) = std::env::var_os(INHERITED_VARIABLE) {
        command.set_variable(OsStr::new(INHERITED_VARIABLE), &path);
    }
    for (name, value) in exports {
        command.set_variable(OsStr::new(name), OsStr::new(value));
    }
}

#[cfg(test)]
mod tests {
    use std::ffi::OsStr;

    use super::{DeclaredCommandWorld, INHERITED_VARIABLE, place_in_declared_world};

    /// Records what was done to it, in the order it was done, so the world's
    /// SEQUENCE is assertable and not only its end state. The sequence is the
    /// part that carries meaning: a `PATH` export applied before the inherited
    /// value would be silently overridden by the host's.
    #[derive(Default)]
    struct Recorder {
        steps: Vec<String>,
    }

    impl DeclaredCommandWorld for Recorder {
        fn close_standard_input(&mut self) {
            self.steps.push("stdin-closed".to_owned());
        }

        fn clear_environment(&mut self) {
            self.steps.push("environment-cleared".to_owned());
        }

        fn set_variable(&mut self, name: &OsStr, value: &OsStr) {
            self.steps.push(format!(
                "{}={}",
                name.to_string_lossy(),
                value.to_string_lossy()
            ));
        }
    }

    #[test]
    fn the_world_is_stdin_closed_then_a_cleared_environment_then_path_then_the_exports() {
        let mut recorder = Recorder::default();
        place_in_declared_world(&mut recorder, [("GREETING", "hello"), ("SEAT", "calliope")]);

        let mut expected = vec!["stdin-closed".to_owned(), "environment-cleared".to_owned()];
        if let Some(path) = std::env::var_os(INHERITED_VARIABLE) {
            expected.push(format!("PATH={}", path.to_string_lossy()));
        }
        expected.push("GREETING=hello".to_owned());
        expected.push("SEAT=calliope".to_owned());
        assert_eq!(recorder.steps, expected);
    }

    /// An exported `PATH` is applied AFTER the inherited one, which is what
    /// makes it win. Asserted on the sequence rather than on a spawned child,
    /// because the sequence is the reason the child sees what it sees.
    #[test]
    fn an_exported_path_is_applied_after_the_inherited_one() {
        let mut recorder = Recorder::default();
        place_in_declared_world(&mut recorder, [("PATH", "/usr/bin:/bin")]);

        let last = recorder.steps.last().map(String::as_str);
        assert_eq!(last, Some("PATH=/usr/bin:/bin"));
        assert_eq!(
            recorder
                .steps
                .iter()
                .filter(|step| step.starts_with("PATH="))
                .count(),
            if std::env::var_os(INHERITED_VARIABLE).is_some() {
                2
            } else {
                1
            },
            "the inherited PATH is set first and the export after it: {:?}",
            recorder.steps
        );
    }

    /// A document that states one name twice ends on the later binding — the
    /// declared order is spent as declared, never sorted or de-duplicated.
    #[test]
    fn a_name_bound_twice_ends_on_the_later_binding() {
        let mut recorder = Recorder::default();
        place_in_declared_world(&mut recorder, [("SEAT", "first"), ("SEAT", "second")]);

        let bindings: Vec<&str> = recorder
            .steps
            .iter()
            .filter(|step| step.starts_with("SEAT="))
            .map(String::as_str)
            .collect();
        assert_eq!(bindings, vec!["SEAT=first", "SEAT=second"]);
    }
}