1use std::collections::HashMap;
12use std::io::Read;
13use std::process::{Command, Stdio};
14use std::sync::{LazyLock, Mutex, PoisonError};
15use std::time::{Duration, Instant};
16
17use indexmap::IndexMap;
18
19use crate::env::{EnvSource, lookup};
20
21pub type ExplicitEnv = IndexMap<String, String>;
23
24const COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
26
27static COMMAND_CACHE: LazyLock<Mutex<HashMap<String, Option<String>>>> =
28 LazyLock::new(|| Mutex::new(HashMap::new()));
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31enum TemplatePart {
32 Literal(String),
33 Env(String),
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37enum Reference {
38 Command(String),
40 Template(Vec<TemplatePart>),
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45pub enum ConfigValueError {
46 #[error("Failed to resolve {description} from shell command: {command}")]
48 Command {
49 description: String,
51 command: String,
53 },
54 #[error("Failed to resolve {description} from environment variable: {name}")]
56 EnvVar {
57 description: String,
59 name: String,
61 },
62 #[error("Failed to resolve {description} from environment variables: {}", names.join(", "))]
64 EnvVars {
65 description: String,
67 names: Vec<String>,
69 },
70 #[error("Failed to resolve {description}")]
72 Other {
73 description: String,
75 },
76}
77
78fn is_env_name(name: &str) -> bool {
79 let mut chars = name.chars();
80 chars
81 .next()
82 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
83 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
84}
85
86fn env_name_prefix(text: &str) -> &str {
87 let mut end = 0;
88 for (index, c) in text.char_indices() {
89 let valid = if index == 0 {
90 c.is_ascii_alphabetic() || c == '_'
91 } else {
92 c.is_ascii_alphanumeric() || c == '_'
93 };
94 if !valid {
95 break;
96 }
97 end = index + c.len_utf8();
98 }
99 &text[..end]
100}
101
102fn push_literal(parts: &mut Vec<TemplatePart>, value: &str) {
103 if value.is_empty() {
104 return;
105 }
106 if let Some(TemplatePart::Literal(previous)) = parts.last_mut() {
107 previous.push_str(value);
108 } else {
109 parts.push(TemplatePart::Literal(value.to_owned()));
110 }
111}
112
113fn parse_template(config: &str) -> Vec<TemplatePart> {
114 let mut parts = Vec::new();
115 let mut index = 0;
116 while index < config.len() {
117 let Some(offset) = config[index..].find('$') else {
118 push_literal(&mut parts, &config[index..]);
119 break;
120 };
121 let dollar = index + offset;
122 push_literal(&mut parts, &config[index..dollar]);
123 let rest = &config[dollar + 1..];
124 match rest.chars().next() {
125 Some(next @ ('$' | '!')) => {
126 push_literal(&mut parts, &next.to_string());
127 index = dollar + 2;
128 }
129 Some('{') => {
130 if let Some(close) = config[dollar + 2..].find('}') {
131 let end = dollar + 2 + close;
132 let name = &config[dollar + 2..end];
133 if is_env_name(name) {
134 parts.push(TemplatePart::Env(name.to_owned()));
135 } else {
136 push_literal(&mut parts, &config[dollar..=end]);
137 }
138 index = end + 1;
139 } else {
140 push_literal(&mut parts, "$");
141 index = dollar + 1;
142 }
143 }
144 _ => {
145 let name = env_name_prefix(rest);
146 if name.is_empty() {
147 push_literal(&mut parts, "$");
148 index = dollar + 1;
149 } else {
150 parts.push(TemplatePart::Env(name.to_owned()));
151 index = dollar + 1 + name.len();
152 }
153 }
154 }
155 }
156 parts
157}
158
159fn parse_reference(config: &str) -> Reference {
160 if config.starts_with('!') {
161 Reference::Command(config.to_owned())
162 } else {
163 Reference::Template(parse_template(config))
164 }
165}
166
167fn template_env_names(parts: &[TemplatePart]) -> Vec<String> {
168 let mut names: Vec<String> = Vec::new();
169 for part in parts {
170 if let TemplatePart::Env(name) = part
171 && !names.contains(name)
172 {
173 names.push(name.clone());
174 }
175 }
176 names
177}
178
179fn resolve_template(
180 parts: &[TemplatePart],
181 explicit: Option<&ExplicitEnv>,
182 env: &dyn EnvSource,
183) -> Option<String> {
184 let mut resolved = String::new();
185 for part in parts {
186 match part {
187 TemplatePart::Literal(value) => resolved.push_str(value),
188 TemplatePart::Env(name) => resolved.push_str(&lookup(name, explicit, env)?),
189 }
190 }
191 Some(resolved)
192}
193
194#[must_use]
196pub fn config_value_env_var_name(config: &str) -> Option<String> {
197 match parse_reference(config) {
198 Reference::Template(parts) => match parts.as_slice() {
199 [TemplatePart::Env(name)] => Some(name.clone()),
200 _ => None,
201 },
202 Reference::Command(_) => None,
203 }
204}
205
206#[must_use]
208pub fn config_value_env_var_names(config: &str) -> Vec<String> {
209 match parse_reference(config) {
210 Reference::Template(parts) => template_env_names(&parts),
211 Reference::Command(_) => Vec::new(),
212 }
213}
214
215#[must_use]
217pub fn missing_config_value_env_var_names(
218 config: &str,
219 explicit: Option<&ExplicitEnv>,
220 env: &dyn EnvSource,
221) -> Vec<String> {
222 config_value_env_var_names(config)
223 .into_iter()
224 .filter(|name| lookup(name, explicit, env).is_none())
225 .collect()
226}
227
228#[must_use]
230pub fn is_command_config_value(config: &str) -> bool {
231 config.starts_with('!')
232}
233
234#[must_use]
236pub fn is_config_value_configured(
237 config: &str,
238 explicit: Option<&ExplicitEnv>,
239 env: &dyn EnvSource,
240) -> bool {
241 missing_config_value_env_var_names(config, explicit, env).is_empty()
242}
243
244#[must_use]
246pub fn resolve_config_value(
247 config: &str,
248 explicit: Option<&ExplicitEnv>,
249 env: &dyn EnvSource,
250) -> Option<String> {
251 match parse_reference(config) {
252 Reference::Command(command) => execute_cached(&command),
253 Reference::Template(parts) => resolve_template(&parts, explicit, env),
254 }
255}
256
257#[must_use]
259pub fn resolve_config_value_uncached(
260 config: &str,
261 explicit: Option<&ExplicitEnv>,
262 env: &dyn EnvSource,
263) -> Option<String> {
264 match parse_reference(config) {
265 Reference::Command(command) => execute_command(&command[1..]),
266 Reference::Template(parts) => resolve_template(&parts, explicit, env),
267 }
268}
269
270pub fn resolve_config_value_strict(
276 config: &str,
277 description: &str,
278 explicit: Option<&ExplicitEnv>,
279 env: &dyn EnvSource,
280) -> Result<String, ConfigValueError> {
281 if let Some(value) = resolve_config_value_uncached(config, explicit, env) {
282 return Ok(value);
283 }
284 let description = description.to_owned();
285 if let Some(command) = config.strip_prefix('!') {
286 return Err(ConfigValueError::Command {
287 description,
288 command: command.to_owned(),
289 });
290 }
291 let mut missing = missing_config_value_env_var_names(config, explicit, env);
292 match missing.len() {
293 0 => Err(ConfigValueError::Other { description }),
294 1 => Err(ConfigValueError::EnvVar {
295 description,
296 name: missing.remove(0),
297 }),
298 _ => Err(ConfigValueError::EnvVars {
299 description,
300 names: missing,
301 }),
302 }
303}
304
305#[must_use]
308pub fn resolve_headers(
309 headers: Option<&IndexMap<String, String>>,
310 explicit: Option<&ExplicitEnv>,
311 env: &dyn EnvSource,
312) -> Option<IndexMap<String, String>> {
313 let resolved: IndexMap<String, String> = headers?
314 .iter()
315 .filter_map(|(key, value)| {
316 resolve_config_value(value, explicit, env)
317 .filter(|v| !v.is_empty())
318 .map(|v| (key.clone(), v))
319 })
320 .collect();
321 (!resolved.is_empty()).then_some(resolved)
322}
323
324pub fn resolve_headers_strict(
331 headers: Option<&IndexMap<String, String>>,
332 description: &str,
333 explicit: Option<&ExplicitEnv>,
334 env: &dyn EnvSource,
335) -> Result<Option<IndexMap<String, String>>, ConfigValueError> {
336 let Some(headers) = headers else {
337 return Ok(None);
338 };
339 let mut resolved = IndexMap::new();
340 for (key, value) in headers {
341 let header_description = format!("{description} header \"{key}\"");
342 resolved.insert(
343 key.clone(),
344 resolve_config_value_strict(value, &header_description, explicit, env)?,
345 );
346 }
347 Ok((!resolved.is_empty()).then_some(resolved))
348}
349
350pub fn clear_config_value_cache() {
352 COMMAND_CACHE
353 .lock()
354 .unwrap_or_else(PoisonError::into_inner)
355 .clear();
356}
357
358fn execute_cached(command_config: &str) -> Option<String> {
359 if let Some(cached) = COMMAND_CACHE
360 .lock()
361 .unwrap_or_else(PoisonError::into_inner)
362 .get(command_config)
363 {
364 return cached.clone();
365 }
366 let result = execute_command(&command_config[1..]);
367 COMMAND_CACHE
368 .lock()
369 .unwrap_or_else(PoisonError::into_inner)
370 .insert(command_config.to_owned(), result.clone());
371 result
372}
373
374fn shell_command(command: &str) -> Command {
375 if cfg!(windows) {
376 let mut cmd = Command::new("cmd");
377 cmd.arg("/C").arg(command);
378 cmd
379 } else {
380 let mut cmd = Command::new("/bin/sh");
381 cmd.arg("-c").arg(command);
382 cmd
383 }
384}
385
386fn execute_command(command: &str) -> Option<String> {
389 let mut child = shell_command(command)
390 .stdin(Stdio::null())
391 .stdout(Stdio::piped())
392 .stderr(Stdio::null())
393 .spawn()
394 .ok()?;
395 let mut stdout = child.stdout.take()?;
396 let reader = std::thread::spawn(move || {
397 let mut buffer = Vec::new();
398 stdout.read_to_end(&mut buffer).map(|_| buffer)
399 });
400 let deadline = Instant::now() + COMMAND_TIMEOUT;
401 let status = loop {
402 match child.try_wait() {
403 Ok(Some(status)) => break Some(status),
404 Ok(None) if Instant::now() < deadline => {
405 std::thread::sleep(Duration::from_millis(5));
406 }
407 Ok(None) | Err(_) => {
408 let _ = child.kill();
411 let _ = child.wait();
412 break None;
413 }
414 }
415 };
416 let output = reader.join().ok()?.ok()?;
417 if !status?.success() {
418 return None;
419 }
420 let text = String::from_utf8_lossy(&output).trim().to_owned();
421 (!text.is_empty()).then_some(text)
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use crate::env::MapEnv;
428
429 fn env() -> MapEnv {
430 MapEnv::new()
431 .with("FOO", "foo")
432 .with("BAR", "bar")
433 .with("EMPTY", "")
434 }
435
436 #[test]
437 fn templates() {
438 let env = env();
439 let r = |c: &str| resolve_config_value(c, None, &env);
440 assert_eq!(r("literal"), Some("literal".into()));
441 assert_eq!(r("$FOO"), Some("foo".into()));
442 assert_eq!(r("${FOO}-$BAR!"), Some("foo-bar!".into()));
443 assert_eq!(r("$$FOO"), Some("$FOO".into()));
444 assert_eq!(r("$!x"), Some("!x".into()));
445 assert_eq!(r("$MISSING"), None);
446 assert_eq!(r("$EMPTY"), None);
447 assert_eq!(r("${not valid}"), Some("${not valid}".into()));
448 assert_eq!(r("${FOO"), Some("${FOO".into()));
449 assert_eq!(r("a$"), Some("a$".into()));
450 assert_eq!(r("$1"), Some("$1".into()));
451 assert_eq!(r(""), Some(String::new()));
452 }
453
454 #[test]
455 fn explicit_env_wins() {
456 let env = env();
457 let explicit: ExplicitEnv = [("FOO".to_owned(), "override".to_owned())].into();
458 assert_eq!(
459 resolve_config_value("$FOO", Some(&explicit), &env),
460 Some("override".into())
461 );
462 }
463
464 #[test]
465 fn env_names() {
466 assert_eq!(config_value_env_var_name("$FOO"), Some("FOO".into()));
467 assert_eq!(config_value_env_var_name("${FOO}"), Some("FOO".into()));
468 assert_eq!(config_value_env_var_name("x$FOO"), None);
469 assert_eq!(
470 config_value_env_var_names("$A-${B}-$A"),
471 vec!["A".to_owned(), "B".to_owned()]
472 );
473 assert_eq!(config_value_env_var_names("!echo $A"), Vec::<String>::new());
474 }
475
476 #[test]
477 fn strict_errors() {
478 let env = env();
479 let err = resolve_config_value_strict("$A$B", "API key", None, &env).unwrap_err();
480 assert_eq!(
481 err.to_string(),
482 "Failed to resolve API key from environment variables: A, B"
483 );
484 let err = resolve_config_value_strict("${A}", "API key", None, &env).unwrap_err();
485 assert_eq!(
486 err.to_string(),
487 "Failed to resolve API key from environment variable: A"
488 );
489 }
490
491 #[cfg(unix)]
492 #[test]
493 fn commands() {
494 let env = env();
495 assert_eq!(
496 resolve_config_value_uncached("!printf ' hi \\n'", None, &env),
497 Some("hi".into())
498 );
499 assert_eq!(resolve_config_value_uncached("!exit 3", None, &env), None);
500 assert_eq!(resolve_config_value_uncached("!true", None, &env), None);
501 let err = resolve_config_value_strict("!false", "API key", None, &env).unwrap_err();
502 assert_eq!(
503 err.to_string(),
504 "Failed to resolve API key from shell command: false"
505 );
506 }
507}