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}