Skip to main content

codewhale_secrets/
lib.rs

1//! Secret storage for CodeWhale API keys.
2//!
3//! Provides a small abstraction (`KeyringStore`) plus a default
4//! file-based implementation (`FileKeyringStore`), an opt-in OS keyring
5//! implementation (`DefaultKeyringStore`), and an in-memory store for tests
6//! (`InMemoryKeyringStore`).
7//!
8//! Higher-level lookup through [`Secrets::resolve`] checks the secret store first
9//! and falls back to environment variables. Config-file precedence lives in the
10//! config crate so user-facing commands can keep `config -> secret store -> env`
11//! explicit at the call site.
12#![deny(missing_docs)]
13
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22/// Default OS keychain service name. Kept as `deepseek` for compatibility
23/// with credentials saved before the CodeWhale rename. macOS users can verify
24/// entries with `security find-generic-password -s deepseek -a <provider>`.
25pub const DEFAULT_SERVICE: &str = "deepseek";
26/// Select the secret storage backend. Supported values are `file` (default)
27/// and `system`/`keyring` for the OS credential store.
28pub const SECRET_BACKEND_ENV: &str = "CODEWHALE_SECRET_BACKEND";
29/// Legacy alias for [`SECRET_BACKEND_ENV`].
30pub const LEGACY_SECRET_BACKEND_ENV: &str = "DEEPSEEK_SECRET_BACKEND";
31const FILE_BACKEND_LABEL: &str = "file-based (~/.codewhale/secrets/)";
32
33/// Errors that may arise from a [`KeyringStore`] backend.
34#[derive(Debug, Error)]
35pub enum SecretsError {
36    /// Underlying OS keyring backend reported an error.
37    #[error("keyring backend error: {0}")]
38    Keyring(String),
39    /// File-backed fallback I/O error.
40    #[error("file-backed secret store I/O error: {0}")]
41    Io(#[from] std::io::Error),
42    /// File-backed fallback JSON (de)serialisation error.
43    #[error("file-backed secret store JSON error: {0}")]
44    Json(#[from] serde_json::Error),
45    /// Caught when a stored secret on disk has unsafe permissions.
46    #[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
47    InsecurePermissions {
48        /// Absolute path to the secrets file.
49        path: PathBuf,
50        /// Observed unix permission mode.
51        mode: u32,
52    },
53}
54
55/// Abstract secret store trait.
56///
57/// Concrete implementations may use the OS keyring ([`DefaultKeyringStore`]),
58/// a JSON file under `~/.codewhale/secrets/` ([`FileKeyringStore`]), or an
59/// in-memory map for tests ([`InMemoryKeyringStore`]).
60///
61/// All implementations must be [`Send`] + [`Sync`] so they can be shared
62/// across threads via [`Arc`].
63pub trait KeyringStore: Send + Sync {
64    /// Read a secret by key.
65    ///
66    /// Returns `Ok(None)` if no entry exists for the given key. Returns
67    /// `Err` only on backend failures (I/O errors, keyring access issues).
68    fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
69
70    /// Write a secret, replacing any existing value for the same key.
71    ///
72    /// Creates the backing store (e.g. the JSON file) on first write if
73    /// it does not yet exist.
74    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
75
76    /// Remove a secret by key.
77    ///
78    /// Implementations should succeed (no-op) if the entry is already absent
79    /// rather than returning an error.
80    fn delete(&self, key: &str) -> Result<(), SecretsError>;
81
82    /// Short, human-readable label for this backend.
83    ///
84    /// Used by diagnostic output (e.g. `doctor` command) to indicate which
85    /// storage backend is active. Examples: `"file-based (~/.codewhale/secrets/)"`,
86    /// `"system keyring"`, `"in-memory (test)"`.
87    fn backend_name(&self) -> &'static str;
88}
89
90/// OS-native keyring backend.
91///
92/// Wraps the platform credential store:
93/// - **macOS**: Keychain (via `security` framework)
94/// - **Windows**: Credential Manager
95/// - **Linux**: Secret Service (GNOME Keyring / kwallet via dbus)
96///
97/// This backend is opt-in -- set the [`SECRET_BACKEND_ENV`] environment
98/// variable to `system` or `keyring` to activate it. On platforms without
99/// a configured native keyring dependency, [`probe`](DefaultKeyringStore::probe)
100/// returns an unsupported error so [`Secrets::auto_detect`] can transparently
101/// fall back to [`FileKeyringStore`].
102#[derive(Debug, Clone)]
103pub struct DefaultKeyringStore {
104    /// Keyring service name used to namespace stored credentials.
105    /// Defaults to [`DEFAULT_SERVICE`].
106    service: String,
107}
108
109impl Default for DefaultKeyringStore {
110    fn default() -> Self {
111        Self::new(DEFAULT_SERVICE)
112    }
113}
114
115impl DefaultKeyringStore {
116    /// Build a new store with the given service name.
117    #[must_use]
118    pub fn new(service: impl Into<String>) -> Self {
119        Self {
120            service: service.into(),
121        }
122    }
123
124    /// Probe the OS keyring without writing anything. Returns `Ok(())` if
125    /// a backend is reachable, otherwise an error describing why not.
126    pub fn probe(&self) -> Result<(), SecretsError> {
127        #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
128        {
129            // `Entry::new` is enough to validate the native macOS/Windows
130            // backend path. Avoid a dummy read there because it can trigger
131            // a second user-visible Keychain/Credential Manager access before
132            // the real provider key lookup.
133            let entry = keyring::Entry::new(&self.service, "__probe__")
134                .map_err(|err| SecretsError::Keyring(err.to_string()))?;
135            #[cfg(any(target_os = "macos", target_os = "windows"))]
136            {
137                let _ = entry;
138                Ok(())
139            }
140            #[cfg(not(any(target_os = "macos", target_os = "windows")))]
141            match entry.get_password() {
142                Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
143                Err(keyring::Error::PlatformFailure(err)) => {
144                    Err(SecretsError::Keyring(format!("platform failure: {err}")))
145                }
146                Err(keyring::Error::NoStorageAccess(err)) => {
147                    Err(SecretsError::Keyring(format!("no storage access: {err}")))
148                }
149                Err(other) => Err(SecretsError::Keyring(other.to_string())),
150            }
151        }
152        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
153        {
154            let _ = &self.service;
155            Err(SecretsError::Keyring(unsupported_keyring_message()))
156        }
157    }
158}
159
160impl KeyringStore for DefaultKeyringStore {
161    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
162        #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
163        {
164            let entry = keyring::Entry::new(&self.service, key)
165                .map_err(|err| SecretsError::Keyring(err.to_string()))?;
166            match entry.get_password() {
167                Ok(value) => Ok(Some(value)),
168                Err(keyring::Error::NoEntry) => Ok(None),
169                Err(err) => Err(SecretsError::Keyring(err.to_string())),
170            }
171        }
172        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
173        {
174            let _ = key;
175            Err(SecretsError::Keyring(unsupported_keyring_message()))
176        }
177    }
178
179    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
180        #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
181        {
182            let entry = keyring::Entry::new(&self.service, key)
183                .map_err(|err| SecretsError::Keyring(err.to_string()))?;
184            entry
185                .set_password(value)
186                .map_err(|err| SecretsError::Keyring(err.to_string()))
187        }
188        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
189        {
190            let _ = (key, value);
191            Err(SecretsError::Keyring(unsupported_keyring_message()))
192        }
193    }
194
195    fn delete(&self, key: &str) -> Result<(), SecretsError> {
196        #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
197        {
198            let entry = keyring::Entry::new(&self.service, key)
199                .map_err(|err| SecretsError::Keyring(err.to_string()))?;
200            match entry.delete_credential() {
201                Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
202                Err(err) => Err(SecretsError::Keyring(err.to_string())),
203            }
204        }
205        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
206        {
207            let _ = key;
208            Err(SecretsError::Keyring(unsupported_keyring_message()))
209        }
210    }
211
212    fn backend_name(&self) -> &'static str {
213        "system keyring"
214    }
215}
216
217#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
218fn unsupported_keyring_message() -> String {
219    "system keyring backend is unsupported on this platform".to_string()
220}
221
222/// In-memory keyring store for tests.
223///
224/// Stores secrets in a [`HashMap`] protected by a [`Mutex`]. Not persisted
225/// to disk -- all entries are lost when the process exits. This is the
226/// preferred store for unit tests because it requires no filesystem setup
227/// and is safe to use in parallel test threads.
228#[derive(Debug, Default)]
229pub struct InMemoryKeyringStore {
230    /// Thread-safe map of key-value pairs.
231    entries: Mutex<HashMap<String, String>>,
232}
233
234impl InMemoryKeyringStore {
235    /// Create an empty store.
236    #[must_use]
237    pub fn new() -> Self {
238        Self::default()
239    }
240}
241
242impl KeyringStore for InMemoryKeyringStore {
243    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
244        let guard = self.entries.lock().map_err(|e| {
245            SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
246        })?;
247        Ok(guard.get(key).cloned())
248    }
249
250    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
251        let mut guard = self.entries.lock().map_err(|e| {
252            SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
253        })?;
254        guard.insert(key.to_string(), value.to_string());
255        Ok(())
256    }
257
258    fn delete(&self, key: &str) -> Result<(), SecretsError> {
259        let mut guard = self.entries.lock().map_err(|e| {
260            SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
261        })?;
262        guard.remove(key);
263        Ok(())
264    }
265
266    fn backend_name(&self) -> &'static str {
267        "in-memory (test)"
268    }
269}
270
271/// JSON-on-disk secret store for headless environments.
272///
273/// This is the default backend. Secrets are serialised as a JSON object
274/// at `<home>/.codewhale/secrets/secrets.json` with Unix file mode `0600`
275/// (owner read/write only). The parent directory is created with mode `0700`
276/// if it does not exist.
277///
278/// On Unix, the store rejects files whose permissions are more permissive
279/// than `0600` (i.e. group or world bits are set). This prevents other
280/// users on the system from reading stored credentials. On Windows, the
281/// ACL model is too different to enforce programmatically; callers are
282/// responsible for placing the file in a per-user directory.
283#[derive(Debug, Clone)]
284pub struct FileKeyringStore {
285    /// Absolute path to the JSON secrets file.
286    path: PathBuf,
287}
288
289#[derive(Debug, Default, Serialize, Deserialize)]
290struct FileSecretsBlob {
291    #[serde(default)]
292    entries: HashMap<String, String>,
293}
294
295impl FileKeyringStore {
296    /// Build a store backed by the given JSON file path.
297    #[must_use]
298    pub fn new(path: impl Into<PathBuf>) -> Self {
299        Self { path: path.into() }
300    }
301
302    /// Default path: `<home>/.codewhale/secrets/secrets.json`. Honours
303    /// `CODEWHALE_HOME`, then `HOME`, `USERPROFILE`, and finally the platform
304    /// home directory from the `dirs` crate. On first use, non-conflicting
305    /// entries from the legacy `<home>/.deepseek/secrets/secrets.json` file are
306    /// copied into the CodeWhale store.
307    pub fn default_path() -> Result<PathBuf, SecretsError> {
308        let primary = default_codewhale_secrets_path()?;
309        let legacy = legacy_deepseek_secrets_path()?;
310        if let Err(err) = Self::migrate_legacy_file_if_needed(&primary, &legacy) {
311            tracing::warn!(
312                "could not migrate legacy secret store from {} to {}: {err}",
313                legacy.display(),
314                primary.display()
315            );
316        }
317        Ok(primary)
318    }
319
320    fn migrate_legacy_file_if_needed(primary: &Path, legacy: &Path) -> Result<(), SecretsError> {
321        if !legacy.exists() {
322            return Ok(());
323        }
324
325        let legacy_store = Self::new(legacy.to_path_buf());
326        let legacy_blob = legacy_store.load_unlocked()?;
327        if legacy_blob.entries.is_empty() {
328            return Ok(());
329        }
330
331        let primary_store = Self::new(primary.to_path_buf());
332        let mut primary_blob = primary_store.load_unlocked()?;
333        let mut changed = false;
334        for (key, value) in legacy_blob.entries {
335            if let std::collections::hash_map::Entry::Vacant(entry) =
336                primary_blob.entries.entry(key)
337            {
338                entry.insert(value);
339                changed = true;
340            }
341        }
342        if changed {
343            primary_store.store_unlocked(&primary_blob)?;
344        }
345        Ok(())
346    }
347
348    fn home_dir() -> Result<PathBuf, SecretsError> {
349        for var in ["HOME", "USERPROFILE"] {
350            if let Ok(value) = std::env::var(var) {
351                let trimmed = value.trim();
352                if !trimmed.is_empty() {
353                    return Ok(PathBuf::from(trimmed));
354                }
355            }
356        }
357
358        dirs::home_dir().ok_or_else(|| {
359            SecretsError::Io(std::io::Error::new(
360                std::io::ErrorKind::NotFound,
361                "could not resolve home directory for FileKeyringStore",
362            ))
363        })
364    }
365
366    /// Path used for storage.
367    #[must_use]
368    pub fn path(&self) -> &Path {
369        &self.path
370    }
371
372    fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
373        if !self.path.exists() {
374            return Ok(FileSecretsBlob::default());
375        }
376        // Reject files with unsafe permissions on unix. On Windows the
377        // ACL model is too different to enforce here; the caller is
378        // responsible for placing the file in a per-user directory.
379        #[cfg(unix)]
380        {
381            use std::os::unix::fs::PermissionsExt;
382            let meta = fs::metadata(&self.path)?;
383            let mode = meta.permissions().mode() & 0o777;
384            if mode & 0o077 != 0 {
385                return Err(SecretsError::InsecurePermissions {
386                    path: self.path.clone(),
387                    mode,
388                });
389            }
390        }
391        let raw = fs::read_to_string(&self.path)?;
392        if raw.trim().is_empty() {
393            return Ok(FileSecretsBlob::default());
394        }
395        let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
396        Ok(blob)
397    }
398
399    fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
400        if let Some(parent) = self.path.parent() {
401            fs::create_dir_all(parent)?;
402            #[cfg(unix)]
403            {
404                use std::os::unix::fs::PermissionsExt;
405                let mut perms = fs::metadata(parent)?.permissions();
406                perms.set_mode(0o700);
407                let _ = fs::set_permissions(parent, perms);
408            }
409        }
410        let body = serde_json::to_string_pretty(blob)?;
411        fs::write(&self.path, body)?;
412        #[cfg(unix)]
413        {
414            use std::os::unix::fs::PermissionsExt;
415            // Best-effort 0o600 — matches the parent-dir chmod above which
416            // is also `let _ = ...`. Filesystems that don't support Unix
417            // chmod (Docker bind-mounts of NTFS, network shares — #897)
418            // would otherwise fail the whole save here even though the
419            // blob already wrote successfully. The host's native ACLs
420            // are doing access control in those environments.
421            if let Ok(meta) = fs::metadata(&self.path) {
422                let mut perms = meta.permissions();
423                perms.set_mode(0o600);
424                let _ = fs::set_permissions(&self.path, perms);
425            }
426        }
427        Ok(())
428    }
429}
430
431impl KeyringStore for FileKeyringStore {
432    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
433        let blob = self.load_unlocked()?;
434        Ok(blob.entries.get(key).cloned())
435    }
436
437    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
438        // load_unlocked already returns Ok(default) for a missing file, so the
439        // first-write-creates-the-file path is preserved. Any other Err
440        // (insecure permissions, corrupt JSON, transient I/O) MUST surface to
441        // the caller — propagating it via `unwrap_or_default()` silently
442        // wipes every previously stored secret on the next `store_unlocked`.
443        let mut blob = self.load_unlocked()?;
444        blob.entries.insert(key.to_string(), value.to_string());
445        self.store_unlocked(&blob)
446    }
447
448    fn delete(&self, key: &str) -> Result<(), SecretsError> {
449        // Same invariant as `set`: never fall back to an empty blob on read
450        // error, or `delete <one-key>` becomes `delete <every-key>`.
451        let mut blob = self.load_unlocked()?;
452        blob.entries.remove(key);
453        self.store_unlocked(&blob)
454    }
455
456    fn backend_name(&self) -> &'static str {
457        FILE_BACKEND_LABEL
458    }
459}
460
461fn default_codewhale_secrets_path() -> Result<PathBuf, SecretsError> {
462    if let Ok(value) = std::env::var("CODEWHALE_HOME") {
463        let trimmed = value.trim();
464        if !trimmed.is_empty() {
465            return Ok(PathBuf::from(trimmed).join("secrets").join("secrets.json"));
466        }
467    }
468    Ok(FileKeyringStore::home_dir()?
469        .join(".codewhale")
470        .join("secrets")
471        .join("secrets.json"))
472}
473
474fn legacy_deepseek_secrets_path() -> Result<PathBuf, SecretsError> {
475    Ok(FileKeyringStore::home_dir()?
476        .join(".deepseek")
477        .join("secrets")
478        .join("secrets.json"))
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482enum SecretBackendSelection {
483    File,
484    System,
485    Unknown,
486}
487
488fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
489    match value.map(str::trim).filter(|value| !value.is_empty()) {
490        None => SecretBackendSelection::File,
491        Some(value) => match value.to_ascii_lowercase().as_str() {
492            "file" | "local" | "json" => SecretBackendSelection::File,
493            "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
494            _ => SecretBackendSelection::Unknown,
495        },
496    }
497}
498
499fn configured_secret_backend() -> Option<String> {
500    std::env::var(SECRET_BACKEND_ENV)
501        .ok()
502        .filter(|value| !value.trim().is_empty())
503        .or_else(|| std::env::var(LEGACY_SECRET_BACKEND_ENV).ok())
504}
505
506/// High-level facade combining a [`KeyringStore`] with environment variable fallbacks.
507///
508/// Lookup precedence: **secret store -> env -> none**. Callers that also
509/// have a TOML config layer must wire that themselves at the very end
510/// of the chain (the config crate handles this).
511///
512/// # Examples
513///
514/// ```no_run
515/// use codewhale_secrets::Secrets;
516///
517/// let secrets = Secrets::auto_detect();
518/// if let Some(key) = secrets.resolve("deepseek") {
519///     // use the API key
520/// }
521/// ```
522#[derive(Clone)]
523pub struct Secrets {
524    /// Underlying secret store backend.
525    pub store: Arc<dyn KeyringStore>,
526    /// Owner identifier within the secret store (typically `"deepseek"`).
527    /// The `key` parameter passed to [`resolve`](Secrets::resolve) is
528    /// forwarded to the store as-is, while environment variables are
529    /// looked up by canonical provider name via [`env_for`].
530    service: String,
531}
532
533/// Identifies which layer in the resolution chain supplied a secret.
534///
535/// Returned by [`Secrets::resolve_with_source`] so callers can
536/// distinguish whether a value came from the configured store or from
537/// a process environment variable.
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub enum SecretSource {
540    /// The secret was returned by the configured [`KeyringStore`] backend.
541    Keyring,
542    /// The secret was found in a process environment variable.
543    Env,
544}
545
546impl std::fmt::Debug for Secrets {
547    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548        f.debug_struct("Secrets")
549            .field("backend", &self.store.backend_name())
550            .field("service", &self.service)
551            .finish()
552    }
553}
554
555impl Secrets {
556    /// Build a new facade around the given store, using the
557    /// [`DEFAULT_SERVICE`] service name.
558    #[must_use]
559    pub fn new(store: Arc<dyn KeyringStore>) -> Self {
560        Self {
561            store,
562            service: DEFAULT_SERVICE.to_string(),
563        }
564    }
565
566    /// Auto-detect the best available backend based on the environment.
567    ///
568    /// Selection logic:
569    /// 1. If [`SECRET_BACKEND_ENV`] is set to `system`/`keyring`/`os`/`os-keyring`,
570    ///    probe the OS keyring. If the probe succeeds, use it; otherwise
571    ///    fall back to the file-based store with a warning.
572    /// 2. If the env var is unset, empty, or `file`/`local`/`json`, use
573    ///    the file-based store directly.
574    /// 3. If the env var is set to an unrecognised value, log a warning
575    ///    and use the file-based store.
576    pub fn auto_detect() -> Self {
577        match secret_backend_selection(configured_secret_backend().as_deref()) {
578            SecretBackendSelection::File => Self::file_backed_default(),
579            SecretBackendSelection::Unknown => {
580                tracing::warn!(
581                    "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
582                );
583                Self::file_backed_default()
584            }
585            SecretBackendSelection::System => {
586                let default_store = DefaultKeyringStore::default();
587                match default_store.probe() {
588                    Ok(()) => Self::new(Arc::new(default_store)),
589                    Err(err) => {
590                        tracing::warn!(
591                            "OS keyring unavailable ({err}); falling back to file-backed secret store"
592                        );
593                        Self::file_backed_default()
594                    }
595                }
596            }
597        }
598    }
599
600    fn file_backed_default() -> Self {
601        let path = FileKeyringStore::default_path()
602            .unwrap_or_else(|_| PathBuf::from(".codewhale-secrets.json"));
603        Self::new(Arc::new(FileKeyringStore::new(path)))
604    }
605
606    /// Construct the file-backed default backend directly.
607    #[must_use]
608    pub fn file_backed() -> Self {
609        Self::file_backed_default()
610    }
611
612    /// Construct the opt-in OS credential backend, falling back to the
613    /// file-backed store when the platform backend is unavailable.
614    #[must_use]
615    pub fn system_keyring() -> Self {
616        let default_store = DefaultKeyringStore::default();
617        match default_store.probe() {
618            Ok(()) => Self::new(Arc::new(default_store)),
619            Err(err) => {
620                tracing::warn!(
621                    "OS keyring unavailable ({err}); falling back to file-backed secret store"
622                );
623                Self::file_backed_default()
624            }
625        }
626    }
627
628    /// Backend label, suitable for `doctor` output.
629    #[must_use]
630    pub fn backend_name(&self) -> &'static str {
631        self.store.backend_name()
632    }
633
634    /// Resolve a secret with `secret store → env → none` precedence.
635    ///
636    /// `name` is the canonical provider name or a supported provider alias.
637    /// Empty strings on either layer are treated as "not set".
638    #[must_use]
639    pub fn resolve(&self, name: &str) -> Option<String> {
640        self.resolve_with_source(name).map(|(value, _)| value)
641    }
642
643    /// Resolve a secret and report which layer supplied it.
644    #[must_use]
645    pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
646        if let Ok(Some(v)) = self.store.get(name)
647            && !v.trim().is_empty()
648        {
649            return Some((v, SecretSource::Keyring));
650        }
651        env_for(name).map(|value| (value, SecretSource::Env))
652    }
653
654    /// Convenience: write a secret through the underlying store.
655    pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
656        self.store.set(name, value)
657    }
658
659    /// Convenience: delete a secret through the underlying store.
660    pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
661        self.store.delete(name)
662    }
663
664    /// Convenience: read a secret directly (no env fallback).
665    pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
666        self.store.get(name)
667    }
668}
669
670/// Map a canonical provider name to its environment variable(s), returning
671/// the first non-empty value found.
672///
673/// Provider names are case-insensitive. Supported providers and their
674/// environment variables:
675///
676/// | Provider | Env var(s) |
677/// |---|---|
678/// | `deepseek` | `DEEPSEEK_API_KEY` |
679/// | `openrouter` | `OPENROUTER_API_KEY` |
680/// | `xiaomi-mimo` / `mimo` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |
681/// | `novita` | `NOVITA_API_KEY` |
682/// | `nvidia` / `nvidia-nim` / `nim` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY`, `DEEPSEEK_API_KEY` |
683/// | `fireworks` | `FIREWORKS_API_KEY` |
684/// | `siliconflow` / `siliconflow-cn` | `SILICONFLOW_API_KEY` |
685/// | `arcee` / `arcee-ai` | `ARCEE_API_KEY` |
686/// | `moonshot` / `kimi` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |
687/// | `sglang` | `SGLANG_API_KEY` |
688/// | `vllm` | `VLLM_API_KEY` |
689/// | `ollama` | `OLLAMA_API_KEY` |
690/// | `openai` | `OPENAI_API_KEY` |
691/// | `atlascloud` / `atlas` | `ATLASCLOUD_API_KEY` |
692/// | `volcengine` / `ark` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |
693/// | `wanjie` / `wanjie-ark` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |
694///
695/// Returns `None` if the provider is not recognised or none of its
696/// candidate environment variables are set to a non-empty value.
697#[must_use]
698pub fn env_for(name: &str) -> Option<String> {
699    let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
700        "deepseek" => &["DEEPSEEK_API_KEY"],
701        "openrouter" => &["OPENROUTER_API_KEY"],
702        "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
703            &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
704        }
705        "novita" => &["NOVITA_API_KEY"],
706        // NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
707        // catalog endpoint accepts the same DeepSeek-issued key when no
708        // dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
709        "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
710            &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
711        }
712        "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
713        "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
714        | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
715        "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
716        "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
717        "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
718        "vllm" | "v-llm" => &["VLLM_API_KEY"],
719        "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
720        "openai" => &["OPENAI_API_KEY"],
721        "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
722        "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
723        | "volcengineark" => &[
724            "VOLCENGINE_API_KEY",
725            "VOLCENGINE_ARK_API_KEY",
726            "ARK_API_KEY",
727        ],
728        "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
729        | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
730            "WANJIE_ARK_API_KEY",
731            "WANJIE_API_KEY",
732            "WANJIE_MAAS_API_KEY",
733        ],
734        _ => return None,
735    };
736    for var in candidates {
737        if let Ok(value) = std::env::var(var)
738            && !value.trim().is_empty()
739        {
740            return Some(value);
741        }
742    }
743    None
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749    use std::sync::{Mutex, OnceLock};
750
751    /// Serialise env-mutating tests: tests in this module poke
752    /// `DEEPSEEK_API_KEY` etc., which is process-global.
753    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
754        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
755        LOCK.get_or_init(|| Mutex::new(()))
756            .lock()
757            .unwrap_or_else(|p| p.into_inner())
758    }
759
760    fn clear_known_envs() {
761        for var in [
762            "CODEWHALE_HOME",
763            "DEEPSEEK_API_KEY",
764            "OPENROUTER_API_KEY",
765            "NOVITA_API_KEY",
766            "NVIDIA_API_KEY",
767            "NVIDIA_NIM_API_KEY",
768            "FIREWORKS_API_KEY",
769            "SILICONFLOW_API_KEY",
770            "ARCEE_API_KEY",
771            "SGLANG_API_KEY",
772            "VLLM_API_KEY",
773            "OLLAMA_API_KEY",
774            "OPENAI_API_KEY",
775            "ATLASCLOUD_API_KEY",
776            "WANJIE_ARK_API_KEY",
777            "WANJIE_API_KEY",
778            "WANJIE_MAAS_API_KEY",
779            "XIAOMI_MIMO_API_KEY",
780            "XIAOMI_API_KEY",
781            "MIMO_API_KEY",
782            SECRET_BACKEND_ENV,
783            LEGACY_SECRET_BACKEND_ENV,
784        ] {
785            // Safety: tests serialise on env_lock(); the broader
786            // workspace has the same pattern in `crates/config`.
787            unsafe { std::env::remove_var(var) };
788        }
789    }
790
791    struct EnvVarGuard {
792        name: &'static str,
793        previous: Option<std::ffi::OsString>,
794    }
795
796    impl EnvVarGuard {
797        fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
798            let previous = std::env::var_os(name);
799            unsafe { std::env::set_var(name, value) };
800            Self { name, previous }
801        }
802    }
803
804    impl Drop for EnvVarGuard {
805        fn drop(&mut self) {
806            match self.previous.take() {
807                Some(value) => unsafe { std::env::set_var(self.name, value) },
808                None => unsafe { std::env::remove_var(self.name) },
809            }
810        }
811    }
812
813    #[test]
814    fn backend_selection_defaults_to_file() {
815        assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
816        assert_eq!(
817            secret_backend_selection(Some("")),
818            SecretBackendSelection::File
819        );
820        assert_eq!(
821            secret_backend_selection(Some("  file  ")),
822            SecretBackendSelection::File
823        );
824    }
825
826    #[test]
827    fn backend_selection_accepts_explicit_system_keyring() {
828        assert_eq!(
829            secret_backend_selection(Some("system")),
830            SecretBackendSelection::System
831        );
832        assert_eq!(
833            secret_backend_selection(Some("keyring")),
834            SecretBackendSelection::System
835        );
836        assert_eq!(
837            secret_backend_selection(Some("os-keyring")),
838            SecretBackendSelection::System
839        );
840    }
841
842    #[test]
843    fn auto_detect_is_file_backed_by_default() {
844        let _lock = env_lock();
845        clear_known_envs();
846        let tmp = tempfile::tempdir().unwrap();
847        let _home = EnvVarGuard::set("HOME", tmp.path());
848        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
849
850        let secrets = Secrets::auto_detect();
851
852        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
853    }
854
855    #[test]
856    fn auto_detect_honors_explicit_file_backend() {
857        let _lock = env_lock();
858        clear_known_envs();
859        let tmp = tempfile::tempdir().unwrap();
860        let _home = EnvVarGuard::set("HOME", tmp.path());
861        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
862        // Safety: env mutation guarded by env_lock().
863        unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
864
865        let secrets = Secrets::auto_detect();
866
867        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
868        // Safety: env mutation guarded by env_lock().
869        unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
870    }
871
872    #[test]
873    fn auto_detect_honors_legacy_backend_env_alias() {
874        let _lock = env_lock();
875        clear_known_envs();
876        let tmp = tempfile::tempdir().unwrap();
877        let _home = EnvVarGuard::set("HOME", tmp.path());
878        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
879        unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
880
881        let secrets = Secrets::auto_detect();
882
883        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
884        clear_known_envs();
885    }
886
887    #[test]
888    fn file_default_path_uses_codewhale_home() {
889        let _lock = env_lock();
890        clear_known_envs();
891        let tmp = tempfile::tempdir().unwrap();
892        let _home = EnvVarGuard::set("HOME", tmp.path());
893        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
894
895        let path = FileKeyringStore::default_path().unwrap();
896
897        assert_eq!(
898            path,
899            tmp.path()
900                .join(".codewhale")
901                .join("secrets")
902                .join("secrets.json")
903        );
904    }
905
906    #[test]
907    fn file_default_path_honors_codewhale_home() {
908        let _lock = env_lock();
909        clear_known_envs();
910        let tmp = tempfile::tempdir().unwrap();
911        let custom = tmp.path().join("custom-codewhale");
912        let _home = EnvVarGuard::set("HOME", tmp.path());
913        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
914        let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
915
916        let path = FileKeyringStore::default_path().unwrap();
917
918        assert_eq!(path, custom.join("secrets").join("secrets.json"));
919    }
920
921    #[test]
922    fn file_default_path_migrates_legacy_entries_to_codewhale() {
923        let _lock = env_lock();
924        clear_known_envs();
925        let tmp = tempfile::tempdir().unwrap();
926        let _home = EnvVarGuard::set("HOME", tmp.path());
927        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
928        let legacy = tmp
929            .path()
930            .join(".deepseek")
931            .join("secrets")
932            .join("secrets.json");
933        FileKeyringStore::new(legacy.clone())
934            .set("xiaomi-mimo", "legacy-mimo")
935            .unwrap();
936
937        let primary = FileKeyringStore::default_path().unwrap();
938        let primary_store = FileKeyringStore::new(primary.clone());
939
940        assert_eq!(
941            primary,
942            tmp.path()
943                .join(".codewhale")
944                .join("secrets")
945                .join("secrets.json")
946        );
947        assert_eq!(
948            primary_store.get("xiaomi-mimo").unwrap().as_deref(),
949            Some("legacy-mimo")
950        );
951        assert!(
952            legacy.exists(),
953            "migration copies; it does not delete legacy data"
954        );
955    }
956
957    #[test]
958    fn file_default_path_migration_preserves_primary_values() {
959        let _lock = env_lock();
960        clear_known_envs();
961        let tmp = tempfile::tempdir().unwrap();
962        let _home = EnvVarGuard::set("HOME", tmp.path());
963        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
964        let legacy = tmp
965            .path()
966            .join(".deepseek")
967            .join("secrets")
968            .join("secrets.json");
969        let primary = tmp
970            .path()
971            .join(".codewhale")
972            .join("secrets")
973            .join("secrets.json");
974        FileKeyringStore::new(legacy)
975            .set("openrouter", "legacy-openrouter")
976            .unwrap();
977        let primary_store = FileKeyringStore::new(primary.clone());
978        primary_store
979            .set("openrouter", "primary-openrouter")
980            .unwrap();
981
982        let resolved = FileKeyringStore::default_path().unwrap();
983
984        assert_eq!(resolved, primary);
985        assert_eq!(
986            primary_store.get("openrouter").unwrap().as_deref(),
987            Some("primary-openrouter")
988        );
989    }
990
991    #[test]
992    fn in_memory_store_round_trips() {
993        let store = InMemoryKeyringStore::new();
994        assert_eq!(store.get("deepseek").unwrap(), None);
995        store.set("deepseek", "sk-test").unwrap();
996        assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
997        store.set("deepseek", "sk-replaced").unwrap();
998        assert_eq!(
999            store.get("deepseek").unwrap(),
1000            Some("sk-replaced".to_string())
1001        );
1002        store.delete("deepseek").unwrap();
1003        assert_eq!(store.get("deepseek").unwrap(), None);
1004        // Deleting an absent key is a no-op.
1005        store.delete("missing").unwrap();
1006    }
1007
1008    #[test]
1009    fn resolve_prefers_keyring_over_env() {
1010        let _lock = env_lock();
1011        clear_known_envs();
1012        // Safety: env mutation guarded by env_lock().
1013        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1014
1015        let store = Arc::new(InMemoryKeyringStore::new());
1016        store.set("deepseek", "ring-key").unwrap();
1017        let secrets = Secrets::new(store);
1018
1019        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1020        assert_eq!(
1021            secrets.resolve_with_source("deepseek"),
1022            Some(("ring-key".to_string(), SecretSource::Keyring))
1023        );
1024        // Safety: env mutation guarded by env_lock().
1025        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1026    }
1027
1028    #[test]
1029    fn resolve_falls_back_to_env_when_keyring_empty() {
1030        let _lock = env_lock();
1031        clear_known_envs();
1032        // Safety: env mutation guarded by env_lock().
1033        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1034
1035        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1036        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1037        assert_eq!(
1038            secrets.resolve_with_source("deepseek"),
1039            Some(("env-fallback".to_string(), SecretSource::Env))
1040        );
1041        // Safety: env mutation guarded by env_lock().
1042        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1043    }
1044
1045    #[test]
1046    fn resolve_returns_none_when_both_layers_empty() {
1047        let _lock = env_lock();
1048        clear_known_envs();
1049        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1050        assert_eq!(secrets.resolve("deepseek"), None);
1051    }
1052
1053    #[test]
1054    fn resolve_treats_blank_keyring_value_as_unset() {
1055        let _lock = env_lock();
1056        clear_known_envs();
1057        // Safety: env mutation guarded by env_lock().
1058        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1059
1060        let store = Arc::new(InMemoryKeyringStore::new());
1061        store.set("deepseek", "   ").unwrap();
1062        let secrets = Secrets::new(store);
1063        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1064        // Safety: env mutation guarded by env_lock().
1065        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1066    }
1067
1068    #[test]
1069    fn nvidia_env_aliases_resolve() {
1070        let _lock = env_lock();
1071        clear_known_envs();
1072        // Safety: env mutation guarded by env_lock().
1073        unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1074        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1075        assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1076        assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1077        // Safety: env mutation guarded by env_lock().
1078        unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1079    }
1080
1081    #[test]
1082    fn atlascloud_env_aliases_resolve() {
1083        let _guard = env_lock();
1084        clear_known_envs();
1085        unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1086
1087        assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1088        assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1089        assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1090
1091        clear_known_envs();
1092    }
1093
1094    #[test]
1095    fn wanjie_ark_env_aliases_resolve() {
1096        let _guard = env_lock();
1097        clear_known_envs();
1098        unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1099
1100        assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1101        assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1102        assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1103
1104        clear_known_envs();
1105    }
1106
1107    #[test]
1108    fn xiaomi_mimo_env_aliases_resolve() {
1109        let _guard = env_lock();
1110        clear_known_envs();
1111        unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1112
1113        assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1114        assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1115        assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1116        assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1117
1118        clear_known_envs();
1119
1120        unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1121        assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1122        clear_known_envs();
1123    }
1124
1125    #[test]
1126    fn fireworks_env_aliases_resolve() {
1127        let _lock = env_lock();
1128        clear_known_envs();
1129        // Safety: env mutation guarded by env_lock().
1130        unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1131
1132        assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1133        assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1134        // Safety: env mutation guarded by env_lock().
1135        unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1136    }
1137
1138    #[test]
1139    fn siliconflow_env_aliases_resolve() {
1140        let _lock = env_lock();
1141        clear_known_envs();
1142        // Safety: env mutation guarded by env_lock().
1143        unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
1144
1145        assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
1146        assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
1147        assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
1148        assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
1149        assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
1150        // Safety: env mutation guarded by env_lock().
1151        unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
1152    }
1153
1154    #[test]
1155    fn arcee_env_aliases_resolve() {
1156        let _lock = env_lock();
1157        clear_known_envs();
1158        // Safety: env mutation guarded by env_lock().
1159        unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
1160
1161        assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
1162        assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
1163        assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
1164        // Safety: env mutation guarded by env_lock().
1165        unsafe { std::env::remove_var("ARCEE_API_KEY") };
1166    }
1167
1168    #[test]
1169    fn moonshot_kimi_env_aliases_resolve() {
1170        let _lock = env_lock();
1171        clear_known_envs();
1172        // Safety: env mutation guarded by env_lock().
1173        unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
1174
1175        assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
1176        assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
1177        assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
1178        assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
1179        // Safety: env mutation guarded by env_lock().
1180        unsafe { std::env::remove_var("KIMI_API_KEY") };
1181    }
1182
1183    #[test]
1184    fn sglang_env_aliases_resolve() {
1185        let _lock = env_lock();
1186        clear_known_envs();
1187        // Safety: env mutation guarded by env_lock().
1188        unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
1189
1190        assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
1191        assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
1192        // Safety: env mutation guarded by env_lock().
1193        unsafe { std::env::remove_var("SGLANG_API_KEY") };
1194    }
1195
1196    #[test]
1197    fn vllm_env_aliases_resolve() {
1198        let _lock = env_lock();
1199        clear_known_envs();
1200        // Safety: env mutation guarded by env_lock().
1201        unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
1202
1203        assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
1204        assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
1205        // Safety: env mutation guarded by env_lock().
1206        unsafe { std::env::remove_var("VLLM_API_KEY") };
1207    }
1208
1209    #[test]
1210    fn ollama_env_aliases_resolve() {
1211        let _lock = env_lock();
1212        clear_known_envs();
1213        // Safety: env mutation guarded by env_lock().
1214        unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
1215
1216        assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
1217        assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
1218        // Safety: env mutation guarded by env_lock().
1219        unsafe { std::env::remove_var("OLLAMA_API_KEY") };
1220    }
1221
1222    #[cfg(unix)]
1223    #[test]
1224    fn file_store_round_trips_with_secure_perms() {
1225        use std::os::unix::fs::PermissionsExt;
1226
1227        let tmp = tempfile::tempdir().unwrap();
1228        let path = tmp.path().join("nested").join("secrets.json");
1229        let store = FileKeyringStore::new(path.clone());
1230        assert_eq!(store.get("deepseek").unwrap(), None);
1231        store.set("deepseek", "sk-disk").unwrap();
1232        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1233
1234        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1235        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
1236
1237        store.set("openrouter", "or-disk").unwrap();
1238        assert_eq!(
1239            store.get("openrouter").unwrap(),
1240            Some("or-disk".to_string())
1241        );
1242        // First entry must still be intact.
1243        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1244
1245        store.delete("deepseek").unwrap();
1246        assert_eq!(store.get("deepseek").unwrap(), None);
1247    }
1248
1249    #[cfg(unix)]
1250    #[test]
1251    fn file_store_rejects_world_readable_file() {
1252        use std::os::unix::fs::PermissionsExt;
1253        let tmp = tempfile::tempdir().unwrap();
1254        let path = tmp.path().join("secrets.json");
1255        fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
1256        let mut perms = fs::metadata(&path).unwrap().permissions();
1257        perms.set_mode(0o644);
1258        fs::set_permissions(&path, perms).unwrap();
1259
1260        let store = FileKeyringStore::new(path);
1261        let err = store.get("deepseek").unwrap_err();
1262        assert!(
1263            matches!(err, SecretsError::InsecurePermissions { .. }),
1264            "unexpected error: {err}"
1265        );
1266    }
1267
1268    // Regression for #281: `set` and `delete` used to call
1269    // `load_unlocked().unwrap_or_default()`, which silently wiped every
1270    // existing secret whenever the read failed (insecure permissions,
1271    // corrupt JSON, or any other I/O error).
1272
1273    #[cfg(unix)]
1274    #[test]
1275    fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
1276        use std::os::unix::fs::PermissionsExt;
1277        let tmp = tempfile::tempdir().unwrap();
1278        let path = tmp.path().join("secrets.json");
1279        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1280        fs::write(&path, original).unwrap();
1281        let mut perms = fs::metadata(&path).unwrap().permissions();
1282        perms.set_mode(0o644);
1283        fs::set_permissions(&path, perms).unwrap();
1284
1285        let store = FileKeyringStore::new(path.clone());
1286        let err = store.set("openrouter", "or-new").unwrap_err();
1287        assert!(
1288            matches!(err, SecretsError::InsecurePermissions { .. }),
1289            "set must surface the read error rather than overwriting; got: {err}"
1290        );
1291
1292        let on_disk = fs::read_to_string(&path).unwrap();
1293        assert_eq!(
1294            on_disk, original,
1295            "set must not modify the file when load_unlocked errored"
1296        );
1297    }
1298
1299    #[cfg(unix)]
1300    #[test]
1301    fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
1302        use std::os::unix::fs::PermissionsExt;
1303        let tmp = tempfile::tempdir().unwrap();
1304        let path = tmp.path().join("secrets.json");
1305        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1306        fs::write(&path, original).unwrap();
1307        let mut perms = fs::metadata(&path).unwrap().permissions();
1308        perms.set_mode(0o644);
1309        fs::set_permissions(&path, perms).unwrap();
1310
1311        let store = FileKeyringStore::new(path.clone());
1312        let err = store.delete("nvidia").unwrap_err();
1313        assert!(
1314            matches!(err, SecretsError::InsecurePermissions { .. }),
1315            "delete must surface the read error rather than wiping the file; got: {err}"
1316        );
1317        let on_disk = fs::read_to_string(&path).unwrap();
1318        assert_eq!(on_disk, original);
1319    }
1320
1321    #[test]
1322    fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
1323        let tmp = tempfile::tempdir().unwrap();
1324        let path = tmp.path().join("secrets.json");
1325        // Corrupt JSON. Permissions ok where unix; on Windows the perm-check
1326        // doesn't run so we exercise the json-error path directly.
1327        fs::write(&path, "{ this is not valid json").unwrap();
1328        #[cfg(unix)]
1329        {
1330            use std::os::unix::fs::PermissionsExt;
1331            let mut perms = fs::metadata(&path).unwrap().permissions();
1332            perms.set_mode(0o600);
1333            fs::set_permissions(&path, perms).unwrap();
1334        }
1335
1336        let store = FileKeyringStore::new(path.clone());
1337        let err = store.set("deepseek", "sk-new").unwrap_err();
1338        assert!(
1339            matches!(err, SecretsError::Json(_)),
1340            "set must surface the parse error rather than wiping the file; got: {err}"
1341        );
1342        let on_disk = fs::read_to_string(&path).unwrap();
1343        assert_eq!(on_disk, "{ this is not valid json");
1344    }
1345
1346    #[test]
1347    fn file_store_set_still_creates_file_when_missing() {
1348        // Regression guard: the #281 fix removed `unwrap_or_default()` from
1349        // the load call. Make sure the original first-write-creates-the-file
1350        // ergonomic still works — `load_unlocked` returns `Ok(default)` for
1351        // a missing file, so the `?` should pass through cleanly.
1352        let tmp = tempfile::tempdir().unwrap();
1353        let path = tmp.path().join("nested").join("secrets.json");
1354        let store = FileKeyringStore::new(path.clone());
1355
1356        store.set("deepseek", "sk-fresh").unwrap();
1357        assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
1358    }
1359
1360    #[test]
1361    fn file_store_default_path_uses_home() {
1362        let _lock = env_lock();
1363        clear_known_envs();
1364        let tmp = tempfile::tempdir().unwrap();
1365        let _home = EnvVarGuard::set("HOME", tmp.path());
1366        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1367
1368        let path = FileKeyringStore::default_path().unwrap();
1369        assert_eq!(
1370            path,
1371            tmp.path()
1372                .join(".codewhale")
1373                .join("secrets")
1374                .join("secrets.json")
1375        );
1376    }
1377}