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