Skip to main content

datui_lib/
cloud_command.rs

1//! Running the commands other tools provide for credentials: `aws`, a profile's
2//! `credential_process`, and later `gcloud` and `az`.
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};
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/// How long a credential command may take. An SSO refresh is a network round trip;
42/// anything slower than this is waiting on something that is not coming.
43pub const CREDENTIAL_TIMEOUT: Duration = Duration::from_secs(30);
44
45/// The secret a `secret_command` prints, run once per session: split into arguments with
46/// no shell, through `env`'s runner, its output trimmed and kept in memory. An error
47/// never includes what the command printed on its standard output.
48pub fn secret(command: &str, env: &crate::cloud_browse::Environment<'_>) -> Result<String, String> {
49    static SECRETS: std::sync::OnceLock<
50        std::sync::Mutex<std::collections::HashMap<String, String>>,
51    > = std::sync::OnceLock::new();
52    let secrets = SECRETS.get_or_init(Default::default);
53    if let Some(secret) = secrets.lock().ok().and_then(|s| s.get(command).cloned()) {
54        return Ok(secret);
55    }
56    let words = split_command_line(command)
57        .filter(|w| !w.is_empty())
58        .ok_or_else(|| "secret_command is not a command line".to_string())?;
59    let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
60    let output = (env.run)(&words[0], &args).map_err(|e| match e {
61        CommandError::Failed(message) => format!(
62            "secret_command failed: {}",
63            message.lines().next().unwrap_or("").trim()
64        ),
65        other => format!("secret_command: {other}"),
66    })?;
67    let secret = output.trim().to_string();
68    if secret.is_empty() {
69        return Err("secret_command printed nothing".to_string());
70    }
71    crate::logging::keep_out_of_log(&secret);
72    if let Ok(mut secrets) = secrets.lock() {
73        secrets.insert(command.to_string(), secret.clone());
74    }
75    Ok(secret)
76}
77
78/// Run `program` with `args` and return what it printed.
79pub fn run(program: &str, args: &[&str], timeout: Duration) -> Result<String, CommandError> {
80    let path = std::env::var_os("PATH").unwrap_or_default();
81    let pathext = std::env::var("PATHEXT").ok();
82    let resolved = find_program(program, &path, pathext.as_deref(), cfg!(windows))
83        .ok_or_else(|| CommandError::Missing(program.to_string()))?;
84
85    let mut command = if is_batch_file(&resolved) {
86        // A `.cmd` or `.bat` file cannot be started directly, only through `cmd`, and
87        // `cmd` parses its arguments again with rules no escaping survives. Arguments
88        // that reach it are held to characters it treats as plain text.
89        if let Some(bad) = args.iter().find(|a| !is_plain_argument(a)) {
90            return Err(CommandError::Refused(format!(
91                "\"{bad}\" cannot be passed to {program}: use only letters, digits and . _ - : / ="
92            )));
93        }
94        let mut command = Command::new("cmd");
95        command.arg("/C").arg(&resolved);
96        command
97    } else {
98        Command::new(&resolved)
99    };
100    command
101        .args(args)
102        .stdin(Stdio::null())
103        .stdout(Stdio::piped())
104        .stderr(Stdio::piped());
105
106    let mut child = command
107        .spawn()
108        .map_err(|_| CommandError::Missing(program.to_string()))?;
109    // Read both pipes on their own threads, so a chatty command cannot fill one and
110    // stall waiting for a reader while this thread waits for it to exit.
111    let stdout = child.stdout.take().map(read_to_end_on_thread);
112    let stderr = child.stderr.take().map(read_to_end_on_thread);
113
114    let deadline = Instant::now() + timeout;
115    let status = loop {
116        match child.try_wait() {
117            Ok(Some(status)) => break status,
118            Ok(None) if Instant::now() >= deadline => {
119                let _ = child.kill();
120                let _ = child.wait();
121                return Err(CommandError::TimedOut(program.to_string()));
122            }
123            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
124            Err(e) => return Err(CommandError::Failed(format!("{program}: {e}"))),
125        }
126    };
127    let stdout = stdout.and_then(|h| h.join().ok()).unwrap_or_default();
128    let stderr = stderr.and_then(|h| h.join().ok()).unwrap_or_default();
129    if status.success() {
130        Ok(stdout)
131    } else {
132        let message = stderr.trim();
133        Err(CommandError::Failed(if message.is_empty() {
134            format!("{program} exited with {status}")
135        } else {
136            message.to_string()
137        }))
138    }
139}
140
141fn read_to_end_on_thread<R: Read + Send + 'static>(
142    mut reader: R,
143) -> std::thread::JoinHandle<String> {
144    std::thread::spawn(move || {
145        let mut text = String::new();
146        let _ = reader.read_to_string(&mut text);
147        text
148    })
149}
150
151/// Where `program` would be run from: itself when it names a path, else the first
152/// match on `path`. On Windows each `PATHEXT` extension is tried too, which is how
153/// `gcloud` finds `gcloud.cmd`.
154pub fn find_program(
155    program: &str,
156    path: &std::ffi::OsStr,
157    pathext: Option<&str>,
158    windows: bool,
159) -> Option<PathBuf> {
160    let candidate = Path::new(program);
161    if candidate.components().count() > 1 {
162        return candidate.is_file().then(|| candidate.to_path_buf());
163    }
164    let extensions: Vec<String> = if windows && candidate.extension().is_none() {
165        pathext
166            .unwrap_or(".COM;.EXE;.BAT;.CMD")
167            .split(';')
168            .filter(|e| !e.is_empty())
169            .map(|e| e.to_ascii_lowercase())
170            .collect()
171    } else {
172        vec![String::new()]
173    };
174    for dir in std::env::split_paths(path) {
175        for extension in &extensions {
176            let file = dir.join(format!("{program}{extension}"));
177            if file.is_file() {
178                return Some(file);
179            }
180        }
181    }
182    None
183}
184
185fn is_batch_file(path: &Path) -> bool {
186    path.extension()
187        .and_then(|e| e.to_str())
188        .is_some_and(|e| e.eq_ignore_ascii_case("cmd") || e.eq_ignore_ascii_case("bat"))
189}
190
191/// Characters `cmd` passes through untouched.
192fn is_plain_argument(arg: &str) -> bool {
193    arg.chars()
194        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':' | '/' | '=' | '\\'))
195}
196
197/// Split a command line into a program and its arguments, the way a POSIX shell does
198/// for words and quotes, without doing anything else a shell does: no variables, no
199/// globs, no pipes. Enough for `credential_process = "op read ..." --flag 'a b'`.
200pub fn split_command_line(line: &str) -> Option<Vec<String>> {
201    let mut words = Vec::new();
202    let mut word = String::new();
203    let mut in_word = false;
204    let mut chars = line.chars();
205    while let Some(c) = chars.next() {
206        match c {
207            '\'' => {
208                in_word = true;
209                loop {
210                    match chars.next()? {
211                        '\'' => break,
212                        other => word.push(other),
213                    }
214                }
215            }
216            '"' => {
217                in_word = true;
218                loop {
219                    match chars.next()? {
220                        '"' => break,
221                        '\\' => word.push(chars.next()?),
222                        other => word.push(other),
223                    }
224                }
225            }
226            '\\' => {
227                in_word = true;
228                word.push(chars.next()?);
229            }
230            c if c.is_whitespace() => {
231                if in_word {
232                    words.push(std::mem::take(&mut word));
233                    in_word = false;
234                }
235            }
236            other => {
237                in_word = true;
238                word.push(other);
239            }
240        }
241    }
242    if in_word {
243        words.push(word);
244    }
245    (!words.is_empty()).then_some(words)
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn a_command_line_splits_like_a_shell_without_being_one() {
254        assert_eq!(
255            split_command_line(r#"op read "op://Private/AWS lab/json" --no-newline"#).unwrap(),
256            ["op", "read", "op://Private/AWS lab/json", "--no-newline"]
257        );
258        assert_eq!(
259            split_command_line("/opt/bin/creds 'a b' c\\ d $HOME").unwrap(),
260            ["/opt/bin/creds", "a b", "c d", "$HOME"]
261        );
262        assert_eq!(split_command_line("   "), None);
263        assert_eq!(split_command_line("unterminated 'quote"), None);
264    }
265
266    #[test]
267    fn only_plain_text_reaches_a_batch_file() {
268        assert!(is_plain_argument("--profile"));
269        assert!(is_plain_argument("research-prod_2"));
270        assert!(!is_plain_argument("work&calc"));
271        assert!(!is_plain_argument("a b"));
272        assert!(!is_plain_argument("%PATH%"));
273        assert!(is_batch_file(Path::new(r"C:\sdk\bin\gcloud.cmd")));
274        assert!(!is_batch_file(Path::new(
275            r"C:\Program Files\Amazon\AWSCLIV2\aws.exe"
276        )));
277    }
278
279    #[test]
280    fn a_program_is_found_on_path_with_windows_extensions() {
281        let dir = tempfile::TempDir::new().unwrap();
282        std::fs::write(dir.path().join("gcloud.cmd"), "").unwrap();
283        std::fs::write(dir.path().join("aws"), "").unwrap();
284        let path = std::env::join_paths([dir.path()]).unwrap();
285
286        let windows = find_program("gcloud", &path, Some(".EXE;.CMD"), true);
287        assert_eq!(windows, Some(dir.path().join("gcloud.cmd")));
288        assert_eq!(find_program("gcloud", &path, None, false), None);
289        assert_eq!(
290            find_program("aws", &path, None, false),
291            Some(dir.path().join("aws"))
292        );
293        assert_eq!(find_program("az", &path, Some(".CMD"), true), None);
294    }
295
296    #[cfg(unix)]
297    #[test]
298    fn output_failure_missing_and_timeout_are_told_apart() {
299        assert_eq!(
300            run(
301                "sh",
302                &["-c", "printf '{\"Version\": 1}'"],
303                Duration::from_secs(5)
304            ),
305            Ok("{\"Version\": 1}".to_string())
306        );
307        assert_eq!(
308            run(
309                "sh",
310                &["-c", "echo expired >&2; exit 3"],
311                Duration::from_secs(5)
312            ),
313            Err(CommandError::Failed("expired".to_string()))
314        );
315        assert_eq!(
316            run("datui-no-such-program", &[], Duration::from_secs(5)),
317            Err(CommandError::Missing("datui-no-such-program".to_string()))
318        );
319        let started = Instant::now();
320        assert_eq!(
321            run("sh", &["-c", "sleep 10"], Duration::from_millis(200)),
322            Err(CommandError::TimedOut("sh".to_string()))
323        );
324        assert!(started.elapsed() < Duration::from_secs(5));
325    }
326}