majordomus_cli/environment/shell.rs
1//! Shell export: the few variables a shell working in this repository benefits from,
2//! emitted so that `eval "$(majordomus env export)"` is safe whatever the repository is
3//! called and wherever it lives.
4//!
5//! # Why this exists at all
6//!
7//! Without it, a `.envrc` grows a pipeline: a `git` here, a `sed` there, a `jq` to read
8//! the server's lease. Each is a second implementation of something this executable
9//! already knows, none of them is tested, and all of them run on every `cd`. The adapter
10//! should carry no logic, so the logic lives here and the adapter evaluates it.
11//!
12//! # Escaping
13//!
14//! Every value is quoted, not escaped-in-place: a repository whose path holds a quote, a
15//! space, a newline, a `$(...)` or a backtick must produce an assignment that a shell
16//! reads as one literal string. POSIX single quotes admit no interpolation at all, so the
17//! only character needing care is the quote itself. `fish` uses single quotes too but does
18//! process backslash inside them, so it gets its own rule rather than the same one with a
19//! hope attached.
20//!
21//! [`quote_posix`] and [`quote_fish`] are exercised against a corpus of hostile values in
22//! `nothing_in_a_value_can_escape_its_quotes`, and the emitted script is run through a
23//! real shell in the crate's integration tests.
24
25use std::fmt::Write as _;
26
27use super::RepositoryEnvironment;
28
29/// The shell dialect an export is written for.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum Dialect {
32 /// `export NAME='value'` — what `direnv`, `bash`, `zsh`, `sh` and `ksh` all read.
33 #[default]
34 Posix,
35 /// `set -gx NAME 'value'`.
36 Fish,
37}
38
39impl Dialect {
40 /// Parse a dialect by name; `None` for a shell this does not write.
41 ///
42 /// ```
43 /// use majordomus_cli::environment::shell::Dialect;
44 /// assert_eq!(Dialect::parse("direnv"), Some(Dialect::Posix));
45 /// assert_eq!(Dialect::parse("bash"), Some(Dialect::Posix));
46 /// assert_eq!(Dialect::parse("fish"), Some(Dialect::Fish));
47 /// assert_eq!(Dialect::parse("powershell"), None);
48 /// ```
49 pub fn parse(name: &str) -> Option<Self> {
50 match name.trim().to_ascii_lowercase().as_str() {
51 "direnv" | "posix" | "sh" | "bash" | "zsh" | "ksh" => Some(Dialect::Posix),
52 "fish" => Some(Dialect::Fish),
53 _ => None,
54 }
55 }
56
57 /// The name as written.
58 pub fn as_str(self) -> &'static str {
59 match self {
60 Dialect::Posix => "posix",
61 Dialect::Fish => "fish",
62 }
63 }
64}
65
66/// One variable an export may set.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Variable {
69 /// The name, `[A-Za-z_][A-Za-z0-9_]*`.
70 pub name: &'static str,
71 /// The value.
72 pub value: String,
73 /// What it is for, written into the script as a comment.
74 pub purpose: &'static str,
75}
76
77/// The variables this snapshot exports, in a stable order.
78///
79/// Deliberately few. Every variable is a name a person's shell now carries everywhere,
80/// and one that is merely interesting is one more thing to unset; each of these is
81/// something a command in this repository actually reads.
82pub fn variables(environment: &RepositoryEnvironment, share: Option<&str>) -> Vec<Variable> {
83 let mut out = vec![Variable {
84 name: "MAJORDOMUS_ROOT",
85 value: environment.repository.root.clone(),
86 purpose: "the repository this shell is working in",
87 }];
88 if let Some(share) = share {
89 out.push(Variable {
90 name: "MAJORDOMUS_SHARE",
91 value: share.to_string(),
92 purpose: "the kinds and schemas the executable reads at run time",
93 });
94 }
95 // The completion adapter's default binary is `majordomus`, and in this repository that
96 // name on the path is the *shell tool*, which has no `completion query`. Left unset,
97 // every TAB in the checkout that ships the completion would silently answer nothing and
98 // fall back to file completion — the one failure mode a completion must not have,
99 // because it looks exactly like "there is nothing to complete".
100 //
101 // The value is this process's own executable, not the `bin/majordomus-cli` launcher.
102 // The launcher builds the crate when its output is missing or stale, which is right for
103 // a person running a command and catastrophic for a keypress: TAB would block on a
104 // compiler. Whatever is answering this call has already been resolved and is already
105 // built, so naming it is both the fastest answer and the honest one.
106 if let Ok(exe) = std::env::current_exe() {
107 out.push(Variable {
108 name: crate::command_graph::shell::BIN_ENV,
109 value: exe.to_string_lossy().into_owned(),
110 purpose: "the executable the shell completion asks for candidates; never builds",
111 });
112 }
113
114 // Only while a server is actually holding the lease: a variable naming an address
115 // nothing answers at is worse than no variable, because a script will use it.
116 if let Some(url) = environment
117 .service("index")
118 .and_then(|s| s.url.as_ref())
119 .filter(|_| {
120 environment
121 .service("index")
122 .is_some_and(|s| s.availability != super::ServiceAvailability::NotRunning)
123 })
124 {
125 out.push(Variable {
126 name: "MAJORDOMUS_URL",
127 value: url.trim_end_matches('/').to_string(),
128 purpose: "the shared server of this repository, while one is running",
129 });
130 }
131 out
132}
133
134/// The script that sets them.
135///
136/// Has no side effects of any kind: it assigns variables and nothing else. It runs no
137/// command, changes no directory, defines no function and touches no file, so that
138/// evaluating it can do nothing but what reading it says.
139pub fn export(
140 environment: &RepositoryEnvironment,
141 share: Option<&str>,
142 dialect: Dialect,
143) -> String {
144 let mut out = String::new();
145 let _ = writeln!(
146 out,
147 "# generated by `majordomus env export`; assignments only, no side effects"
148 );
149 for variable in variables(environment, share) {
150 debug_assert!(
151 is_identifier(variable.name),
152 "{} is not a shell identifier",
153 variable.name
154 );
155 if !is_identifier(variable.name) {
156 continue;
157 }
158 let _ = writeln!(out, "# {}", variable.purpose);
159 let _ = match dialect {
160 Dialect::Posix => writeln!(
161 out,
162 "export {}={}",
163 variable.name,
164 quote_posix(&variable.value)
165 ),
166 Dialect::Fish => writeln!(
167 out,
168 "set -gx {} {}",
169 variable.name,
170 quote_fish(&variable.value)
171 ),
172 };
173 }
174 out
175}
176
177/// Is this a name a shell will accept as a variable?
178pub fn is_identifier(name: &str) -> bool {
179 let mut chars = name.chars();
180 chars
181 .next()
182 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
183 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
184}
185
186/// Quote a value for `sh`, `bash`, `zsh` and `direnv`.
187///
188/// A POSIX single-quoted string ends at the first `'` and interprets nothing inside it, so
189/// a value is safe once every `'` is closed, escaped outside the quotes, and reopened.
190///
191/// ```
192/// use majordomus_cli::environment::shell::quote_posix;
193/// assert_eq!(quote_posix("/plain/path"), "'/plain/path'");
194/// assert_eq!(quote_posix("with space"), "'with space'");
195/// assert_eq!(quote_posix("$(whoami)"), "'$(whoami)'");
196/// assert_eq!(quote_posix("it's"), r#"'it'\''s'"#);
197/// assert_eq!(quote_posix(""), "''");
198/// ```
199pub fn quote_posix(value: &str) -> String {
200 format!("'{}'", value.replace('\'', r"'\''"))
201}
202
203/// Quote a value for `fish`.
204///
205/// `fish` single quotes are not POSIX single quotes: they do process `\\` and `\'`, so
206/// both are escaped and everything else — `$`, backticks, newlines — is literal.
207///
208/// ```
209/// use majordomus_cli::environment::shell::quote_fish;
210/// assert_eq!(quote_fish("/plain/path"), "'/plain/path'");
211/// assert_eq!(quote_fish("it's"), r"'it\'s'");
212/// assert_eq!(quote_fish(r"back\slash"), r"'back\\slash'");
213/// assert_eq!(quote_fish("$(whoami)"), "'$(whoami)'");
214/// ```
215pub fn quote_fish(value: &str) -> String {
216 format!("'{}'", value.replace('\\', r"\\").replace('\'', r"\'"))
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 /// The values here come from the filesystem and from a lease file, so they are
224 /// attacker-influenced in exactly the way that matters: a repository can be cloned
225 /// into a directory with any name at all, and its path is then evaluated by a shell on
226 /// every `cd`. Nothing in a value may end its own quoting.
227 const HOSTILE: &[&str] = &[
228 "'; rm -rf /; echo '",
229 "$(touch /tmp/pwned)",
230 "`touch /tmp/pwned`",
231 "${IFS}evil",
232 "a\nb",
233 "a\\'b",
234 "\\",
235 "''''",
236 "\"double\"",
237 "a;b|c&d",
238 "*",
239 "~",
240 "$HOME",
241 "",
242 ];
243
244 #[test]
245 fn nothing_in_a_value_can_escape_its_quotes() {
246 for value in HOSTILE {
247 let posix = quote_posix(value);
248 assert!(posix.starts_with('\'') && posix.ends_with('\''));
249 // Every quote inside is part of the `'\''` sequence and never a bare one that
250 // would end the string early and leave the rest as shell code.
251 let inside = &posix[1..posix.len() - 1];
252 assert!(
253 !inside.contains('\'') || inside.contains(r"'\''"),
254 "{value:?} produced {posix}"
255 );
256
257 let fish = quote_fish(value);
258 assert!(fish.starts_with('\'') && fish.ends_with('\''));
259 let inside = &fish[1..fish.len() - 1];
260 let mut chars = inside.chars().peekable();
261 while let Some(c) = chars.next() {
262 if c == '\\' {
263 // an escape must consume the character after it
264 assert!(chars.next().is_some(), "{value:?} produced {fish}");
265 } else {
266 assert_ne!(c, '\'', "{value:?} produced {fish}");
267 }
268 }
269 }
270 }
271
272 #[test]
273 fn a_quoted_value_survives_a_real_shell() {
274 for value in HOSTILE {
275 let script = format!("printf '%s' {}", quote_posix(value));
276 let out = std::process::Command::new("sh")
277 .arg("-c")
278 .arg(&script)
279 .output()
280 .expect("sh runs");
281 assert!(out.status.success(), "{script} failed");
282 assert_eq!(
283 String::from_utf8_lossy(&out.stdout),
284 *value,
285 "{script} did not round-trip"
286 );
287 // and it must not have run anything
288 assert!(!std::path::Path::new("/tmp/pwned").exists());
289 }
290 }
291
292 #[test]
293 fn a_name_that_is_not_an_identifier_is_refused() {
294 assert!(is_identifier("MAJORDOMUS_ROOT"));
295 assert!(is_identifier("_x1"));
296 for bad in ["1BAD", "has-dash", "has space", "", "a;b", "PATH=x"] {
297 assert!(!is_identifier(bad), "{bad:?} was accepted");
298 }
299 }
300
301 #[test]
302 fn every_exported_name_is_an_identifier() {
303 for name in ["MAJORDOMUS_ROOT", "MAJORDOMUS_SHARE", "MAJORDOMUS_URL"] {
304 assert!(is_identifier(name));
305 }
306 }
307
308 #[test]
309 fn the_script_assigns_and_does_nothing_else() {
310 let environment = crate::environment::render::tests_support::environment();
311 let script = export(&environment, Some("/a/share"), Dialect::Posix);
312 for line in script.lines().filter(|l| !l.starts_with('#')) {
313 assert!(line.starts_with("export "), "{line:?} is not an assignment");
314 }
315 assert!(script.contains("MAJORDOMUS_ROOT="));
316 assert!(script.contains("MAJORDOMUS_SHARE='/a/share'"));
317 }
318
319 #[test]
320 fn fish_gets_its_own_dialect_and_not_a_posix_script() {
321 let environment = crate::environment::render::tests_support::environment();
322 let script = export(&environment, None, Dialect::Fish);
323 assert!(script.contains("set -gx MAJORDOMUS_ROOT "));
324 assert!(!script.contains("export "));
325 }
326
327 #[test]
328 fn no_address_is_exported_when_no_server_is_running() {
329 let mut environment = crate::environment::render::tests_support::environment();
330 for service in &mut environment.services {
331 service.url = None;
332 service.availability = super::super::ServiceAvailability::NotRunning;
333 }
334 let script = export(&environment, None, Dialect::Posix);
335 assert!(
336 !script.contains("MAJORDOMUS_URL"),
337 "an address nothing answers at was exported"
338 );
339 }
340}