Skip to main content

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