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        write_private_file(&self.path, body.as_bytes())?;
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
503#[cfg(unix)]
504fn write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
505    use std::fs::OpenOptions;
506    use std::io::Write;
507    use std::os::unix::fs::OpenOptionsExt;
508
509    let mut file = OpenOptions::new()
510        .create(true)
511        .truncate(true)
512        .write(true)
513        .mode(0o600)
514        .open(path)?;
515    file.write_all(body)?;
516    Ok(())
517}
518
519#[cfg(not(unix))]
520fn write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
521    fs::write(path, body)?;
522    Ok(())
523}
524
525impl KeyringStore for FileKeyringStore {
526    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
527        let blob = self.load_unlocked()?;
528        Ok(blob.entries.get(key).cloned())
529    }
530
531    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
532        // load_unlocked already returns Ok(default) for a missing file, so the
533        // first-write-creates-the-file path is preserved. Any other Err
534        // (insecure permissions, corrupt JSON, transient I/O) MUST surface to
535        // the caller — propagating it via `unwrap_or_default()` silently
536        // wipes every previously stored secret on the next `store_unlocked`.
537        let mut blob = self.load_unlocked()?;
538        blob.entries.insert(key.to_string(), value.to_string());
539        self.store_unlocked(&blob)
540    }
541
542    fn delete(&self, key: &str) -> Result<(), SecretsError> {
543        // Same invariant as `set`: never fall back to an empty blob on read
544        // error, or `delete <one-key>` becomes `delete <every-key>`.
545        let mut blob = self.load_unlocked()?;
546        blob.entries.remove(key);
547        self.store_unlocked(&blob)
548    }
549
550    fn backend_name(&self) -> &'static str {
551        FILE_BACKEND_LABEL
552    }
553}
554
555fn default_codewhale_secrets_path() -> Result<PathBuf, SecretsError> {
556    if let Ok(value) = std::env::var("CODEWHALE_HOME") {
557        let trimmed = value.trim();
558        if !trimmed.is_empty() {
559            return Ok(PathBuf::from(trimmed).join("secrets").join("secrets.json"));
560        }
561    }
562    Ok(FileKeyringStore::home_dir()?
563        .join(".codewhale")
564        .join("secrets")
565        .join("secrets.json"))
566}
567
568fn legacy_deepseek_secrets_path() -> Result<PathBuf, SecretsError> {
569    Ok(FileKeyringStore::home_dir()?
570        .join(".deepseek")
571        .join("secrets")
572        .join("secrets.json"))
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576enum SecretBackendSelection {
577    File,
578    System,
579    Unknown,
580}
581
582fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
583    match value.map(str::trim).filter(|value| !value.is_empty()) {
584        None => SecretBackendSelection::File,
585        Some(value) => match value.to_ascii_lowercase().as_str() {
586            "file" | "local" | "json" => SecretBackendSelection::File,
587            "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
588            _ => SecretBackendSelection::Unknown,
589        },
590    }
591}
592
593fn configured_secret_backend() -> Option<String> {
594    std::env::var(SECRET_BACKEND_ENV)
595        .ok()
596        .filter(|value| !value.trim().is_empty())
597        .or_else(|| std::env::var(LEGACY_SECRET_BACKEND_ENV).ok())
598}
599
600/// High-level facade combining a [`KeyringStore`] with environment variable fallbacks.
601///
602/// Lookup precedence: **secret store -> env -> none**. Callers that also
603/// have a TOML config layer must wire that themselves at the very end
604/// of the chain (the config crate handles this).
605///
606/// # Examples
607///
608/// ```no_run
609/// use codewhale_secrets::Secrets;
610///
611/// let secrets = Secrets::auto_detect();
612/// if let Some(key) = secrets.resolve("deepseek") {
613///     // use the API key
614/// }
615/// ```
616#[derive(Clone)]
617pub struct Secrets {
618    /// Underlying secret store backend.
619    pub store: Arc<dyn KeyringStore>,
620    /// Owner identifier within the secret store (typically `"deepseek"`).
621    /// The `key` parameter passed to [`resolve`](Secrets::resolve) is
622    /// forwarded to the store as-is, while environment variables are
623    /// looked up by canonical provider name via [`env_for`].
624    service: String,
625}
626
627/// Identifies which layer in the resolution chain supplied a secret.
628///
629/// Returned by [`Secrets::resolve_with_source`] so callers can
630/// distinguish whether a value came from the configured store or from
631/// a process environment variable.
632#[derive(Debug, Clone, Copy, PartialEq, Eq)]
633pub enum SecretSource {
634    /// The secret was returned by the configured [`KeyringStore`] backend.
635    Keyring,
636    /// The secret was found in a process environment variable.
637    Env,
638}
639
640impl std::fmt::Debug for Secrets {
641    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
642        f.debug_struct("Secrets")
643            .field("backend", &self.store.backend_name())
644            .field("service", &self.service)
645            .finish()
646    }
647}
648
649impl Secrets {
650    /// Build a new facade around the given store, using the
651    /// [`DEFAULT_SERVICE`] service name.
652    #[must_use]
653    pub fn new(store: Arc<dyn KeyringStore>) -> Self {
654        Self {
655            store,
656            service: DEFAULT_SERVICE.to_string(),
657        }
658    }
659
660    /// Auto-detect the best available backend based on the environment.
661    ///
662    /// Selection logic:
663    /// 1. If [`SECRET_BACKEND_ENV`] is set to `system`/`keyring`/`os`/`os-keyring`,
664    ///    probe the OS keyring. If the probe succeeds, use it; otherwise
665    ///    fall back to the file-based store with a warning.
666    /// 2. If the env var is unset, empty, or `file`/`local`/`json`, use
667    ///    the file-based store directly.
668    /// 3. If the env var is set to an unrecognised value, log a warning
669    ///    and use the file-based store.
670    pub fn auto_detect() -> Self {
671        match secret_backend_selection(configured_secret_backend().as_deref()) {
672            SecretBackendSelection::File => Self::file_backed_default(),
673            SecretBackendSelection::Unknown => {
674                tracing::warn!(
675                    "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
676                );
677                Self::file_backed_default()
678            }
679            SecretBackendSelection::System => {
680                let default_store = DefaultKeyringStore::default();
681                match default_store.probe() {
682                    Ok(()) => Self::new(Arc::new(default_store)),
683                    Err(err) => {
684                        tracing::warn!(
685                            "OS keyring unavailable ({err}); falling back to file-backed secret store"
686                        );
687                        Self::file_backed_default()
688                    }
689                }
690            }
691        }
692    }
693
694    fn file_backed_default() -> Self {
695        let path = FileKeyringStore::default_path()
696            .unwrap_or_else(|_| PathBuf::from(".codewhale-secrets.json"));
697        Self::new(Arc::new(FileKeyringStore::new(path)))
698    }
699
700    /// Construct the file-backed default backend directly.
701    #[must_use]
702    pub fn file_backed() -> Self {
703        Self::file_backed_default()
704    }
705
706    /// Construct the opt-in OS credential backend, falling back to the
707    /// file-backed store when the platform backend is unavailable.
708    #[must_use]
709    pub fn system_keyring() -> Self {
710        let default_store = DefaultKeyringStore::default();
711        match default_store.probe() {
712            Ok(()) => Self::new(Arc::new(default_store)),
713            Err(err) => {
714                tracing::warn!(
715                    "OS keyring unavailable ({err}); falling back to file-backed secret store"
716                );
717                Self::file_backed_default()
718            }
719        }
720    }
721
722    /// Backend label, suitable for `doctor` output.
723    #[must_use]
724    pub fn backend_name(&self) -> &'static str {
725        self.store.backend_name()
726    }
727
728    /// Resolve a secret with `secret store → env → none` precedence.
729    ///
730    /// `name` is the canonical provider name or a supported provider alias.
731    /// Empty strings on either layer are treated as "not set".
732    #[must_use]
733    pub fn resolve(&self, name: &str) -> Option<String> {
734        self.resolve_with_source(name).map(|(value, _)| value)
735    }
736
737    /// Resolve a secret and report which layer supplied it.
738    #[must_use]
739    pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
740        if let Ok(Some(v)) = self.store.get(name)
741            && !v.trim().is_empty()
742        {
743            return Some((v, SecretSource::Keyring));
744        }
745        env_for(name).map(|value| (value, SecretSource::Env))
746    }
747
748    /// Convenience: write a secret through the underlying store.
749    pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
750        self.store.set(name, value)
751    }
752
753    /// Convenience: delete a secret through the underlying store.
754    pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
755        self.store.delete(name)
756    }
757
758    /// Convenience: read a secret directly (no env fallback).
759    pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
760        self.store.get(name)
761    }
762
763    /// Resolve a secret by key name with an optional source constraint.
764    ///
765    /// This is the fleet-worker secret resolution path. Unlike
766    /// [`resolve`](Secrets::resolve), this does NOT map provider names
767    /// to their canonical env vars — the caller controls the exact key
768    /// and resolution order.
769    ///
770    /// `source_hint` controls the resolution order:
771    /// - `Some("env")` — only check environment variables
772    /// - `Some("keyring")` — only check the keyring/file store
773    /// - `None` — try the store first, then fall back to environment
774    #[must_use]
775    pub fn resolve_direct(&self, key: &str, source_hint: Option<&str>) -> Option<String> {
776        match source_hint {
777            Some("env") => {
778                // Only check process environment — skip the store entirely.
779                std::env::var(key).ok().filter(|v| !v.trim().is_empty())
780            }
781            Some("keyring") | Some("file") => {
782                // Only check the store backend.
783                self.store
784                    .get(key)
785                    .ok()
786                    .flatten()
787                    .filter(|v| !v.trim().is_empty())
788            }
789            Some(_) | None => {
790                // Default: store first, then env fallback.
791                if let Ok(Some(v)) = self.store.get(key)
792                    && !v.trim().is_empty()
793                {
794                    return Some(v);
795                }
796                std::env::var(key).ok().filter(|v| !v.trim().is_empty())
797            }
798        }
799    }
800}
801
802/// Map a canonical provider name to its environment variable(s), returning
803/// the first non-empty value found.
804///
805/// Provider names are case-insensitive. Supported providers and their
806/// environment variables:
807///
808/// | Provider | Env var(s) |
809/// |---|---|
810/// | `deepseek` | `DEEPSEEK_API_KEY` |
811/// | `openrouter` | `OPENROUTER_API_KEY` |
812/// | `xiaomi-mimo` / `mimo` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |
813/// | `novita` | `NOVITA_API_KEY` |
814/// | `nvidia` / `nvidia-nim` / `nim` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY`, `DEEPSEEK_API_KEY` |
815/// | `fireworks` | `FIREWORKS_API_KEY` |
816/// | `siliconflow` / `siliconflow-cn` | `SILICONFLOW_API_KEY` |
817/// | `arcee` / `arcee-ai` | `ARCEE_API_KEY` |
818/// | `moonshot` / `kimi` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |
819/// | `sglang` | `SGLANG_API_KEY` |
820/// | `vllm` | `VLLM_API_KEY` |
821/// | `ollama` | `OLLAMA_API_KEY` |
822/// | `openai` | `OPENAI_API_KEY` |
823/// | `atlascloud` / `atlas` | `ATLASCLOUD_API_KEY` |
824/// | `volcengine` / `ark` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |
825/// | `wanjie` / `wanjie-ark` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |
826///
827/// Returns `None` if the provider is not recognised or none of its
828/// candidate environment variables are set to a non-empty value.
829#[must_use]
830pub fn env_for(name: &str) -> Option<String> {
831    let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
832        "deepseek" => &["DEEPSEEK_API_KEY"],
833        "openrouter" => &["OPENROUTER_API_KEY"],
834        "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
835            &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
836        }
837        "novita" => &["NOVITA_API_KEY"],
838        // NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
839        // catalog endpoint accepts the same DeepSeek-issued key when no
840        // dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
841        "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
842            &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
843        }
844        "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
845        "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
846        | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
847        "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
848        "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
849        "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
850        "vllm" | "v-llm" => &["VLLM_API_KEY"],
851        "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
852        "openai" => &["OPENAI_API_KEY"],
853        "anthropic" | "claude" => &["ANTHROPIC_API_KEY"],
854        "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
855        "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
856        | "volcengineark" => &[
857            "VOLCENGINE_API_KEY",
858            "VOLCENGINE_ARK_API_KEY",
859            "ARK_API_KEY",
860        ],
861        "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
862        | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
863            "WANJIE_ARK_API_KEY",
864            "WANJIE_API_KEY",
865            "WANJIE_MAAS_API_KEY",
866        ],
867        _ => return None,
868    };
869    for var in candidates {
870        if let Ok(value) = std::env::var(var)
871            && !value.trim().is_empty()
872        {
873            return Some(value);
874        }
875    }
876    None
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use std::sync::{Mutex, OnceLock};
883
884    /// Serialise env-mutating tests: tests in this module poke
885    /// `DEEPSEEK_API_KEY` etc., which is process-global.
886    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
887        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
888        LOCK.get_or_init(|| Mutex::new(()))
889            .lock()
890            .unwrap_or_else(|p| p.into_inner())
891    }
892
893    fn clear_known_envs() {
894        for var in [
895            "CODEWHALE_HOME",
896            "DEEPSEEK_API_KEY",
897            "OPENROUTER_API_KEY",
898            "NOVITA_API_KEY",
899            "NVIDIA_API_KEY",
900            "NVIDIA_NIM_API_KEY",
901            "FIREWORKS_API_KEY",
902            "SILICONFLOW_API_KEY",
903            "ARCEE_API_KEY",
904            "SGLANG_API_KEY",
905            "VLLM_API_KEY",
906            "OLLAMA_API_KEY",
907            "OPENAI_API_KEY",
908            "ATLASCLOUD_API_KEY",
909            "WANJIE_ARK_API_KEY",
910            "WANJIE_API_KEY",
911            "WANJIE_MAAS_API_KEY",
912            "XIAOMI_MIMO_API_KEY",
913            "XIAOMI_API_KEY",
914            "MIMO_API_KEY",
915            SECRET_BACKEND_ENV,
916            LEGACY_SECRET_BACKEND_ENV,
917        ] {
918            // Safety: tests serialise on env_lock(); the broader
919            // workspace has the same pattern in `crates/config`.
920            unsafe { std::env::remove_var(var) };
921        }
922    }
923
924    struct EnvVarGuard {
925        name: &'static str,
926        previous: Option<std::ffi::OsString>,
927    }
928
929    impl EnvVarGuard {
930        fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
931            let previous = std::env::var_os(name);
932            unsafe { std::env::set_var(name, value) };
933            Self { name, previous }
934        }
935    }
936
937    impl Drop for EnvVarGuard {
938        fn drop(&mut self) {
939            match self.previous.take() {
940                Some(value) => unsafe { std::env::set_var(self.name, value) },
941                None => unsafe { std::env::remove_var(self.name) },
942            }
943        }
944    }
945
946    #[test]
947    fn backend_selection_defaults_to_file() {
948        assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
949        assert_eq!(
950            secret_backend_selection(Some("")),
951            SecretBackendSelection::File
952        );
953        assert_eq!(
954            secret_backend_selection(Some("  file  ")),
955            SecretBackendSelection::File
956        );
957    }
958
959    #[test]
960    fn backend_selection_accepts_explicit_system_keyring() {
961        assert_eq!(
962            secret_backend_selection(Some("system")),
963            SecretBackendSelection::System
964        );
965        assert_eq!(
966            secret_backend_selection(Some("keyring")),
967            SecretBackendSelection::System
968        );
969        assert_eq!(
970            secret_backend_selection(Some("os-keyring")),
971            SecretBackendSelection::System
972        );
973    }
974
975    #[test]
976    fn auto_detect_is_file_backed_by_default() {
977        let _lock = env_lock();
978        clear_known_envs();
979        let tmp = tempfile::tempdir().unwrap();
980        let _home = EnvVarGuard::set("HOME", tmp.path());
981        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
982
983        let secrets = Secrets::auto_detect();
984
985        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
986    }
987
988    #[test]
989    fn auto_detect_honors_explicit_file_backend() {
990        let _lock = env_lock();
991        clear_known_envs();
992        let tmp = tempfile::tempdir().unwrap();
993        let _home = EnvVarGuard::set("HOME", tmp.path());
994        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
995        // Safety: env mutation guarded by env_lock().
996        unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
997
998        let secrets = Secrets::auto_detect();
999
1000        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1001        // Safety: env mutation guarded by env_lock().
1002        unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
1003    }
1004
1005    #[test]
1006    fn auto_detect_honors_legacy_backend_env_alias() {
1007        let _lock = env_lock();
1008        clear_known_envs();
1009        let tmp = tempfile::tempdir().unwrap();
1010        let _home = EnvVarGuard::set("HOME", tmp.path());
1011        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1012        unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
1013
1014        let secrets = Secrets::auto_detect();
1015
1016        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1017        clear_known_envs();
1018    }
1019
1020    #[test]
1021    fn file_default_path_uses_codewhale_home() {
1022        let _lock = env_lock();
1023        clear_known_envs();
1024        let tmp = tempfile::tempdir().unwrap();
1025        let _home = EnvVarGuard::set("HOME", tmp.path());
1026        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1027
1028        let path = FileKeyringStore::default_path().unwrap();
1029
1030        assert_eq!(
1031            path,
1032            tmp.path()
1033                .join(".codewhale")
1034                .join("secrets")
1035                .join("secrets.json")
1036        );
1037    }
1038
1039    #[test]
1040    fn file_default_path_honors_codewhale_home() {
1041        let _lock = env_lock();
1042        clear_known_envs();
1043        let tmp = tempfile::tempdir().unwrap();
1044        let custom = tmp.path().join("custom-codewhale");
1045        let _home = EnvVarGuard::set("HOME", tmp.path());
1046        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1047        let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
1048
1049        let path = FileKeyringStore::default_path().unwrap();
1050
1051        assert_eq!(path, custom.join("secrets").join("secrets.json"));
1052    }
1053
1054    #[test]
1055    fn file_default_path_migrates_legacy_entries_to_codewhale() {
1056        let _lock = env_lock();
1057        clear_known_envs();
1058        let tmp = tempfile::tempdir().unwrap();
1059        let _home = EnvVarGuard::set("HOME", tmp.path());
1060        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1061        let legacy = tmp
1062            .path()
1063            .join(".deepseek")
1064            .join("secrets")
1065            .join("secrets.json");
1066        FileKeyringStore::new(legacy.clone())
1067            .set("xiaomi-mimo", "legacy-mimo")
1068            .unwrap();
1069
1070        let primary = FileKeyringStore::default_path().unwrap();
1071        let primary_store = FileKeyringStore::new(primary.clone());
1072
1073        assert_eq!(
1074            primary,
1075            tmp.path()
1076                .join(".codewhale")
1077                .join("secrets")
1078                .join("secrets.json")
1079        );
1080        assert_eq!(
1081            primary_store.get("xiaomi-mimo").unwrap().as_deref(),
1082            Some("legacy-mimo")
1083        );
1084        assert!(
1085            legacy.exists(),
1086            "migration copies; it does not delete legacy data"
1087        );
1088    }
1089
1090    #[test]
1091    fn file_default_path_migration_preserves_primary_values() {
1092        let _lock = env_lock();
1093        clear_known_envs();
1094        let tmp = tempfile::tempdir().unwrap();
1095        let _home = EnvVarGuard::set("HOME", tmp.path());
1096        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1097        let legacy = tmp
1098            .path()
1099            .join(".deepseek")
1100            .join("secrets")
1101            .join("secrets.json");
1102        let primary = tmp
1103            .path()
1104            .join(".codewhale")
1105            .join("secrets")
1106            .join("secrets.json");
1107        FileKeyringStore::new(legacy)
1108            .set("openrouter", "legacy-openrouter")
1109            .unwrap();
1110        let primary_store = FileKeyringStore::new(primary.clone());
1111        primary_store
1112            .set("openrouter", "primary-openrouter")
1113            .unwrap();
1114
1115        let resolved = FileKeyringStore::default_path().unwrap();
1116
1117        assert_eq!(resolved, primary);
1118        assert_eq!(
1119            primary_store.get("openrouter").unwrap().as_deref(),
1120            Some("primary-openrouter")
1121        );
1122    }
1123
1124    #[test]
1125    fn in_memory_store_round_trips() {
1126        let store = InMemoryKeyringStore::new();
1127        assert_eq!(store.get("deepseek").unwrap(), None);
1128        store.set("deepseek", "sk-test").unwrap();
1129        assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
1130        store.set("deepseek", "sk-replaced").unwrap();
1131        assert_eq!(
1132            store.get("deepseek").unwrap(),
1133            Some("sk-replaced".to_string())
1134        );
1135        store.delete("deepseek").unwrap();
1136        assert_eq!(store.get("deepseek").unwrap(), None);
1137        // Deleting an absent key is a no-op.
1138        store.delete("missing").unwrap();
1139    }
1140
1141    #[test]
1142    fn resolve_prefers_keyring_over_env() {
1143        let _lock = env_lock();
1144        clear_known_envs();
1145        // Safety: env mutation guarded by env_lock().
1146        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1147
1148        let store = Arc::new(InMemoryKeyringStore::new());
1149        store.set("deepseek", "ring-key").unwrap();
1150        let secrets = Secrets::new(store);
1151
1152        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1153        assert_eq!(
1154            secrets.resolve_with_source("deepseek"),
1155            Some(("ring-key".to_string(), SecretSource::Keyring))
1156        );
1157        // Safety: env mutation guarded by env_lock().
1158        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1159    }
1160
1161    #[test]
1162    fn resolve_falls_back_to_env_when_keyring_empty() {
1163        let _lock = env_lock();
1164        clear_known_envs();
1165        // Safety: env mutation guarded by env_lock().
1166        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1167
1168        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1169        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1170        assert_eq!(
1171            secrets.resolve_with_source("deepseek"),
1172            Some(("env-fallback".to_string(), SecretSource::Env))
1173        );
1174        // Safety: env mutation guarded by env_lock().
1175        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1176    }
1177
1178    #[test]
1179    fn resolve_returns_none_when_both_layers_empty() {
1180        let _lock = env_lock();
1181        clear_known_envs();
1182        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1183        assert_eq!(secrets.resolve("deepseek"), None);
1184    }
1185
1186    #[test]
1187    fn resolve_treats_blank_keyring_value_as_unset() {
1188        let _lock = env_lock();
1189        clear_known_envs();
1190        // Safety: env mutation guarded by env_lock().
1191        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1192
1193        let store = Arc::new(InMemoryKeyringStore::new());
1194        store.set("deepseek", "   ").unwrap();
1195        let secrets = Secrets::new(store);
1196        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1197        // Safety: env mutation guarded by env_lock().
1198        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1199    }
1200
1201    #[test]
1202    fn nvidia_env_aliases_resolve() {
1203        let _lock = env_lock();
1204        clear_known_envs();
1205        // Safety: env mutation guarded by env_lock().
1206        unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1207        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1208        assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1209        assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1210        // Safety: env mutation guarded by env_lock().
1211        unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1212    }
1213
1214    #[test]
1215    fn atlascloud_env_aliases_resolve() {
1216        let _guard = env_lock();
1217        clear_known_envs();
1218        unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1219
1220        assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1221        assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1222        assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1223
1224        clear_known_envs();
1225    }
1226
1227    #[test]
1228    fn wanjie_ark_env_aliases_resolve() {
1229        let _guard = env_lock();
1230        clear_known_envs();
1231        unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1232
1233        assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1234        assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1235        assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1236
1237        clear_known_envs();
1238    }
1239
1240    #[test]
1241    fn xiaomi_mimo_env_aliases_resolve() {
1242        let _guard = env_lock();
1243        clear_known_envs();
1244        unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1245
1246        assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1247        assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1248        assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1249        assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1250
1251        clear_known_envs();
1252
1253        unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1254        assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1255        clear_known_envs();
1256    }
1257
1258    #[test]
1259    fn fireworks_env_aliases_resolve() {
1260        let _lock = env_lock();
1261        clear_known_envs();
1262        // Safety: env mutation guarded by env_lock().
1263        unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1264
1265        assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1266        assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1267        // Safety: env mutation guarded by env_lock().
1268        unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1269    }
1270
1271    #[test]
1272    fn siliconflow_env_aliases_resolve() {
1273        let _lock = env_lock();
1274        clear_known_envs();
1275        // Safety: env mutation guarded by env_lock().
1276        unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
1277
1278        assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
1279        assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
1280        assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
1281        assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
1282        assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
1283        // Safety: env mutation guarded by env_lock().
1284        unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
1285    }
1286
1287    #[test]
1288    fn arcee_env_aliases_resolve() {
1289        let _lock = env_lock();
1290        clear_known_envs();
1291        // Safety: env mutation guarded by env_lock().
1292        unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
1293
1294        assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
1295        assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
1296        assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
1297        // Safety: env mutation guarded by env_lock().
1298        unsafe { std::env::remove_var("ARCEE_API_KEY") };
1299    }
1300
1301    #[test]
1302    fn moonshot_kimi_env_aliases_resolve() {
1303        let _lock = env_lock();
1304        clear_known_envs();
1305        // Safety: env mutation guarded by env_lock().
1306        unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
1307
1308        assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
1309        assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
1310        assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
1311        assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
1312        // Safety: env mutation guarded by env_lock().
1313        unsafe { std::env::remove_var("KIMI_API_KEY") };
1314    }
1315
1316    #[test]
1317    fn sglang_env_aliases_resolve() {
1318        let _lock = env_lock();
1319        clear_known_envs();
1320        // Safety: env mutation guarded by env_lock().
1321        unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
1322
1323        assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
1324        assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
1325        // Safety: env mutation guarded by env_lock().
1326        unsafe { std::env::remove_var("SGLANG_API_KEY") };
1327    }
1328
1329    #[test]
1330    fn vllm_env_aliases_resolve() {
1331        let _lock = env_lock();
1332        clear_known_envs();
1333        // Safety: env mutation guarded by env_lock().
1334        unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
1335
1336        assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
1337        assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
1338        // Safety: env mutation guarded by env_lock().
1339        unsafe { std::env::remove_var("VLLM_API_KEY") };
1340    }
1341
1342    #[test]
1343    fn ollama_env_aliases_resolve() {
1344        let _lock = env_lock();
1345        clear_known_envs();
1346        // Safety: env mutation guarded by env_lock().
1347        unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
1348
1349        assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
1350        assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
1351        // Safety: env mutation guarded by env_lock().
1352        unsafe { std::env::remove_var("OLLAMA_API_KEY") };
1353    }
1354
1355    #[cfg(unix)]
1356    #[test]
1357    fn file_store_round_trips_with_secure_perms() {
1358        use std::os::unix::fs::PermissionsExt;
1359
1360        let tmp = tempfile::tempdir().unwrap();
1361        let path = tmp.path().join("nested").join("secrets.json");
1362        let store = FileKeyringStore::new(path.clone());
1363        assert_eq!(store.get("deepseek").unwrap(), None);
1364        store.set("deepseek", "sk-disk").unwrap();
1365        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1366
1367        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1368        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
1369
1370        store.set("openrouter", "or-disk").unwrap();
1371        assert_eq!(
1372            store.get("openrouter").unwrap(),
1373            Some("or-disk".to_string())
1374        );
1375        // First entry must still be intact.
1376        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1377
1378        store.delete("deepseek").unwrap();
1379        assert_eq!(store.get("deepseek").unwrap(), None);
1380    }
1381
1382    #[cfg(unix)]
1383    #[test]
1384    fn file_store_rejects_world_readable_file() {
1385        use std::os::unix::fs::PermissionsExt;
1386        let tmp = tempfile::tempdir().unwrap();
1387        let path = tmp.path().join("secrets.json");
1388        fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
1389        let mut perms = fs::metadata(&path).unwrap().permissions();
1390        perms.set_mode(0o644);
1391        fs::set_permissions(&path, perms).unwrap();
1392
1393        let store = FileKeyringStore::new(path);
1394        let err = store.get("deepseek").unwrap_err();
1395        assert!(
1396            matches!(err, SecretsError::InsecurePermissions { .. }),
1397            "unexpected error: {err}"
1398        );
1399    }
1400
1401    // Regression for #281: `set` and `delete` used to call
1402    // `load_unlocked().unwrap_or_default()`, which silently wiped every
1403    // existing secret whenever the read failed (insecure permissions,
1404    // corrupt JSON, or any other I/O error).
1405
1406    #[cfg(unix)]
1407    #[test]
1408    fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
1409        use std::os::unix::fs::PermissionsExt;
1410        let tmp = tempfile::tempdir().unwrap();
1411        let path = tmp.path().join("secrets.json");
1412        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1413        fs::write(&path, original).unwrap();
1414        let mut perms = fs::metadata(&path).unwrap().permissions();
1415        perms.set_mode(0o644);
1416        fs::set_permissions(&path, perms).unwrap();
1417
1418        let store = FileKeyringStore::new(path.clone());
1419        let err = store.set("openrouter", "or-new").unwrap_err();
1420        assert!(
1421            matches!(err, SecretsError::InsecurePermissions { .. }),
1422            "set must surface the read error rather than overwriting; got: {err}"
1423        );
1424
1425        let on_disk = fs::read_to_string(&path).unwrap();
1426        assert_eq!(
1427            on_disk, original,
1428            "set must not modify the file when load_unlocked errored"
1429        );
1430    }
1431
1432    #[cfg(unix)]
1433    #[test]
1434    fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
1435        use std::os::unix::fs::PermissionsExt;
1436        let tmp = tempfile::tempdir().unwrap();
1437        let path = tmp.path().join("secrets.json");
1438        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1439        fs::write(&path, original).unwrap();
1440        let mut perms = fs::metadata(&path).unwrap().permissions();
1441        perms.set_mode(0o644);
1442        fs::set_permissions(&path, perms).unwrap();
1443
1444        let store = FileKeyringStore::new(path.clone());
1445        let err = store.delete("nvidia").unwrap_err();
1446        assert!(
1447            matches!(err, SecretsError::InsecurePermissions { .. }),
1448            "delete must surface the read error rather than wiping the file; got: {err}"
1449        );
1450        let on_disk = fs::read_to_string(&path).unwrap();
1451        assert_eq!(on_disk, original);
1452    }
1453
1454    #[test]
1455    fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
1456        let tmp = tempfile::tempdir().unwrap();
1457        let path = tmp.path().join("secrets.json");
1458        // Corrupt JSON. Permissions ok where unix; on Windows the perm-check
1459        // doesn't run so we exercise the json-error path directly.
1460        fs::write(&path, "{ this is not valid json").unwrap();
1461        #[cfg(unix)]
1462        {
1463            use std::os::unix::fs::PermissionsExt;
1464            let mut perms = fs::metadata(&path).unwrap().permissions();
1465            perms.set_mode(0o600);
1466            fs::set_permissions(&path, perms).unwrap();
1467        }
1468
1469        let store = FileKeyringStore::new(path.clone());
1470        let err = store.set("deepseek", "sk-new").unwrap_err();
1471        assert!(
1472            matches!(err, SecretsError::Json(_)),
1473            "set must surface the parse error rather than wiping the file; got: {err}"
1474        );
1475        let on_disk = fs::read_to_string(&path).unwrap();
1476        assert_eq!(on_disk, "{ this is not valid json");
1477    }
1478
1479    #[test]
1480    fn file_store_set_still_creates_file_when_missing() {
1481        // Regression guard: the #281 fix removed `unwrap_or_default()` from
1482        // the load call. Make sure the original first-write-creates-the-file
1483        // ergonomic still works — `load_unlocked` returns `Ok(default)` for
1484        // a missing file, so the `?` should pass through cleanly.
1485        let tmp = tempfile::tempdir().unwrap();
1486        let path = tmp.path().join("nested").join("secrets.json");
1487        let store = FileKeyringStore::new(path.clone());
1488
1489        store.set("deepseek", "sk-fresh").unwrap();
1490        assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
1491    }
1492
1493    #[test]
1494    fn file_store_default_path_uses_home() {
1495        let _lock = env_lock();
1496        clear_known_envs();
1497        let tmp = tempfile::tempdir().unwrap();
1498        let _home = EnvVarGuard::set("HOME", tmp.path());
1499        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1500
1501        let path = FileKeyringStore::default_path().unwrap();
1502        assert_eq!(
1503            path,
1504            tmp.path()
1505                .join(".codewhale")
1506                .join("secrets")
1507                .join("secrets.json")
1508        );
1509    }
1510}