Skip to main content

aion_worker/shell/
world.rs

1//! The WORLD a declared command's process runs in, stated once.
2//!
3//! A declared command is spawned by more than one executor — the activity
4//! executor in [`super::action`], the declared-body executor in
5//! [`super::declared`], and the `aion awl recipe fire` runner in the CLI, which
6//! runs a document's emitted argv against a working tree. Two executors that
7//! agreed about the argv and disagreed about the world the process runs in
8//! would be two different bodies wearing one declaration, so the world is
9//! established HERE and every executor is handed it by the same function.
10//!
11//! # What the world is
12//!
13//! Three properties, in this order:
14//!
15//! 1. **Standard input is closed.** A declared command that reads standard
16//!    input would otherwise read the host process's — a terminal on an
17//!    interactive boot, or another activity's leftovers — and block forever
18//!    waiting for a line nobody is going to type.
19//! 2. **The host's environment is cleared**, then [`INHERITED_VARIABLE`] alone
20//!    is put back.
21//! 3. **The document's exported bindings are applied in declared order**, so a
22//!    binding a document states twice ends on the later one, and a document
23//!    that exports `PATH` overrides the inherited value rather than being
24//!    silently overridden by it.
25//!
26//! Everything else about a spawn — the working directory, whether the process
27//! is contained in its own group, whether its output is captured or reaches a
28//! terminal — belongs to the executor, because those differ by what the
29//! executor is FOR, not by what a declaration means.
30
31use std::ffi::OsStr;
32
33/// The one environment variable a declared command inherits from its host.
34///
35/// A declared command runs with the host's environment CLEARED. That is a
36/// security boundary, not tidiness: the host process legitimately holds
37/// credentials — store URLs, signing keys, cloud tokens — and a deployed
38/// package must never be able to read them by declaring `run "env"`. Nothing
39/// crosses unless an operator names it.
40///
41/// `PATH` is the sole exception, and only because resolving a bare program
42/// name requires it: `run "echo hi"` must find `echo`. Its value is the
43/// host's, unmodified. An author who will not tolerate even that names the
44/// program by absolute path and exports a `PATH` of their own, which replaces
45/// the inherited one because exports are applied after it.
46pub const INHERITED_VARIABLE: &str = "PATH";
47
48/// A process builder that can be placed in the declared-command world.
49///
50/// Implemented for both process builders this workspace spawns declared
51/// commands with — [`std::process::Command`] for the synchronous CLI runner and
52/// [`tokio::process::Command`] for the activity executors — so
53/// [`place_in_declared_world`] is one function rather than one per builder.
54/// The trait carries only what the world is made of; nothing here can express
55/// a working directory or an argument, which is what stops it growing into a
56/// second command builder.
57pub trait DeclaredCommandWorld {
58    /// Close standard input: the child reads end-of-file immediately.
59    fn close_standard_input(&mut self);
60
61    /// Remove every variable the host holds.
62    fn clear_environment(&mut self);
63
64    /// Bind `name` to `value`, replacing any binding of that name already set.
65    fn set_variable(&mut self, name: &OsStr, value: &OsStr);
66}
67
68impl DeclaredCommandWorld for std::process::Command {
69    fn close_standard_input(&mut self) {
70        self.stdin(std::process::Stdio::null());
71    }
72
73    fn clear_environment(&mut self) {
74        self.env_clear();
75    }
76
77    fn set_variable(&mut self, name: &OsStr, value: &OsStr) {
78        self.env(name, value);
79    }
80}
81
82impl DeclaredCommandWorld for tokio::process::Command {
83    fn close_standard_input(&mut self) {
84        self.stdin(std::process::Stdio::null());
85    }
86
87    fn clear_environment(&mut self) {
88        self.env_clear();
89    }
90
91    fn set_variable(&mut self, name: &OsStr, value: &OsStr) {
92        self.env(name, value);
93    }
94}
95
96/// Place `command` in the world every declared command runs in, binding
97/// `exports` — the document's exported environment, in DECLARED ORDER.
98///
99/// Order is the contract, not an implementation detail: the inherited
100/// [`INHERITED_VARIABLE`] is set first and the exports after it, so a document
101/// that writes `export PATH := …` gets the `PATH` it wrote, and two exports of
102/// one name end on the one the document states last.
103pub fn place_in_declared_world<'binding, C, E>(command: &mut C, exports: E)
104where
105    C: DeclaredCommandWorld + ?Sized,
106    E: IntoIterator<Item = (&'binding str, &'binding str)>,
107{
108    command.close_standard_input();
109    command.clear_environment();
110    if let Some(path) = std::env::var_os(INHERITED_VARIABLE) {
111        command.set_variable(OsStr::new(INHERITED_VARIABLE), &path);
112    }
113    for (name, value) in exports {
114        command.set_variable(OsStr::new(name), OsStr::new(value));
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use std::ffi::OsStr;
121
122    use super::{DeclaredCommandWorld, INHERITED_VARIABLE, place_in_declared_world};
123
124    /// Records what was done to it, in the order it was done, so the world's
125    /// SEQUENCE is assertable and not only its end state. The sequence is the
126    /// part that carries meaning: a `PATH` export applied before the inherited
127    /// value would be silently overridden by the host's.
128    #[derive(Default)]
129    struct Recorder {
130        steps: Vec<String>,
131    }
132
133    impl DeclaredCommandWorld for Recorder {
134        fn close_standard_input(&mut self) {
135            self.steps.push("stdin-closed".to_owned());
136        }
137
138        fn clear_environment(&mut self) {
139            self.steps.push("environment-cleared".to_owned());
140        }
141
142        fn set_variable(&mut self, name: &OsStr, value: &OsStr) {
143            self.steps.push(format!(
144                "{}={}",
145                name.to_string_lossy(),
146                value.to_string_lossy()
147            ));
148        }
149    }
150
151    #[test]
152    fn the_world_is_stdin_closed_then_a_cleared_environment_then_path_then_the_exports() {
153        let mut recorder = Recorder::default();
154        place_in_declared_world(&mut recorder, [("GREETING", "hello"), ("SEAT", "calliope")]);
155
156        let mut expected = vec!["stdin-closed".to_owned(), "environment-cleared".to_owned()];
157        if let Some(path) = std::env::var_os(INHERITED_VARIABLE) {
158            expected.push(format!("PATH={}", path.to_string_lossy()));
159        }
160        expected.push("GREETING=hello".to_owned());
161        expected.push("SEAT=calliope".to_owned());
162        assert_eq!(recorder.steps, expected);
163    }
164
165    /// An exported `PATH` is applied AFTER the inherited one, which is what
166    /// makes it win. Asserted on the sequence rather than on a spawned child,
167    /// because the sequence is the reason the child sees what it sees.
168    #[test]
169    fn an_exported_path_is_applied_after_the_inherited_one() {
170        let mut recorder = Recorder::default();
171        place_in_declared_world(&mut recorder, [("PATH", "/usr/bin:/bin")]);
172
173        let last = recorder.steps.last().map(String::as_str);
174        assert_eq!(last, Some("PATH=/usr/bin:/bin"));
175        assert_eq!(
176            recorder
177                .steps
178                .iter()
179                .filter(|step| step.starts_with("PATH="))
180                .count(),
181            if std::env::var_os(INHERITED_VARIABLE).is_some() {
182                2
183            } else {
184                1
185            },
186            "the inherited PATH is set first and the export after it: {:?}",
187            recorder.steps
188        );
189    }
190
191    /// A document that states one name twice ends on the later binding — the
192    /// declared order is spent as declared, never sorted or de-duplicated.
193    #[test]
194    fn a_name_bound_twice_ends_on_the_later_binding() {
195        let mut recorder = Recorder::default();
196        place_in_declared_world(&mut recorder, [("SEAT", "first"), ("SEAT", "second")]);
197
198        let bindings: Vec<&str> = recorder
199            .steps
200            .iter()
201            .filter(|step| step.starts_with("SEAT="))
202            .map(String::as_str)
203            .collect();
204        assert_eq!(bindings, vec!["SEAT=first", "SEAT=second"]);
205    }
206}