1use std::io::Read;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::time::{Duration, Instant, SystemTime};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum CommandError {
17 Missing(String),
19 Failed(String),
21 TimedOut(String),
23 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
38pub type Runner<'a> = dyn Fn(&str, &[&str]) -> Result<String, CommandError> + 'a;
40
41pub 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
69pub 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
89pub const CREDENTIAL_TIMEOUT: Duration = Duration::from_secs(30);
92
93pub 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
129pub 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 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 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
202pub 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
242fn is_plain_argument(arg: &str) -> bool {
244 arg.chars()
245 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':' | '/' | '=' | '\\'))
246}
247
248pub 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}