Skip to main content

datui_lib/cloud/
cloud_command.rs

1//! Running the commands other tools provide for credentials: `aws`, a profile's
2//! `credential_process`, `gcloud`, and `az` and PowerShell for Azure.
3//!
4//! Asking a cloud's own CLI is how SSO, assume-role and MFA work without datui
5//! reimplementing any of them. Every call here blocks, so it only ever runs on a
6//! worker, never on the thread that draws. Arguments are passed as a list, with no
7//! shell in between, and every call has a deadline.
8
9use std::io::Read;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::time::{Duration, Instant, SystemTime};
13
14/// Why a command did not produce its output.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum CommandError {
17    /// The program is not installed, or not on `PATH`.
18    Missing(String),
19    /// It ran and failed; the text is its error output.
20    Failed(String),
21    /// It did not finish in time and was stopped.
22    TimedOut(String),
23    /// An argument could not be passed safely.
24    Refused(String),
25}
26
27impl std::fmt::Display for CommandError {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            CommandError::Missing(program) => write!(f, "needs {program}"),
31            CommandError::Failed(message) => f.write_str(message),
32            CommandError::TimedOut(program) => write!(f, "{program} did not answer in time"),
33            CommandError::Refused(message) => f.write_str(message),
34        }
35    }
36}
37
38/// Runs one command: the real [`run`] with a deadline, or a stand-in in tests.
39pub type Runner<'a> = dyn Fn(&str, &[&str]) -> Result<String, CommandError> + 'a;
40
41/// Secrets by key, each kept until five minutes before it expires, so a credential
42/// command, slow to start, runs once per expiry rather than once per request. One with
43/// no known expiry is kept for the session.
44pub struct Expiring<T>(
45    std::sync::Mutex<std::collections::HashMap<String, (T, Option<SystemTime>)>>,
46);
47
48impl<T> Default for Expiring<T> {
49    fn default() -> Self {
50        Expiring(Default::default())
51    }
52}
53
54impl<T: Clone> Expiring<T> {
55    pub fn get(&self, key: &str) -> Option<T> {
56        let margin = SystemTime::now() + Duration::from_secs(5 * 60);
57        let kept = self.0.lock().ok()?;
58        let (value, expires) = kept.get(key)?;
59        expires.is_none_or(|at| at > margin).then(|| value.clone())
60    }
61
62    pub fn put(&self, key: &str, value: T, expires: Option<SystemTime>) {
63        if let Ok(mut kept) = self.0.lock() {
64            kept.insert(key.to_string(), (value, expires));
65        }
66    }
67}
68
69/// Every item of a paged API, `fetch` handed each page's token. At most `max_pages`
70/// pages: an API that keeps handing back a token would be a loop on a worker nobody is
71/// watching.
72pub fn paged<T>(
73    max_pages: usize,
74    mut fetch: impl FnMut(Option<&str>) -> Result<(Vec<T>, Option<String>), String>,
75) -> Result<Vec<T>, String> {
76    let mut items = Vec::new();
77    let mut token: Option<String> = None;
78    for _ in 0..max_pages {
79        let (page, next) = fetch(token.as_deref())?;
80        items.extend(page);
81        match next {
82            Some(next) => token = Some(next),
83            None => break,
84        }
85    }
86    Ok(items)
87}
88
89/// How long a credential command may take. An SSO refresh is a network round trip;
90/// anything slower than this is waiting on something that is not coming.
91pub const CREDENTIAL_TIMEOUT: Duration = Duration::from_secs(30);
92
93/// The secret a `secret_command` prints, run once per session: split into arguments with
94/// no shell, through `env`'s runner, its output trimmed and kept in memory. An error
95/// never includes what the command printed on its standard output.
96pub fn secret(
97    command: &str,
98    env: &crate::cloud::cloud_browse::Environment<'_>,
99) -> Result<String, String> {
100    static SECRETS: std::sync::OnceLock<
101        std::sync::Mutex<std::collections::HashMap<String, String>>,
102    > = std::sync::OnceLock::new();
103    let secrets = SECRETS.get_or_init(Default::default);
104    if let Some(secret) = secrets.lock().ok().and_then(|s| s.get(command).cloned()) {
105        return Ok(secret);
106    }
107    let words = split_command_line(command)
108        .filter(|w| !w.is_empty())
109        .ok_or_else(|| "secret_command is not a command line".to_string())?;
110    let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
111    let output = (env.run)(&words[0], &args).map_err(|e| match e {
112        CommandError::Failed(message) => format!(
113            "secret_command failed: {}",
114            message.lines().next().unwrap_or("").trim()
115        ),
116        other => format!("secret_command: {other}"),
117    })?;
118    let secret = output.trim().to_string();
119    if secret.is_empty() {
120        return Err("secret_command printed nothing".to_string());
121    }
122    crate::logging::keep_out_of_log(&secret);
123    if let Ok(mut secrets) = secrets.lock() {
124        secrets.insert(command.to_string(), secret.clone());
125    }
126    Ok(secret)
127}
128
129/// Run `program` with `args` and return what it printed.
130pub fn run(program: &str, args: &[&str], timeout: Duration) -> Result<String, CommandError> {
131    let path = std::env::var_os("PATH").unwrap_or_default();
132    let pathext = std::env::var("PATHEXT").ok();
133    let resolved = find_program(program, &path, pathext.as_deref(), cfg!(windows))
134        .ok_or_else(|| CommandError::Missing(program.to_string()))?;
135
136    let mut command = if is_batch_file(&resolved) {
137        // A `.cmd` or `.bat` file cannot be started directly, only through `cmd`, and
138        // `cmd` parses its arguments again with rules no escaping survives. Arguments
139        // that reach it are held to characters it treats as plain text.
140        if let Some(bad) = args.iter().find(|a| !is_plain_argument(a)) {
141            return Err(CommandError::Refused(format!(
142                "\"{bad}\" cannot be passed to {program}: use only letters, digits and . _ - : / ="
143            )));
144        }
145        let mut command = Command::new("cmd");
146        command.arg("/C").arg(&resolved);
147        command
148    } else {
149        Command::new(&resolved)
150    };
151    command
152        .args(args)
153        .stdin(Stdio::null())
154        .stdout(Stdio::piped())
155        .stderr(Stdio::piped());
156
157    let mut child = command
158        .spawn()
159        .map_err(|_| CommandError::Missing(program.to_string()))?;
160    // Read both pipes on their own threads, so a chatty command cannot fill one and
161    // stall waiting for a reader while this thread waits for it to exit.
162    let stdout = child.stdout.take().map(read_to_end_on_thread);
163    let stderr = child.stderr.take().map(read_to_end_on_thread);
164
165    let deadline = Instant::now() + timeout;
166    let status = loop {
167        match child.try_wait() {
168            Ok(Some(status)) => break status,
169            Ok(None) if Instant::now() >= deadline => {
170                let _ = child.kill();
171                let _ = child.wait();
172                return Err(CommandError::TimedOut(program.to_string()));
173            }
174            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
175            Err(e) => return Err(CommandError::Failed(format!("{program}: {e}"))),
176        }
177    };
178    let stdout = stdout.and_then(|h| h.join().ok()).unwrap_or_default();
179    let stderr = stderr.and_then(|h| h.join().ok()).unwrap_or_default();
180    if status.success() {
181        Ok(stdout)
182    } else {
183        let message = stderr.trim();
184        Err(CommandError::Failed(if message.is_empty() {
185            format!("{program} exited with {status}")
186        } else {
187            message.to_string()
188        }))
189    }
190}
191
192fn read_to_end_on_thread<R: Read + Send + 'static>(
193    mut reader: R,
194) -> std::thread::JoinHandle<String> {
195    std::thread::spawn(move || {
196        let mut text = String::new();
197        let _ = reader.read_to_string(&mut text);
198        text
199    })
200}
201
202/// Where `program` would be run from: itself when it names a path, else the first
203/// match on `path`. On Windows each `PATHEXT` extension is tried too, which is how
204/// `gcloud` finds `gcloud.cmd`.
205pub fn find_program(
206    program: &str,
207    path: &std::ffi::OsStr,
208    pathext: Option<&str>,
209    windows: bool,
210) -> Option<PathBuf> {
211    let candidate = Path::new(program);
212    if candidate.components().count() > 1 {
213        return candidate.is_file().then(|| candidate.to_path_buf());
214    }
215    let extensions: Vec<String> = if windows && candidate.extension().is_none() {
216        pathext
217            .unwrap_or(".COM;.EXE;.BAT;.CMD")
218            .split(';')
219            .filter(|e| !e.is_empty())
220            .map(|e| e.to_ascii_lowercase())
221            .collect()
222    } else {
223        vec![String::new()]
224    };
225    for dir in std::env::split_paths(path) {
226        for extension in &extensions {
227            let file = dir.join(format!("{program}{extension}"));
228            if file.is_file() {
229                return Some(file);
230            }
231        }
232    }
233    None
234}
235
236fn is_batch_file(path: &Path) -> bool {
237    path.extension()
238        .and_then(|e| e.to_str())
239        .is_some_and(|e| e.eq_ignore_ascii_case("cmd") || e.eq_ignore_ascii_case("bat"))
240}
241
242/// Characters `cmd` passes through untouched.
243fn is_plain_argument(arg: &str) -> bool {
244    arg.chars()
245        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':' | '/' | '=' | '\\'))
246}
247
248/// Split a command line into a program and its arguments, the way a POSIX shell does
249/// for words and quotes, without doing anything else a shell does: no variables, no
250/// globs, no pipes. Enough for `credential_process = "op read ..." --flag 'a b'`.
251pub fn split_command_line(line: &str) -> Option<Vec<String>> {
252    let mut words = Vec::new();
253    let mut word = String::new();
254    let mut in_word = false;
255    let mut chars = line.chars();
256    while let Some(c) = chars.next() {
257        match c {
258            '\'' => {
259                in_word = true;
260                loop {
261                    match chars.next()? {
262                        '\'' => break,
263                        other => word.push(other),
264                    }
265                }
266            }
267            '"' => {
268                in_word = true;
269                loop {
270                    match chars.next()? {
271                        '"' => break,
272                        '\\' => word.push(chars.next()?),
273                        other => word.push(other),
274                    }
275                }
276            }
277            '\\' => {
278                in_word = true;
279                word.push(chars.next()?);
280            }
281            c if c.is_whitespace() => {
282                if in_word {
283                    words.push(std::mem::take(&mut word));
284                    in_word = false;
285                }
286            }
287            other => {
288                in_word = true;
289                word.push(other);
290            }
291        }
292    }
293    if in_word {
294        words.push(word);
295    }
296    (!words.is_empty()).then_some(words)
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn a_command_line_splits_like_a_shell_without_being_one() {
305        assert_eq!(
306            split_command_line(r#"op read "op://Private/AWS lab/json" --no-newline"#).unwrap(),
307            ["op", "read", "op://Private/AWS lab/json", "--no-newline"]
308        );
309        assert_eq!(
310            split_command_line("/opt/bin/creds 'a b' c\\ d $HOME").unwrap(),
311            ["/opt/bin/creds", "a b", "c d", "$HOME"]
312        );
313        assert_eq!(split_command_line("   "), None);
314        assert_eq!(split_command_line("unterminated 'quote"), None);
315    }
316
317    #[test]
318    fn only_plain_text_reaches_a_batch_file() {
319        assert!(is_plain_argument("--profile"));
320        assert!(is_plain_argument("research-prod_2"));
321        assert!(!is_plain_argument("work&calc"));
322        assert!(!is_plain_argument("a b"));
323        assert!(!is_plain_argument("%PATH%"));
324        assert!(is_batch_file(Path::new(r"C:\sdk\bin\gcloud.cmd")));
325        assert!(!is_batch_file(Path::new(
326            r"C:\Program Files\Amazon\AWSCLIV2\aws.exe"
327        )));
328    }
329
330    #[test]
331    fn a_program_is_found_on_path_with_windows_extensions() {
332        let dir = tempfile::TempDir::new().unwrap();
333        std::fs::write(dir.path().join("gcloud.cmd"), "").unwrap();
334        std::fs::write(dir.path().join("aws"), "").unwrap();
335        let path = std::env::join_paths([dir.path()]).unwrap();
336
337        let windows = find_program("gcloud", &path, Some(".EXE;.CMD"), true);
338        assert_eq!(windows, Some(dir.path().join("gcloud.cmd")));
339        assert_eq!(find_program("gcloud", &path, None, false), None);
340        assert_eq!(
341            find_program("aws", &path, None, false),
342            Some(dir.path().join("aws"))
343        );
344        assert_eq!(find_program("az", &path, Some(".CMD"), true), None);
345    }
346
347    #[cfg(unix)]
348    #[test]
349    fn output_failure_missing_and_timeout_are_told_apart() {
350        assert_eq!(
351            run(
352                "sh",
353                &["-c", "printf '{\"Version\": 1}'"],
354                Duration::from_secs(5)
355            ),
356            Ok("{\"Version\": 1}".to_string())
357        );
358        assert_eq!(
359            run(
360                "sh",
361                &["-c", "echo expired >&2; exit 3"],
362                Duration::from_secs(5)
363            ),
364            Err(CommandError::Failed("expired".to_string()))
365        );
366        assert_eq!(
367            run("datui-no-such-program", &[], Duration::from_secs(5)),
368            Err(CommandError::Missing("datui-no-such-program".to_string()))
369        );
370        let started = Instant::now();
371        assert_eq!(
372            run("sh", &["-c", "sleep 10"], Duration::from_millis(200)),
373            Err(CommandError::TimedOut("sh".to_string()))
374        );
375        assert!(started.elapsed() < Duration::from_secs(5));
376    }
377}