Skip to main content

oino_models/
config_value.rs

1//! Config-value resolution: API keys and header values that are literals, `$ENV` /
2//! `${ENV}` templates, or `!command` output.
3//!
4//! - A value starting with `!` runs the rest as a shell command (`sh -c`, 10 s timeout) and uses
5//!   its trimmed stdout; empty output, a timeout or a non-zero exit leaves it unresolved.
6//!   [`resolve_config_value`] caches command results for the process lifetime (auth.json keys);
7//!   [`resolve_config_value_uncached`] runs every time (models.json values).
8//! - Otherwise `$NAME` / `${NAME}` interpolate environment variables; `$$` and `$!` escape a
9//!   literal `$` / `!`. A template with an unset variable is unresolved.
10
11use 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
21/// Explicit variables consulted before the environment (a stored credential's `env`).
22pub type ExplicitEnv = IndexMap<String, String>;
23
24/// Timeout for `!command` values (default: 10 000 ms).
25const 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    /// Full config text including the leading `!`.
39    Command(String),
40    Template(Vec<TemplatePart>),
41}
42
43/// Failure to resolve a required config value.
44#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45pub enum ConfigValueError {
46    /// The `!command` produced no value.
47    #[error("Failed to resolve {description} from shell command: {command}")]
48    Command {
49        /// What was being resolved, e.g. `API key for provider "x"`.
50        description: String,
51        /// Command text without the `!`.
52        command: String,
53    },
54    /// One template variable is unset.
55    #[error("Failed to resolve {description} from environment variable: {name}")]
56    EnvVar {
57        /// What was being resolved.
58        description: String,
59        /// The unset variable.
60        name: String,
61    },
62    /// Several template variables are unset.
63    #[error("Failed to resolve {description} from environment variables: {}", names.join(", "))]
64    EnvVars {
65        /// What was being resolved.
66        description: String,
67        /// The unset variables, in first-use order.
68        names: Vec<String>,
69    },
70    /// Unresolved for another reason.
71    #[error("Failed to resolve {description}")]
72    Other {
73        /// What was being resolved.
74        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/// The variable when the value is exactly one `$NAME` reference.
195#[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/// Every distinct variable a template references.
207#[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/// Returns the missing config value env var names.
216#[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/// Returns whether it is command config value.
229#[must_use]
230pub fn is_command_config_value(config: &str) -> bool {
231    config.starts_with('!')
232}
233
234/// Every referenced variable is set (commands count as configured).
235#[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/// Resolves a value; `!command` results are cached per process.
245#[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/// Like [`resolve_config_value`] but runs commands every time.
258#[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
270/// Resolves a config value without caching.
271///
272/// # Errors
273///
274/// [`ConfigValueError`] naming the command or the unset variables.
275pub 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/// Resolves header values (cached commands); unresolved or empty values
306/// are dropped and an empty result is `None`.
307#[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
324/// Every header must resolve; `description` is extended with
325/// ` header "<name>"`.
326///
327/// # Errors
328///
329/// The first header that fails to resolve.
330pub 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
350/// Forgets cached `!command` results.
351pub 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
386/// Runs `command` through the default shell with a 10 s timeout; trimmed non-empty stdout of a
387/// successful run, else `None`.
388fn 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                // Timeout or wait failure: kill and report unresolved. Errors here mean the
409                // child already exited, so there is nothing else to clean up.
410                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}