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