Skip to main content

mermaid_model/utils/
auth.rs

1//! Provider API-key resolution.
2//!
3//! Mermaid's auth surface is uniform across providers: an API key lives in
4//! an environment variable, with the option to override the variable name
5//! per-provider in `config.toml`, and — since `mermaid login` — an optional
6//! OS-keyring fallback. Precedence is strict: env vars are ABSOLUTE; the
7//! keyring only fills the gap when no env var resolves. A per-provider
8//! `api_key_env` override is AUTHORITATIVE: when set, neither the default
9//! env nor the keyring is consulted (fail loudly, as before). There's no
10//! in-config secret storage — keys never sit in config.toml in plaintext.
11
12/// Resolve an API key from the environment.
13///
14/// `default_env` is the env var the built-in registry expects (e.g.
15/// `"GROQ_API_KEY"`). `override_env` is an optional per-provider
16/// override from `config.toml`'s `[providers.<name>] api_key_env = ...`.
17/// When set, it takes precedence — a user who's already standardized on
18/// `LLM_API_KEY` for everything can point all their providers at it.
19///
20/// Empty values are treated as unset (matches the existing
21/// `get_cloud_api_key` semantics).
22#[must_use]
23pub fn resolve_api_key(default_env: &str, override_env: Option<&str>) -> Option<String> {
24    let env_var = override_env.unwrap_or(default_env);
25    match std::env::var(env_var) {
26        Ok(key) if !key.is_empty() => Some(key),
27        _ => None,
28    }
29}
30
31/// Resolve a provider's API key: env (default or authoritative override)
32/// first, then the OS keyring (`mermaid login <provider>`).
33#[must_use]
34pub fn resolve_provider_key(
35    provider: &str,
36    default_env: &str,
37    override_env: Option<&str>,
38) -> Option<String> {
39    resolve_provider_key_in(
40        super::credentials::default_store(),
41        provider,
42        default_env,
43        override_env,
44    )
45}
46
47/// [`resolve_provider_key`] against an explicit store (test seam).
48pub(crate) fn resolve_provider_key_in(
49    store: &dyn super::credentials::CredentialStore,
50    provider: &str,
51    default_env: &str,
52    override_env: Option<&str>,
53) -> Option<String> {
54    if override_env.is_some() {
55        // The user pointed at a specific env var; a stored keyring secret
56        // must not silently override that decision (mirrors the existing
57        // fail-loudly rule for unset overrides).
58        return resolve_api_key(default_env, override_env);
59    }
60    resolve_api_key(default_env, None).or_else(|| store.get(provider))
61}
62
63/// [`resolve_provider_key`] for the one legacy-fallback-env case (Gemini).
64#[must_use]
65pub fn resolve_provider_key_with_fallback(
66    provider: &str,
67    default_env: &str,
68    fallback_env: &str,
69    override_env: Option<&str>,
70) -> Option<String> {
71    if override_env.is_some() {
72        return resolve_api_key(default_env, override_env);
73    }
74    resolve_api_key_with_fallback(default_env, fallback_env, None)
75        .or_else(|| super::credentials::default_store().get(provider))
76}
77
78/// Where a provider's key would come from right now: `"env"`, `"keyring"`,
79/// or `"none"`. Drives `doctor` / `mermaid login` / feedback reporting.
80#[must_use]
81pub fn provider_key_source(
82    provider: &str,
83    default_env: &str,
84    override_env: Option<&str>,
85) -> &'static str {
86    if resolve_api_key(default_env, override_env).is_some() {
87        return "env";
88    }
89    if override_env.is_none() && super::credentials::default_store().get(provider).is_some() {
90        return "keyring";
91    }
92    "none"
93}
94
95/// Resolve an API key with a legacy fallback env var.
96///
97/// If `override_env` is set, it remains authoritative and no fallback
98/// is attempted. Without an override, `default_env` is checked first
99/// and `fallback_env` is accepted only when the default is unset.
100#[must_use]
101pub fn resolve_api_key_with_fallback(
102    default_env: &str,
103    fallback_env: &str,
104    override_env: Option<&str>,
105) -> Option<String> {
106    if override_env.is_some() {
107        return resolve_api_key(default_env, override_env);
108    }
109    resolve_api_key(default_env, None).or_else(|| resolve_api_key(fallback_env, None))
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    /// Generate a unique env var name per test so concurrent test runs
117    /// don't step on each other's process-global env state. `temp_env`
118    /// restores the prior value after the closure, but a collision with
119    /// a concurrent test from another module on a shared name would
120    /// still race — unique names are belt-and-braces.
121    fn unique_env(prefix: &str) -> String {
122        use std::sync::atomic::{AtomicUsize, Ordering};
123        static N: AtomicUsize = AtomicUsize::new(0);
124        format!(
125            "{}_{}_{}",
126            prefix,
127            std::process::id(),
128            N.fetch_add(1, Ordering::SeqCst)
129        )
130    }
131
132    #[test]
133    fn provider_key_env_beats_keyring() {
134        use crate::utils::credentials::test_support::FakeStore;
135        let store = FakeStore::default();
136        store
137            .entries
138            .lock()
139            .unwrap()
140            .insert("groq".to_string(), "stored-key".to_string());
141        let var = unique_env("MERMAID_TEST_PK_ENV");
142        temp_env::with_var(&var, Some("env-key"), || {
143            assert_eq!(
144                resolve_provider_key_in(&store, "groq", &var, None),
145                Some("env-key".to_string()),
146                "env must have absolute precedence"
147            );
148        });
149        temp_env::with_var_unset(&var, || {
150            assert_eq!(
151                resolve_provider_key_in(&store, "groq", &var, None),
152                Some("stored-key".to_string()),
153                "keyring fills the gap when env is unset"
154            );
155        });
156    }
157
158    #[test]
159    fn override_env_blocks_keyring_fallback() {
160        use crate::utils::credentials::test_support::FakeStore;
161        let store = FakeStore::default();
162        store
163            .entries
164            .lock()
165            .unwrap()
166            .insert("groq".to_string(), "stored-key".to_string());
167        let default_var = unique_env("MERMAID_TEST_PK_DEFAULT");
168        let override_var = unique_env("MERMAID_TEST_PK_OVERRIDE");
169        // Override set but its var unset: fail loudly — no default env, no
170        // keyring (the user explicitly redirected auth).
171        temp_env::with_vars(
172            [
173                (default_var.as_str(), Some("default-key")),
174                (override_var.as_str(), None),
175            ],
176            || {
177                assert_eq!(
178                    resolve_provider_key_in(&store, "groq", &default_var, Some(&override_var)),
179                    None
180                );
181            },
182        );
183    }
184
185    #[test]
186    fn returns_none_when_env_var_unset() {
187        let var = unique_env("MERMAID_TEST_AUTH_UNSET");
188        temp_env::with_var_unset(&var, || {
189            assert_eq!(resolve_api_key(&var, None), None);
190        });
191    }
192
193    #[test]
194    fn returns_value_when_env_var_set() {
195        let var = unique_env("MERMAID_TEST_AUTH_SET");
196        temp_env::with_var(&var, Some("secret-value"), || {
197            assert_eq!(
198                resolve_api_key(&var, None),
199                Some("secret-value".to_string())
200            );
201        });
202    }
203
204    #[test]
205    fn empty_string_treated_as_unset() {
206        let var = unique_env("MERMAID_TEST_AUTH_EMPTY");
207        temp_env::with_var(&var, Some(""), || {
208            assert_eq!(resolve_api_key(&var, None), None);
209        });
210    }
211
212    #[test]
213    fn override_env_takes_precedence_over_default() {
214        let default_var = unique_env("MERMAID_TEST_AUTH_DEFAULT");
215        let override_var = unique_env("MERMAID_TEST_AUTH_OVERRIDE");
216        temp_env::with_vars(
217            [
218                (default_var.as_str(), Some("default-key")),
219                (override_var.as_str(), Some("override-key")),
220            ],
221            || {
222                let resolved = resolve_api_key(&default_var, Some(&override_var));
223                assert_eq!(resolved, Some("override-key".to_string()));
224            },
225        );
226    }
227
228    #[test]
229    fn override_env_unset_falls_through_to_none() {
230        // When the override env name is provided but unset, we DON'T fall
231        // back to the default — the user explicitly asked for a different
232        // var and got nothing. Better to fail loudly than silently use a
233        // key the user thought they'd disabled.
234        let default_var = unique_env("MERMAID_TEST_AUTH_DEFAULT2");
235        let override_var = unique_env("MERMAID_TEST_AUTH_OVERRIDE2");
236        temp_env::with_vars(
237            [
238                (default_var.as_str(), Some("default-key")),
239                (override_var.as_str(), None),
240            ],
241            || {
242                let resolved = resolve_api_key(&default_var, Some(&override_var));
243                assert_eq!(resolved, None);
244            },
245        );
246    }
247
248    #[test]
249    fn fallback_env_used_only_when_default_is_unset() {
250        let default_var = unique_env("MERMAID_TEST_AUTH_FALLBACK_DEFAULT");
251        let fallback_var = unique_env("MERMAID_TEST_AUTH_FALLBACK_LEGACY");
252        temp_env::with_vars(
253            [
254                (default_var.as_str(), None),
255                (fallback_var.as_str(), Some("legacy-key")),
256            ],
257            || {
258                let resolved = resolve_api_key_with_fallback(&default_var, &fallback_var, None);
259                assert_eq!(resolved, Some("legacy-key".to_string()));
260            },
261        );
262
263        temp_env::with_vars(
264            [
265                (default_var.as_str(), Some("default-key")),
266                (fallback_var.as_str(), Some("legacy-key")),
267            ],
268            || {
269                let resolved = resolve_api_key_with_fallback(&default_var, &fallback_var, None);
270                assert_eq!(resolved, Some("default-key".to_string()));
271            },
272        );
273    }
274
275    #[test]
276    fn fallback_env_is_ignored_when_override_is_set() {
277        let default_var = unique_env("MERMAID_TEST_AUTH_OVERRIDE_DEFAULT3");
278        let fallback_var = unique_env("MERMAID_TEST_AUTH_OVERRIDE_FALLBACK3");
279        let override_var = unique_env("MERMAID_TEST_AUTH_OVERRIDE3");
280        temp_env::with_vars(
281            [
282                (default_var.as_str(), Some("default-key")),
283                (fallback_var.as_str(), Some("legacy-key")),
284                (override_var.as_str(), None),
285            ],
286            || {
287                let resolved =
288                    resolve_api_key_with_fallback(&default_var, &fallback_var, Some(&override_var));
289                assert_eq!(resolved, None);
290            },
291        );
292    }
293}