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