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    /// Resolve a secret by key name with an optional source constraint.
742    ///
743    /// This is the fleet-worker secret resolution path. Unlike
744    /// [`resolve`](Secrets::resolve), this does NOT map provider names
745    /// to their canonical env vars — the caller controls the exact key
746    /// and resolution order.
747    ///
748    /// `source_hint` controls the resolution order:
749    /// - `Some("env")` — only check environment variables
750    /// - `Some("keyring")` — only check the keyring/file store
751    /// - `None` — try the store first, then fall back to environment
752    #[must_use]
753    pub fn resolve_direct(&self, key: &str, source_hint: Option<&str>) -> Option<String> {
754        match source_hint {
755            Some("env") => {
756                // Only check process environment — skip the store entirely.
757                std::env::var(key).ok().filter(|v| !v.trim().is_empty())
758            }
759            Some("keyring") | Some("file") => {
760                // Only check the store backend.
761                self.store
762                    .get(key)
763                    .ok()
764                    .flatten()
765                    .filter(|v| !v.trim().is_empty())
766            }
767            Some(_) | None => {
768                // Default: store first, then env fallback.
769                if let Ok(Some(v)) = self.store.get(key)
770                    && !v.trim().is_empty()
771                {
772                    return Some(v);
773                }
774                std::env::var(key).ok().filter(|v| !v.trim().is_empty())
775            }
776        }
777    }
778}
779
780/// Map a canonical provider name to its environment variable(s), returning
781/// the first non-empty value found.
782///
783/// Provider names are case-insensitive. Supported providers and their
784/// environment variables:
785///
786/// | Provider | Env var(s) |
787/// |---|---|
788/// | `deepseek` | `DEEPSEEK_API_KEY` |
789/// | `openrouter` | `OPENROUTER_API_KEY` |
790/// | `xiaomi-mimo` / `mimo` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |
791/// | `novita` | `NOVITA_API_KEY` |
792/// | `nvidia` / `nvidia-nim` / `nim` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY`, `DEEPSEEK_API_KEY` |
793/// | `fireworks` | `FIREWORKS_API_KEY` |
794/// | `siliconflow` / `siliconflow-cn` | `SILICONFLOW_API_KEY` |
795/// | `arcee` / `arcee-ai` | `ARCEE_API_KEY` |
796/// | `moonshot` / `kimi` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |
797/// | `sglang` | `SGLANG_API_KEY` |
798/// | `vllm` | `VLLM_API_KEY` |
799/// | `ollama` | `OLLAMA_API_KEY` |
800/// | `openai` | `OPENAI_API_KEY` |
801/// | `atlascloud` / `atlas` | `ATLASCLOUD_API_KEY` |
802/// | `volcengine` / `ark` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |
803/// | `wanjie` / `wanjie-ark` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |
804///
805/// Returns `None` if the provider is not recognised or none of its
806/// candidate environment variables are set to a non-empty value.
807#[must_use]
808pub fn env_for(name: &str) -> Option<String> {
809    let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
810        "deepseek" => &["DEEPSEEK_API_KEY"],
811        "openrouter" => &["OPENROUTER_API_KEY"],
812        "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
813            &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
814        }
815        "novita" => &["NOVITA_API_KEY"],
816        // NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
817        // catalog endpoint accepts the same DeepSeek-issued key when no
818        // dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
819        "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
820            &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
821        }
822        "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
823        "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
824        | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
825        "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
826        "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
827        "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
828        "vllm" | "v-llm" => &["VLLM_API_KEY"],
829        "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
830        "openai" => &["OPENAI_API_KEY"],
831        "anthropic" | "claude" => &["ANTHROPIC_API_KEY"],
832        "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
833        "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
834        | "volcengineark" => &[
835            "VOLCENGINE_API_KEY",
836            "VOLCENGINE_ARK_API_KEY",
837            "ARK_API_KEY",
838        ],
839        "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
840        | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
841            "WANJIE_ARK_API_KEY",
842            "WANJIE_API_KEY",
843            "WANJIE_MAAS_API_KEY",
844        ],
845        _ => return None,
846    };
847    for var in candidates {
848        if let Ok(value) = std::env::var(var)
849            && !value.trim().is_empty()
850        {
851            return Some(value);
852        }
853    }
854    None
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use std::sync::{Mutex, OnceLock};
861
862    /// Serialise env-mutating tests: tests in this module poke
863    /// `DEEPSEEK_API_KEY` etc., which is process-global.
864    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
865        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
866        LOCK.get_or_init(|| Mutex::new(()))
867            .lock()
868            .unwrap_or_else(|p| p.into_inner())
869    }
870
871    fn clear_known_envs() {
872        for var in [
873            "CODEWHALE_HOME",
874            "DEEPSEEK_API_KEY",
875            "OPENROUTER_API_KEY",
876            "NOVITA_API_KEY",
877            "NVIDIA_API_KEY",
878            "NVIDIA_NIM_API_KEY",
879            "FIREWORKS_API_KEY",
880            "SILICONFLOW_API_KEY",
881            "ARCEE_API_KEY",
882            "SGLANG_API_KEY",
883            "VLLM_API_KEY",
884            "OLLAMA_API_KEY",
885            "OPENAI_API_KEY",
886            "ATLASCLOUD_API_KEY",
887            "WANJIE_ARK_API_KEY",
888            "WANJIE_API_KEY",
889            "WANJIE_MAAS_API_KEY",
890            "XIAOMI_MIMO_API_KEY",
891            "XIAOMI_API_KEY",
892            "MIMO_API_KEY",
893            SECRET_BACKEND_ENV,
894            LEGACY_SECRET_BACKEND_ENV,
895        ] {
896            // Safety: tests serialise on env_lock(); the broader
897            // workspace has the same pattern in `crates/config`.
898            unsafe { std::env::remove_var(var) };
899        }
900    }
901
902    struct EnvVarGuard {
903        name: &'static str,
904        previous: Option<std::ffi::OsString>,
905    }
906
907    impl EnvVarGuard {
908        fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
909            let previous = std::env::var_os(name);
910            unsafe { std::env::set_var(name, value) };
911            Self { name, previous }
912        }
913    }
914
915    impl Drop for EnvVarGuard {
916        fn drop(&mut self) {
917            match self.previous.take() {
918                Some(value) => unsafe { std::env::set_var(self.name, value) },
919                None => unsafe { std::env::remove_var(self.name) },
920            }
921        }
922    }
923
924    #[test]
925    fn backend_selection_defaults_to_file() {
926        assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
927        assert_eq!(
928            secret_backend_selection(Some("")),
929            SecretBackendSelection::File
930        );
931        assert_eq!(
932            secret_backend_selection(Some("  file  ")),
933            SecretBackendSelection::File
934        );
935    }
936
937    #[test]
938    fn backend_selection_accepts_explicit_system_keyring() {
939        assert_eq!(
940            secret_backend_selection(Some("system")),
941            SecretBackendSelection::System
942        );
943        assert_eq!(
944            secret_backend_selection(Some("keyring")),
945            SecretBackendSelection::System
946        );
947        assert_eq!(
948            secret_backend_selection(Some("os-keyring")),
949            SecretBackendSelection::System
950        );
951    }
952
953    #[test]
954    fn auto_detect_is_file_backed_by_default() {
955        let _lock = env_lock();
956        clear_known_envs();
957        let tmp = tempfile::tempdir().unwrap();
958        let _home = EnvVarGuard::set("HOME", tmp.path());
959        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
960
961        let secrets = Secrets::auto_detect();
962
963        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
964    }
965
966    #[test]
967    fn auto_detect_honors_explicit_file_backend() {
968        let _lock = env_lock();
969        clear_known_envs();
970        let tmp = tempfile::tempdir().unwrap();
971        let _home = EnvVarGuard::set("HOME", tmp.path());
972        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
973        // Safety: env mutation guarded by env_lock().
974        unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
975
976        let secrets = Secrets::auto_detect();
977
978        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
979        // Safety: env mutation guarded by env_lock().
980        unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
981    }
982
983    #[test]
984    fn auto_detect_honors_legacy_backend_env_alias() {
985        let _lock = env_lock();
986        clear_known_envs();
987        let tmp = tempfile::tempdir().unwrap();
988        let _home = EnvVarGuard::set("HOME", tmp.path());
989        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
990        unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
991
992        let secrets = Secrets::auto_detect();
993
994        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
995        clear_known_envs();
996    }
997
998    #[test]
999    fn file_default_path_uses_codewhale_home() {
1000        let _lock = env_lock();
1001        clear_known_envs();
1002        let tmp = tempfile::tempdir().unwrap();
1003        let _home = EnvVarGuard::set("HOME", tmp.path());
1004        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1005
1006        let path = FileKeyringStore::default_path().unwrap();
1007
1008        assert_eq!(
1009            path,
1010            tmp.path()
1011                .join(".codewhale")
1012                .join("secrets")
1013                .join("secrets.json")
1014        );
1015    }
1016
1017    #[test]
1018    fn file_default_path_honors_codewhale_home() {
1019        let _lock = env_lock();
1020        clear_known_envs();
1021        let tmp = tempfile::tempdir().unwrap();
1022        let custom = tmp.path().join("custom-codewhale");
1023        let _home = EnvVarGuard::set("HOME", tmp.path());
1024        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1025        let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
1026
1027        let path = FileKeyringStore::default_path().unwrap();
1028
1029        assert_eq!(path, custom.join("secrets").join("secrets.json"));
1030    }
1031
1032    #[test]
1033    fn file_default_path_migrates_legacy_entries_to_codewhale() {
1034        let _lock = env_lock();
1035        clear_known_envs();
1036        let tmp = tempfile::tempdir().unwrap();
1037        let _home = EnvVarGuard::set("HOME", tmp.path());
1038        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1039        let legacy = tmp
1040            .path()
1041            .join(".deepseek")
1042            .join("secrets")
1043            .join("secrets.json");
1044        FileKeyringStore::new(legacy.clone())
1045            .set("xiaomi-mimo", "legacy-mimo")
1046            .unwrap();
1047
1048        let primary = FileKeyringStore::default_path().unwrap();
1049        let primary_store = FileKeyringStore::new(primary.clone());
1050
1051        assert_eq!(
1052            primary,
1053            tmp.path()
1054                .join(".codewhale")
1055                .join("secrets")
1056                .join("secrets.json")
1057        );
1058        assert_eq!(
1059            primary_store.get("xiaomi-mimo").unwrap().as_deref(),
1060            Some("legacy-mimo")
1061        );
1062        assert!(
1063            legacy.exists(),
1064            "migration copies; it does not delete legacy data"
1065        );
1066    }
1067
1068    #[test]
1069    fn file_default_path_migration_preserves_primary_values() {
1070        let _lock = env_lock();
1071        clear_known_envs();
1072        let tmp = tempfile::tempdir().unwrap();
1073        let _home = EnvVarGuard::set("HOME", tmp.path());
1074        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1075        let legacy = tmp
1076            .path()
1077            .join(".deepseek")
1078            .join("secrets")
1079            .join("secrets.json");
1080        let primary = tmp
1081            .path()
1082            .join(".codewhale")
1083            .join("secrets")
1084            .join("secrets.json");
1085        FileKeyringStore::new(legacy)
1086            .set("openrouter", "legacy-openrouter")
1087            .unwrap();
1088        let primary_store = FileKeyringStore::new(primary.clone());
1089        primary_store
1090            .set("openrouter", "primary-openrouter")
1091            .unwrap();
1092
1093        let resolved = FileKeyringStore::default_path().unwrap();
1094
1095        assert_eq!(resolved, primary);
1096        assert_eq!(
1097            primary_store.get("openrouter").unwrap().as_deref(),
1098            Some("primary-openrouter")
1099        );
1100    }
1101
1102    #[test]
1103    fn in_memory_store_round_trips() {
1104        let store = InMemoryKeyringStore::new();
1105        assert_eq!(store.get("deepseek").unwrap(), None);
1106        store.set("deepseek", "sk-test").unwrap();
1107        assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
1108        store.set("deepseek", "sk-replaced").unwrap();
1109        assert_eq!(
1110            store.get("deepseek").unwrap(),
1111            Some("sk-replaced".to_string())
1112        );
1113        store.delete("deepseek").unwrap();
1114        assert_eq!(store.get("deepseek").unwrap(), None);
1115        // Deleting an absent key is a no-op.
1116        store.delete("missing").unwrap();
1117    }
1118
1119    #[test]
1120    fn resolve_prefers_keyring_over_env() {
1121        let _lock = env_lock();
1122        clear_known_envs();
1123        // Safety: env mutation guarded by env_lock().
1124        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1125
1126        let store = Arc::new(InMemoryKeyringStore::new());
1127        store.set("deepseek", "ring-key").unwrap();
1128        let secrets = Secrets::new(store);
1129
1130        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1131        assert_eq!(
1132            secrets.resolve_with_source("deepseek"),
1133            Some(("ring-key".to_string(), SecretSource::Keyring))
1134        );
1135        // Safety: env mutation guarded by env_lock().
1136        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1137    }
1138
1139    #[test]
1140    fn resolve_falls_back_to_env_when_keyring_empty() {
1141        let _lock = env_lock();
1142        clear_known_envs();
1143        // Safety: env mutation guarded by env_lock().
1144        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1145
1146        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1147        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1148        assert_eq!(
1149            secrets.resolve_with_source("deepseek"),
1150            Some(("env-fallback".to_string(), SecretSource::Env))
1151        );
1152        // Safety: env mutation guarded by env_lock().
1153        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1154    }
1155
1156    #[test]
1157    fn resolve_returns_none_when_both_layers_empty() {
1158        let _lock = env_lock();
1159        clear_known_envs();
1160        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1161        assert_eq!(secrets.resolve("deepseek"), None);
1162    }
1163
1164    #[test]
1165    fn resolve_treats_blank_keyring_value_as_unset() {
1166        let _lock = env_lock();
1167        clear_known_envs();
1168        // Safety: env mutation guarded by env_lock().
1169        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1170
1171        let store = Arc::new(InMemoryKeyringStore::new());
1172        store.set("deepseek", "   ").unwrap();
1173        let secrets = Secrets::new(store);
1174        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1175        // Safety: env mutation guarded by env_lock().
1176        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1177    }
1178
1179    #[test]
1180    fn nvidia_env_aliases_resolve() {
1181        let _lock = env_lock();
1182        clear_known_envs();
1183        // Safety: env mutation guarded by env_lock().
1184        unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1185        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1186        assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1187        assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1188        // Safety: env mutation guarded by env_lock().
1189        unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1190    }
1191
1192    #[test]
1193    fn atlascloud_env_aliases_resolve() {
1194        let _guard = env_lock();
1195        clear_known_envs();
1196        unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1197
1198        assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1199        assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1200        assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1201
1202        clear_known_envs();
1203    }
1204
1205    #[test]
1206    fn wanjie_ark_env_aliases_resolve() {
1207        let _guard = env_lock();
1208        clear_known_envs();
1209        unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1210
1211        assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1212        assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1213        assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1214
1215        clear_known_envs();
1216    }
1217
1218    #[test]
1219    fn xiaomi_mimo_env_aliases_resolve() {
1220        let _guard = env_lock();
1221        clear_known_envs();
1222        unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1223
1224        assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1225        assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1226        assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1227        assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1228
1229        clear_known_envs();
1230
1231        unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1232        assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1233        clear_known_envs();
1234    }
1235
1236    #[test]
1237    fn fireworks_env_aliases_resolve() {
1238        let _lock = env_lock();
1239        clear_known_envs();
1240        // Safety: env mutation guarded by env_lock().
1241        unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1242
1243        assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1244        assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1245        // Safety: env mutation guarded by env_lock().
1246        unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1247    }
1248
1249    #[test]
1250    fn siliconflow_env_aliases_resolve() {
1251        let _lock = env_lock();
1252        clear_known_envs();
1253        // Safety: env mutation guarded by env_lock().
1254        unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
1255
1256        assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
1257        assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
1258        assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
1259        assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
1260        assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
1261        // Safety: env mutation guarded by env_lock().
1262        unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
1263    }
1264
1265    #[test]
1266    fn arcee_env_aliases_resolve() {
1267        let _lock = env_lock();
1268        clear_known_envs();
1269        // Safety: env mutation guarded by env_lock().
1270        unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
1271
1272        assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
1273        assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
1274        assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
1275        // Safety: env mutation guarded by env_lock().
1276        unsafe { std::env::remove_var("ARCEE_API_KEY") };
1277    }
1278
1279    #[test]
1280    fn moonshot_kimi_env_aliases_resolve() {
1281        let _lock = env_lock();
1282        clear_known_envs();
1283        // Safety: env mutation guarded by env_lock().
1284        unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
1285
1286        assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
1287        assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
1288        assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
1289        assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
1290        // Safety: env mutation guarded by env_lock().
1291        unsafe { std::env::remove_var("KIMI_API_KEY") };
1292    }
1293
1294    #[test]
1295    fn sglang_env_aliases_resolve() {
1296        let _lock = env_lock();
1297        clear_known_envs();
1298        // Safety: env mutation guarded by env_lock().
1299        unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
1300
1301        assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
1302        assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
1303        // Safety: env mutation guarded by env_lock().
1304        unsafe { std::env::remove_var("SGLANG_API_KEY") };
1305    }
1306
1307    #[test]
1308    fn vllm_env_aliases_resolve() {
1309        let _lock = env_lock();
1310        clear_known_envs();
1311        // Safety: env mutation guarded by env_lock().
1312        unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
1313
1314        assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
1315        assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
1316        // Safety: env mutation guarded by env_lock().
1317        unsafe { std::env::remove_var("VLLM_API_KEY") };
1318    }
1319
1320    #[test]
1321    fn ollama_env_aliases_resolve() {
1322        let _lock = env_lock();
1323        clear_known_envs();
1324        // Safety: env mutation guarded by env_lock().
1325        unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
1326
1327        assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
1328        assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
1329        // Safety: env mutation guarded by env_lock().
1330        unsafe { std::env::remove_var("OLLAMA_API_KEY") };
1331    }
1332
1333    #[cfg(unix)]
1334    #[test]
1335    fn file_store_round_trips_with_secure_perms() {
1336        use std::os::unix::fs::PermissionsExt;
1337
1338        let tmp = tempfile::tempdir().unwrap();
1339        let path = tmp.path().join("nested").join("secrets.json");
1340        let store = FileKeyringStore::new(path.clone());
1341        assert_eq!(store.get("deepseek").unwrap(), None);
1342        store.set("deepseek", "sk-disk").unwrap();
1343        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1344
1345        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1346        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
1347
1348        store.set("openrouter", "or-disk").unwrap();
1349        assert_eq!(
1350            store.get("openrouter").unwrap(),
1351            Some("or-disk".to_string())
1352        );
1353        // First entry must still be intact.
1354        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1355
1356        store.delete("deepseek").unwrap();
1357        assert_eq!(store.get("deepseek").unwrap(), None);
1358    }
1359
1360    #[cfg(unix)]
1361    #[test]
1362    fn file_store_rejects_world_readable_file() {
1363        use std::os::unix::fs::PermissionsExt;
1364        let tmp = tempfile::tempdir().unwrap();
1365        let path = tmp.path().join("secrets.json");
1366        fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
1367        let mut perms = fs::metadata(&path).unwrap().permissions();
1368        perms.set_mode(0o644);
1369        fs::set_permissions(&path, perms).unwrap();
1370
1371        let store = FileKeyringStore::new(path);
1372        let err = store.get("deepseek").unwrap_err();
1373        assert!(
1374            matches!(err, SecretsError::InsecurePermissions { .. }),
1375            "unexpected error: {err}"
1376        );
1377    }
1378
1379    // Regression for #281: `set` and `delete` used to call
1380    // `load_unlocked().unwrap_or_default()`, which silently wiped every
1381    // existing secret whenever the read failed (insecure permissions,
1382    // corrupt JSON, or any other I/O error).
1383
1384    #[cfg(unix)]
1385    #[test]
1386    fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
1387        use std::os::unix::fs::PermissionsExt;
1388        let tmp = tempfile::tempdir().unwrap();
1389        let path = tmp.path().join("secrets.json");
1390        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1391        fs::write(&path, original).unwrap();
1392        let mut perms = fs::metadata(&path).unwrap().permissions();
1393        perms.set_mode(0o644);
1394        fs::set_permissions(&path, perms).unwrap();
1395
1396        let store = FileKeyringStore::new(path.clone());
1397        let err = store.set("openrouter", "or-new").unwrap_err();
1398        assert!(
1399            matches!(err, SecretsError::InsecurePermissions { .. }),
1400            "set must surface the read error rather than overwriting; got: {err}"
1401        );
1402
1403        let on_disk = fs::read_to_string(&path).unwrap();
1404        assert_eq!(
1405            on_disk, original,
1406            "set must not modify the file when load_unlocked errored"
1407        );
1408    }
1409
1410    #[cfg(unix)]
1411    #[test]
1412    fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
1413        use std::os::unix::fs::PermissionsExt;
1414        let tmp = tempfile::tempdir().unwrap();
1415        let path = tmp.path().join("secrets.json");
1416        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1417        fs::write(&path, original).unwrap();
1418        let mut perms = fs::metadata(&path).unwrap().permissions();
1419        perms.set_mode(0o644);
1420        fs::set_permissions(&path, perms).unwrap();
1421
1422        let store = FileKeyringStore::new(path.clone());
1423        let err = store.delete("nvidia").unwrap_err();
1424        assert!(
1425            matches!(err, SecretsError::InsecurePermissions { .. }),
1426            "delete must surface the read error rather than wiping the file; got: {err}"
1427        );
1428        let on_disk = fs::read_to_string(&path).unwrap();
1429        assert_eq!(on_disk, original);
1430    }
1431
1432    #[test]
1433    fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
1434        let tmp = tempfile::tempdir().unwrap();
1435        let path = tmp.path().join("secrets.json");
1436        // Corrupt JSON. Permissions ok where unix; on Windows the perm-check
1437        // doesn't run so we exercise the json-error path directly.
1438        fs::write(&path, "{ this is not valid json").unwrap();
1439        #[cfg(unix)]
1440        {
1441            use std::os::unix::fs::PermissionsExt;
1442            let mut perms = fs::metadata(&path).unwrap().permissions();
1443            perms.set_mode(0o600);
1444            fs::set_permissions(&path, perms).unwrap();
1445        }
1446
1447        let store = FileKeyringStore::new(path.clone());
1448        let err = store.set("deepseek", "sk-new").unwrap_err();
1449        assert!(
1450            matches!(err, SecretsError::Json(_)),
1451            "set must surface the parse error rather than wiping the file; got: {err}"
1452        );
1453        let on_disk = fs::read_to_string(&path).unwrap();
1454        assert_eq!(on_disk, "{ this is not valid json");
1455    }
1456
1457    #[test]
1458    fn file_store_set_still_creates_file_when_missing() {
1459        // Regression guard: the #281 fix removed `unwrap_or_default()` from
1460        // the load call. Make sure the original first-write-creates-the-file
1461        // ergonomic still works — `load_unlocked` returns `Ok(default)` for
1462        // a missing file, so the `?` should pass through cleanly.
1463        let tmp = tempfile::tempdir().unwrap();
1464        let path = tmp.path().join("nested").join("secrets.json");
1465        let store = FileKeyringStore::new(path.clone());
1466
1467        store.set("deepseek", "sk-fresh").unwrap();
1468        assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
1469    }
1470
1471    #[test]
1472    fn file_store_default_path_uses_home() {
1473        let _lock = env_lock();
1474        clear_known_envs();
1475        let tmp = tempfile::tempdir().unwrap();
1476        let _home = EnvVarGuard::set("HOME", tmp.path());
1477        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1478
1479        let path = FileKeyringStore::default_path().unwrap();
1480        assert_eq!(
1481            path,
1482            tmp.path()
1483                .join(".codewhale")
1484                .join("secrets")
1485                .join("secrets.json")
1486        );
1487    }
1488}