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    /// A caller attempted to modify a diagnostic-only secret store.
54    #[error("secret store is read-only")]
55    ReadOnly,
56}
57
58/// Abstract secret store trait.
59///
60/// Concrete implementations may use the OS keyring ([`DefaultKeyringStore`]),
61/// a JSON file under `~/.codewhale/secrets/` ([`FileKeyringStore`]), or an
62/// in-memory map for tests ([`InMemoryKeyringStore`]).
63///
64/// All implementations must be [`Send`] + [`Sync`] so they can be shared
65/// across threads via [`Arc`].
66pub trait KeyringStore: Send + Sync {
67    /// Read a secret by key.
68    ///
69    /// Returns `Ok(None)` if no entry exists for the given key. Returns
70    /// `Err` only on backend failures (I/O errors, keyring access issues).
71    fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
72
73    /// Write a secret, replacing any existing value for the same key.
74    ///
75    /// Creates the backing store (e.g. the JSON file) on first write if
76    /// it does not yet exist.
77    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
78
79    /// Remove a secret by key.
80    ///
81    /// Implementations should succeed (no-op) if the entry is already absent
82    /// rather than returning an error.
83    fn delete(&self, key: &str) -> Result<(), SecretsError>;
84
85    /// Short, human-readable label for this backend.
86    ///
87    /// Used by diagnostic output (e.g. `doctor` command) to indicate which
88    /// storage backend is active. Examples: `"file-based (~/.codewhale/secrets/)"`,
89    /// `"system keyring"`, `"in-memory (test)"`.
90    fn backend_name(&self) -> &'static str;
91}
92
93/// OS-native keyring backend.
94///
95/// Wraps the platform credential store:
96/// - **macOS**: Keychain (via `security` framework)
97/// - **Windows**: Credential Manager
98/// - **Linux**: Secret Service (GNOME Keyring / kwallet via dbus), excluding OHOS
99///
100/// This backend is opt-in -- set the [`SECRET_BACKEND_ENV`] environment
101/// variable to `system` or `keyring` to activate it. On platforms without
102/// a configured native keyring dependency, [`probe`](DefaultKeyringStore::probe)
103/// returns an unsupported error so [`Secrets::auto_detect`] can transparently
104/// fall back to [`FileKeyringStore`].
105#[derive(Debug, Clone)]
106pub struct DefaultKeyringStore {
107    /// Keyring service name used to namespace stored credentials.
108    /// Defaults to [`DEFAULT_SERVICE`].
109    service: String,
110}
111
112impl Default for DefaultKeyringStore {
113    fn default() -> Self {
114        Self::new(DEFAULT_SERVICE)
115    }
116}
117
118impl DefaultKeyringStore {
119    /// Build a new store with the given service name.
120    #[must_use]
121    pub fn new(service: impl Into<String>) -> Self {
122        Self {
123            service: service.into(),
124        }
125    }
126
127    /// Probe the OS keyring without writing anything. Returns `Ok(())` if
128    /// a backend is reachable, otherwise an error describing why not.
129    pub fn probe(&self) -> Result<(), SecretsError> {
130        #[cfg(any(
131            target_os = "macos",
132            target_os = "windows",
133            all(
134                target_os = "linux",
135                not(target_env = "ohos"),
136                not(target_env = "musl")
137            )
138        ))]
139        {
140            // `Entry::new` is enough to validate the native macOS/Windows
141            // backend path. Avoid a dummy read there because it can trigger
142            // a second user-visible Keychain/Credential Manager access before
143            // the real provider key lookup.
144            let entry = keyring::Entry::new(&self.service, "__probe__")
145                .map_err(|err| SecretsError::Keyring(err.to_string()))?;
146            #[cfg(any(target_os = "macos", target_os = "windows"))]
147            {
148                let _ = entry;
149                Ok(())
150            }
151            #[cfg(not(any(target_os = "macos", target_os = "windows")))]
152            match entry.get_password() {
153                Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
154                Err(keyring::Error::PlatformFailure(err)) => {
155                    Err(SecretsError::Keyring(format!("platform failure: {err}")))
156                }
157                Err(keyring::Error::NoStorageAccess(err)) => {
158                    Err(SecretsError::Keyring(format!("no storage access: {err}")))
159                }
160                Err(other) => Err(SecretsError::Keyring(other.to_string())),
161            }
162        }
163        #[cfg(not(any(
164            target_os = "macos",
165            target_os = "windows",
166            all(
167                target_os = "linux",
168                not(target_env = "ohos"),
169                not(target_env = "musl")
170            )
171        )))]
172        {
173            let _ = &self.service;
174            Err(SecretsError::Keyring(unsupported_keyring_message()))
175        }
176    }
177}
178
179impl KeyringStore for DefaultKeyringStore {
180    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
181        #[cfg(any(
182            target_os = "macos",
183            target_os = "windows",
184            all(
185                target_os = "linux",
186                not(target_env = "ohos"),
187                not(target_env = "musl")
188            )
189        ))]
190        {
191            let entry = keyring::Entry::new(&self.service, key)
192                .map_err(|err| SecretsError::Keyring(err.to_string()))?;
193            match entry.get_password() {
194                Ok(value) => Ok(Some(value)),
195                Err(keyring::Error::NoEntry) => Ok(None),
196                Err(err) => Err(SecretsError::Keyring(err.to_string())),
197            }
198        }
199        #[cfg(not(any(
200            target_os = "macos",
201            target_os = "windows",
202            all(
203                target_os = "linux",
204                not(target_env = "ohos"),
205                not(target_env = "musl")
206            )
207        )))]
208        {
209            let _ = key;
210            Err(SecretsError::Keyring(unsupported_keyring_message()))
211        }
212    }
213
214    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
215        #[cfg(any(
216            target_os = "macos",
217            target_os = "windows",
218            all(
219                target_os = "linux",
220                not(target_env = "ohos"),
221                not(target_env = "musl")
222            )
223        ))]
224        {
225            let entry = keyring::Entry::new(&self.service, key)
226                .map_err(|err| SecretsError::Keyring(err.to_string()))?;
227            entry
228                .set_password(value)
229                .map_err(|err| SecretsError::Keyring(err.to_string()))
230        }
231        #[cfg(not(any(
232            target_os = "macos",
233            target_os = "windows",
234            all(
235                target_os = "linux",
236                not(target_env = "ohos"),
237                not(target_env = "musl")
238            )
239        )))]
240        {
241            let _ = (key, value);
242            Err(SecretsError::Keyring(unsupported_keyring_message()))
243        }
244    }
245
246    fn delete(&self, key: &str) -> Result<(), SecretsError> {
247        #[cfg(any(
248            target_os = "macos",
249            target_os = "windows",
250            all(
251                target_os = "linux",
252                not(target_env = "ohos"),
253                not(target_env = "musl")
254            )
255        ))]
256        {
257            let entry = keyring::Entry::new(&self.service, key)
258                .map_err(|err| SecretsError::Keyring(err.to_string()))?;
259            match entry.delete_credential() {
260                Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
261                Err(err) => Err(SecretsError::Keyring(err.to_string())),
262            }
263        }
264        #[cfg(not(any(
265            target_os = "macos",
266            target_os = "windows",
267            all(
268                target_os = "linux",
269                not(target_env = "ohos"),
270                not(target_env = "musl")
271            )
272        )))]
273        {
274            let _ = key;
275            Err(SecretsError::Keyring(unsupported_keyring_message()))
276        }
277    }
278
279    fn backend_name(&self) -> &'static str {
280        "system keyring"
281    }
282}
283
284#[cfg(not(any(
285    target_os = "macos",
286    target_os = "windows",
287    all(
288        target_os = "linux",
289        not(target_env = "ohos"),
290        not(target_env = "musl")
291    )
292)))]
293fn unsupported_keyring_message() -> String {
294    "system keyring backend is unsupported on this platform".to_string()
295}
296
297/// In-memory keyring store for tests.
298///
299/// Stores secrets in a [`HashMap`] protected by a [`Mutex`]. Not persisted
300/// to disk -- all entries are lost when the process exits. This is the
301/// preferred store for unit tests because it requires no filesystem setup
302/// and is safe to use in parallel test threads.
303#[derive(Debug, Default)]
304pub struct InMemoryKeyringStore {
305    /// Thread-safe map of key-value pairs.
306    entries: Mutex<HashMap<String, String>>,
307}
308
309impl InMemoryKeyringStore {
310    /// Create an empty store.
311    #[must_use]
312    pub fn new() -> Self {
313        Self::default()
314    }
315}
316
317impl KeyringStore for InMemoryKeyringStore {
318    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
319        let guard = self.entries.lock().map_err(|e| {
320            SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
321        })?;
322        Ok(guard.get(key).cloned())
323    }
324
325    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
326        let mut guard = self.entries.lock().map_err(|e| {
327            SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
328        })?;
329        guard.insert(key.to_string(), value.to_string());
330        Ok(())
331    }
332
333    fn delete(&self, key: &str) -> Result<(), SecretsError> {
334        let mut guard = self.entries.lock().map_err(|e| {
335            SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
336        })?;
337        guard.remove(key);
338        Ok(())
339    }
340
341    fn backend_name(&self) -> &'static str {
342        "in-memory (test)"
343    }
344}
345
346/// JSON-on-disk secret store for headless environments.
347///
348/// This is the default backend. Secrets are serialised as a JSON object
349/// at `<home>/.codewhale/secrets/secrets.json` with Unix file mode `0600`
350/// (owner read/write only). The parent directory is created with mode `0700`
351/// if it does not exist.
352///
353/// On Unix, the store rejects files whose permissions are more permissive
354/// than `0600` (i.e. group or world bits are set). This prevents other
355/// users on the system from reading stored credentials. On Windows, the
356/// ACL model is too different to enforce programmatically; callers are
357/// responsible for placing the file in a per-user directory.
358#[derive(Debug, Clone)]
359pub struct FileKeyringStore {
360    /// Absolute path to the JSON secrets file.
361    path: PathBuf,
362}
363
364/// File-backed secret lookup that never migrates or changes either store.
365///
366/// Normal runtime credential resolution keeps its additive legacy migration:
367/// older entries under `~/.deepseek/secrets/` are copied into the Codewhale
368/// location before use. Diagnostic commands need the same read precedence
369/// without creating that destination, so this store reads the primary file
370/// first and falls back to the legacy file only when the primary has no entry
371/// and the Codewhale home is not explicitly isolated.
372#[derive(Debug, Clone)]
373struct ReadOnlyFileKeyringStore {
374    primary: FileKeyringStore,
375    /// The ambient legacy store is unavailable when `CODEWHALE_HOME` is an
376    /// explicit isolation boundary.
377    legacy: Option<FileKeyringStore>,
378}
379
380#[derive(Debug, Default, Serialize, Deserialize)]
381struct FileSecretsBlob {
382    #[serde(default)]
383    entries: HashMap<String, String>,
384}
385
386impl FileKeyringStore {
387    /// Build a store backed by the given JSON file path.
388    #[must_use]
389    pub fn new(path: impl Into<PathBuf>) -> Self {
390        Self { path: path.into() }
391    }
392
393    /// Default path: `<home>/.codewhale/secrets/secrets.json`. Honours
394    /// `CODEWHALE_HOME`, then `HOME`, `USERPROFILE`, and finally the platform
395    /// home directory from the `dirs` crate. On first use, non-conflicting
396    /// entries from the legacy `<home>/.deepseek/secrets/secrets.json` file are
397    /// copied into the CodeWhale store — unless `CODEWHALE_HOME` is explicit,
398    /// in which case ambient `$HOME/.deepseek` credentials are never imported.
399    pub fn default_path() -> Result<PathBuf, SecretsError> {
400        let primary = default_codewhale_secrets_path()?;
401        // Match the diagnostic isolation boundary: an explicit Codewhale home
402        // must not silently pull ambient legacy DeepSeek credentials.
403        if !codewhale_home_is_explicit() {
404            match legacy_deepseek_secrets_path() {
405                Ok(legacy) => {
406                    if let Err(err) = Self::migrate_legacy_file_if_needed(&primary, &legacy) {
407                        tracing::warn!(
408                            "could not migrate legacy secret store from {} to {}: {err}",
409                            legacy.display(),
410                            primary.display()
411                        );
412                    }
413                }
414                Err(err) => {
415                    tracing::warn!("could not resolve legacy secret store path: {err}");
416                }
417            }
418        }
419        Ok(primary)
420    }
421
422    /// Resolve the primary and legacy secret paths without performing legacy
423    /// migration.
424    ///
425    /// This is intended for diagnostic-only lookup. Runtime and authentication
426    /// flows must keep using [`Self::default_path`] so their existing additive
427    /// migration behavior remains unchanged.
428    pub fn default_paths_read_only() -> Result<(PathBuf, Option<PathBuf>), SecretsError> {
429        let primary = default_codewhale_secrets_path()?;
430        let legacy = (!codewhale_home_is_explicit())
431            .then(legacy_deepseek_secrets_path)
432            .transpose()?;
433        Ok((primary, legacy))
434    }
435
436    fn migrate_legacy_file_if_needed(primary: &Path, legacy: &Path) -> Result<(), SecretsError> {
437        if !legacy.exists() {
438            return Ok(());
439        }
440
441        let legacy_store = Self::new(legacy.to_path_buf());
442        let legacy_blob = legacy_store.load_unlocked()?;
443        if legacy_blob.entries.is_empty() {
444            return Ok(());
445        }
446
447        let primary_store = Self::new(primary.to_path_buf());
448        let mut primary_blob = primary_store.load_unlocked()?;
449        let mut changed = false;
450        for (key, value) in legacy_blob.entries {
451            if let std::collections::hash_map::Entry::Vacant(entry) =
452                primary_blob.entries.entry(key)
453            {
454                entry.insert(value);
455                changed = true;
456            }
457        }
458        if changed {
459            primary_store.store_unlocked(&primary_blob)?;
460        }
461        Ok(())
462    }
463
464    fn home_dir() -> Result<PathBuf, SecretsError> {
465        for var in ["HOME", "USERPROFILE"] {
466            if let Ok(value) = std::env::var(var) {
467                let trimmed = value.trim();
468                if !trimmed.is_empty() {
469                    return Ok(PathBuf::from(trimmed));
470                }
471            }
472        }
473
474        dirs::home_dir().ok_or_else(|| {
475            SecretsError::Io(std::io::Error::new(
476                std::io::ErrorKind::NotFound,
477                "could not resolve home directory for FileKeyringStore",
478            ))
479        })
480    }
481
482    /// Path used for storage.
483    #[must_use]
484    pub fn path(&self) -> &Path {
485        &self.path
486    }
487
488    fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
489        if !self.path.exists() {
490            return Ok(FileSecretsBlob::default());
491        }
492        // Reject files with unsafe permissions on unix. On Windows the
493        // ACL model is too different to enforce here; the caller is
494        // responsible for placing the file in a per-user directory.
495        #[cfg(unix)]
496        {
497            use std::os::unix::fs::PermissionsExt;
498            let meta = fs::metadata(&self.path)?;
499            let mode = meta.permissions().mode() & 0o777;
500            if mode & 0o077 != 0 {
501                return Err(SecretsError::InsecurePermissions {
502                    path: self.path.clone(),
503                    mode,
504                });
505            }
506        }
507        let raw = fs::read_to_string(&self.path)?;
508        if raw.trim().is_empty() {
509            return Ok(FileSecretsBlob::default());
510        }
511        let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
512        Ok(blob)
513    }
514
515    fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
516        if let Some(parent) = self.path.parent() {
517            fs::create_dir_all(parent)?;
518            #[cfg(unix)]
519            {
520                use std::os::unix::fs::PermissionsExt;
521                let mut perms = fs::metadata(parent)?.permissions();
522                perms.set_mode(0o700);
523                let _ = fs::set_permissions(parent, perms);
524            }
525        }
526        let body = serde_json::to_string_pretty(blob)?;
527        write_private_file(&self.path, body.as_bytes())?;
528        #[cfg(unix)]
529        {
530            use std::os::unix::fs::PermissionsExt;
531            // Best-effort 0o600 — matches the parent-dir chmod above which
532            // is also `let _ = ...`. Filesystems that don't support Unix
533            // chmod (Docker bind-mounts of NTFS, network shares — #897)
534            // would otherwise fail the whole save here even though the
535            // blob already wrote successfully. The host's native ACLs
536            // are doing access control in those environments.
537            if let Ok(meta) = fs::metadata(&self.path) {
538                let mut perms = meta.permissions();
539                perms.set_mode(0o600);
540                let _ = fs::set_permissions(&self.path, perms);
541            }
542        }
543        Ok(())
544    }
545}
546
547impl ReadOnlyFileKeyringStore {
548    fn default_for_diagnostics() -> Result<Self, SecretsError> {
549        let (primary, legacy) = FileKeyringStore::default_paths_read_only()?;
550        Ok(Self::new(primary, legacy))
551    }
552
553    fn new(primary: impl Into<PathBuf>, legacy: Option<PathBuf>) -> Self {
554        Self {
555            primary: FileKeyringStore::new(primary),
556            legacy: legacy.map(FileKeyringStore::new),
557        }
558    }
559}
560
561impl KeyringStore for ReadOnlyFileKeyringStore {
562    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
563        match self.primary.get(key)? {
564            Some(value) => Ok(Some(value)),
565            None => self
566                .legacy
567                .as_ref()
568                .map_or(Ok(None), |legacy| legacy.get(key)),
569        }
570    }
571
572    fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
573        Err(SecretsError::ReadOnly)
574    }
575
576    fn delete(&self, _key: &str) -> Result<(), SecretsError> {
577        Err(SecretsError::ReadOnly)
578    }
579
580    fn backend_name(&self) -> &'static str {
581        FILE_BACKEND_LABEL
582    }
583}
584
585#[derive(Clone)]
586struct ReadOnlyKeyringStore {
587    inner: Arc<dyn KeyringStore>,
588}
589
590impl ReadOnlyKeyringStore {
591    fn new(inner: Arc<dyn KeyringStore>) -> Self {
592        Self { inner }
593    }
594}
595
596impl KeyringStore for ReadOnlyKeyringStore {
597    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
598        self.inner.get(key)
599    }
600
601    fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
602        Err(SecretsError::ReadOnly)
603    }
604
605    fn delete(&self, _key: &str) -> Result<(), SecretsError> {
606        Err(SecretsError::ReadOnly)
607    }
608
609    fn backend_name(&self) -> &'static str {
610        self.inner.backend_name()
611    }
612}
613
614fn write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
615    atomic_write_private_file(path, body)
616}
617
618fn atomic_write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
619    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
620        fs::create_dir_all(parent)?;
621    }
622    let dir = path
623        .parent()
624        .filter(|p| !p.as_os_str().is_empty())
625        .unwrap_or_else(|| Path::new("."));
626    let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(SecretsError::Io)?;
627    use std::io::Write as _;
628    tmp.write_all(body).map_err(SecretsError::Io)?;
629    tmp.flush().map_err(SecretsError::Io)?;
630    tmp.as_file().sync_all().map_err(SecretsError::Io)?;
631    #[cfg(unix)]
632    {
633        use std::os::unix::fs::PermissionsExt;
634        let perms = fs::Permissions::from_mode(0o600);
635        tmp.as_file()
636            .set_permissions(perms)
637            .map_err(SecretsError::Io)?;
638    }
639    tmp.persist(path).map_err(|e| SecretsError::Io(e.error))?;
640    Ok(())
641}
642
643impl KeyringStore for FileKeyringStore {
644    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
645        let blob = self.load_unlocked()?;
646        Ok(blob.entries.get(key).cloned())
647    }
648
649    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
650        // load_unlocked already returns Ok(default) for a missing file, so the
651        // first-write-creates-the-file path is preserved. Any other Err
652        // (insecure permissions, corrupt JSON, transient I/O) MUST surface to
653        // the caller — propagating it via `unwrap_or_default()` silently
654        // wipes every previously stored secret on the next `store_unlocked`.
655        let mut blob = self.load_unlocked()?;
656        blob.entries.insert(key.to_string(), value.to_string());
657        self.store_unlocked(&blob)
658    }
659
660    fn delete(&self, key: &str) -> Result<(), SecretsError> {
661        // Same invariant as `set`: never fall back to an empty blob on read
662        // error, or `delete <one-key>` becomes `delete <every-key>`.
663        let mut blob = self.load_unlocked()?;
664        blob.entries.remove(key);
665        self.store_unlocked(&blob)
666    }
667
668    fn backend_name(&self) -> &'static str {
669        FILE_BACKEND_LABEL
670    }
671}
672
673fn default_codewhale_secrets_path() -> Result<PathBuf, SecretsError> {
674    if let Ok(value) = std::env::var("CODEWHALE_HOME") {
675        let trimmed = value.trim();
676        if !trimmed.is_empty() {
677            return Ok(PathBuf::from(trimmed).join("secrets").join("secrets.json"));
678        }
679    }
680    Ok(FileKeyringStore::home_dir()?
681        .join(".codewhale")
682        .join("secrets")
683        .join("secrets.json"))
684}
685
686fn legacy_deepseek_secrets_path() -> Result<PathBuf, SecretsError> {
687    Ok(FileKeyringStore::home_dir()?
688        .join(".deepseek")
689        .join("secrets")
690        .join("secrets.json"))
691}
692
693/// Match the state/config isolation boundary: an explicit Codewhale home must
694/// not fall back to ambient legacy data under `$HOME/.deepseek`.
695fn codewhale_home_is_explicit() -> bool {
696    std::env::var("CODEWHALE_HOME").is_ok_and(|value| !value.trim().is_empty())
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700enum SecretBackendSelection {
701    File,
702    System,
703    Unknown,
704}
705
706fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
707    match value.map(str::trim).filter(|value| !value.is_empty()) {
708        None => SecretBackendSelection::File,
709        Some(value) => match value.to_ascii_lowercase().as_str() {
710            "file" | "local" | "json" => SecretBackendSelection::File,
711            "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
712            _ => SecretBackendSelection::Unknown,
713        },
714    }
715}
716
717fn configured_secret_backend() -> Option<String> {
718    std::env::var(SECRET_BACKEND_ENV)
719        .ok()
720        .filter(|value| !value.trim().is_empty())
721        .or_else(|| std::env::var(LEGACY_SECRET_BACKEND_ENV).ok())
722}
723
724/// High-level facade combining a [`KeyringStore`] with environment variable fallbacks.
725///
726/// Lookup precedence: **secret store -> env -> none**. Callers that also
727/// have a TOML config layer must wire that themselves at the very end
728/// of the chain (the config crate handles this).
729///
730/// # Examples
731///
732/// ```no_run
733/// use codewhale_secrets::Secrets;
734///
735/// let secrets = Secrets::auto_detect();
736/// if let Some(key) = secrets.resolve("deepseek") {
737///     // use the API key
738/// }
739/// ```
740#[derive(Clone)]
741pub struct Secrets {
742    /// Underlying secret store backend.
743    pub store: Arc<dyn KeyringStore>,
744    /// Owner identifier within the secret store (typically `"deepseek"`).
745    /// The `key` parameter passed to [`resolve`](Secrets::resolve) is
746    /// forwarded to the store as-is, while environment variables are
747    /// looked up by canonical provider name via [`env_for`].
748    service: String,
749}
750
751/// Identifies which layer in the resolution chain supplied a secret.
752///
753/// Returned by [`Secrets::resolve_with_source`] so callers can
754/// distinguish whether a value came from the configured store or from
755/// a process environment variable.
756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757pub enum SecretSource {
758    /// The secret was returned by the configured [`KeyringStore`] backend.
759    Keyring,
760    /// The secret was found in a process environment variable.
761    Env,
762}
763
764impl std::fmt::Debug for Secrets {
765    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
766        f.debug_struct("Secrets")
767            .field("backend", &self.store.backend_name())
768            .field("service", &self.service)
769            .finish()
770    }
771}
772
773impl Secrets {
774    /// Build a new facade around the given store, using the
775    /// [`DEFAULT_SERVICE`] service name.
776    #[must_use]
777    pub fn new(store: Arc<dyn KeyringStore>) -> Self {
778        Self {
779            store,
780            service: DEFAULT_SERVICE.to_string(),
781        }
782    }
783
784    /// Auto-detect the best available backend based on the environment.
785    ///
786    /// Selection logic:
787    /// 1. If [`SECRET_BACKEND_ENV`] is set to `system`/`keyring`/`os`/`os-keyring`,
788    ///    probe the OS keyring. If the probe succeeds, use it; otherwise
789    ///    fall back to the file-based store with a warning.
790    /// 2. If the env var is unset, empty, or `file`/`local`/`json`, use
791    ///    the file-based store directly.
792    /// 3. If the env var is set to an unrecognised value, log a warning
793    ///    and use the file-based store.
794    pub fn auto_detect() -> Self {
795        match secret_backend_selection(configured_secret_backend().as_deref()) {
796            SecretBackendSelection::File => Self::file_backed_default(),
797            SecretBackendSelection::Unknown => {
798                tracing::warn!(
799                    "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
800                );
801                Self::file_backed_default()
802            }
803            SecretBackendSelection::System => {
804                let default_store = DefaultKeyringStore::default();
805                match default_store.probe() {
806                    Ok(()) => Self::new(Arc::new(default_store)),
807                    Err(err) => {
808                        tracing::warn!(
809                            "OS keyring unavailable ({err}); falling back to file-backed secret store"
810                        );
811                        Self::file_backed_default()
812                    }
813                }
814            }
815        }
816    }
817
818    /// Auto-detect a secret backend for diagnostics without permitting writes
819    /// or legacy migration.
820    ///
821    /// The selected backend and lookup precedence match [`Self::auto_detect`],
822    /// but file-backed lookup reads the Codewhale location first and the legacy
823    /// location second instead of copying legacy entries into a new file. This
824    /// lets status and doctor reports label a saved credential without changing
825    /// user state.
826    #[must_use]
827    pub fn auto_detect_read_only() -> Self {
828        match secret_backend_selection(configured_secret_backend().as_deref()) {
829            SecretBackendSelection::File => Self::file_backed_read_only(),
830            SecretBackendSelection::Unknown => {
831                tracing::warn!(
832                    "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
833                );
834                Self::file_backed_read_only()
835            }
836            SecretBackendSelection::System => {
837                let default_store = DefaultKeyringStore::default();
838                match default_store.probe() {
839                    Ok(()) => {
840                        Self::new(Arc::new(ReadOnlyKeyringStore::new(Arc::new(default_store))))
841                    }
842                    Err(err) => {
843                        tracing::warn!(
844                            "OS keyring unavailable ({err}); falling back to file-backed secret store"
845                        );
846                        Self::file_backed_read_only()
847                    }
848                }
849            }
850        }
851    }
852
853    fn file_backed_default() -> Self {
854        Self::file_backed_from_default_path(FileKeyringStore::default_path())
855    }
856
857    /// Build the writable default store only when the resolved path is safe.
858    ///
859    /// Keeping the resolution result as an argument gives the no-home and
860    /// relative-path branches direct regression coverage. Both must refuse
861    /// writes rather than placing credentials in the caller's workspace.
862    fn file_backed_from_default_path(path_result: Result<PathBuf, SecretsError>) -> Self {
863        // Never fall back to a workspace-relative secrets path. Writing
864        // credential material beside the cwd is readable by tools and easy to
865        // commit. If home resolution fails, use a write-refusing store.
866        match path_result {
867            Ok(path) if path.is_absolute() => Self::new(Arc::new(FileKeyringStore::new(path))),
868            Ok(path) => {
869                tracing::error!(
870                    "refusing relative file-backed secret path {}; credentials will not be read or persisted",
871                    path.display()
872                );
873                Self::read_only_empty_store()
874            }
875            Err(err) => {
876                tracing::error!(
877                    "could not resolve file-backed secret path ({err}); credentials will not be read or persisted"
878                );
879                Self::read_only_empty_store()
880            }
881        }
882    }
883
884    /// An unavailable default path must be hermetic: neither inspect an
885    /// accidental workspace file nor create one.  The read-only wrapper keeps
886    /// the public API's write failure explicit while reads safely report empty.
887    fn read_only_empty_store() -> Self {
888        Self::new(Arc::new(ReadOnlyKeyringStore::new(Arc::new(
889            InMemoryKeyringStore::new(),
890        ))))
891    }
892
893    /// Construct a file-backed diagnostic store without migration or write
894    /// capability.
895    ///
896    /// This reads the Codewhale file first and the legacy file second (unless
897    /// `CODEWHALE_HOME` is explicit), but never copies legacy entries into a
898    /// primary store. It intentionally bypasses an opted-in OS keyring so
899    /// callers that only need non-secret diagnostics do not cause a platform
900    /// credential prompt.
901    #[must_use]
902    pub fn file_backed_read_only() -> Self {
903        let store = ReadOnlyFileKeyringStore::default_for_diagnostics().unwrap_or_else(|_| {
904            ReadOnlyFileKeyringStore::new(PathBuf::from(".codewhale-secrets.json"), None)
905        });
906        Self::new(Arc::new(store))
907    }
908
909    /// Construct the file-backed default backend directly.
910    #[must_use]
911    pub fn file_backed() -> Self {
912        Self::file_backed_default()
913    }
914
915    /// Construct the opt-in OS credential backend, falling back to the
916    /// file-backed store when the platform backend is unavailable.
917    #[must_use]
918    pub fn system_keyring() -> Self {
919        let default_store = DefaultKeyringStore::default();
920        match default_store.probe() {
921            Ok(()) => Self::new(Arc::new(default_store)),
922            Err(err) => {
923                tracing::warn!(
924                    "OS keyring unavailable ({err}); falling back to file-backed secret store"
925                );
926                Self::file_backed_default()
927            }
928        }
929    }
930
931    /// Backend label, suitable for `doctor` output.
932    #[must_use]
933    pub fn backend_name(&self) -> &'static str {
934        self.store.backend_name()
935    }
936
937    /// Resolve a secret with `secret store → env → none` precedence.
938    ///
939    /// `name` is the canonical provider name or a supported provider alias.
940    /// Empty strings on either layer are treated as "not set".
941    #[must_use]
942    pub fn resolve(&self, name: &str) -> Option<String> {
943        self.resolve_with_source(name).map(|(value, _)| value)
944    }
945
946    /// Resolve a secret and report which layer supplied it.
947    #[must_use]
948    pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
949        if let Ok(Some(v)) = self.store.get(name)
950            && !v.trim().is_empty()
951        {
952            return Some((v, SecretSource::Keyring));
953        }
954        env_for(name).map(|value| (value, SecretSource::Env))
955    }
956
957    /// Convenience: write a secret through the underlying store.
958    pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
959        self.store.set(name, value)
960    }
961
962    /// Convenience: delete a secret through the underlying store.
963    pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
964        self.store.delete(name)
965    }
966
967    /// Convenience: read a secret directly (no env fallback).
968    pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
969        self.store.get(name)
970    }
971
972    /// Resolve a secret by key name with an optional source constraint.
973    ///
974    /// This is the fleet-worker secret resolution path. Unlike
975    /// [`resolve`](Secrets::resolve), this does NOT map provider names
976    /// to their canonical env vars — the caller controls the exact key
977    /// and resolution order.
978    ///
979    /// `source_hint` controls the resolution order:
980    /// - `Some("env")` — only check environment variables
981    /// - `Some("keyring")` — only check the keyring/file store
982    /// - `None` — try the store first, then fall back to environment
983    #[must_use]
984    pub fn resolve_direct(&self, key: &str, source_hint: Option<&str>) -> Option<String> {
985        match source_hint {
986            Some("env") => {
987                // Only check process environment — skip the store entirely.
988                std::env::var(key).ok().filter(|v| !v.trim().is_empty())
989            }
990            Some("keyring") | Some("file") => {
991                // Only check the store backend.
992                self.store
993                    .get(key)
994                    .ok()
995                    .flatten()
996                    .filter(|v| !v.trim().is_empty())
997            }
998            Some(_) | None => {
999                // Default: store first, then env fallback.
1000                if let Ok(Some(v)) = self.store.get(key)
1001                    && !v.trim().is_empty()
1002                {
1003                    return Some(v);
1004                }
1005                std::env::var(key).ok().filter(|v| !v.trim().is_empty())
1006            }
1007        }
1008    }
1009}
1010
1011/// Map a canonical provider name to its environment variable(s), returning
1012/// the first non-empty value found.
1013///
1014/// Provider names are case-insensitive. Supported providers and their
1015/// environment variables:
1016///
1017/// | Provider | Env var(s) |
1018/// |---|---|
1019/// | `deepseek` | `DEEPSEEK_API_KEY` |
1020/// | `openrouter` | `OPENROUTER_API_KEY` |
1021/// | `xiaomi-mimo` / `mimo` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |
1022/// | `novita` / `novita-ai` | `NOVITA_API_KEY` |
1023/// | `nvidia` / `nvidia-nim` / `nim` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY`, `DEEPSEEK_API_KEY` |
1024/// | `fireworks` / `fireworks-ai` | `FIREWORKS_API_KEY` |
1025/// | `together` / `togetherai` | `TOGETHER_API_KEY` |
1026/// | `deepinfra` | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` |
1027/// | `siliconflow` / `siliconflow-cn` | `SILICONFLOW_API_KEY` |
1028/// | `arcee` / `arcee-ai` | `ARCEE_API_KEY` |
1029/// | `moonshot` / `kimi` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |
1030/// | `sglang` | `SGLANG_API_KEY` |
1031/// | `vllm` | `VLLM_API_KEY` |
1032/// | `ollama` | `OLLAMA_API_KEY` |
1033/// | `openai` | `OPENAI_API_KEY` |
1034/// | `atlascloud` / `atlas` | `ATLASCLOUD_API_KEY` |
1035/// | `volcengine` / `ark` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |
1036/// | `wanjie` / `wanjie-ark` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |
1037/// | `meta` / `muse-spark` | `META_MODEL_API_KEY`, `MODEL_API_KEY` |
1038/// | `xai` / `grok` | `XAI_API_KEY` |
1039/// | `telecomjs` / `tokenhub` | `TELECOMJS_API_KEY` |
1040///
1041/// Returns `None` if the provider is not recognised or none of its
1042/// candidate environment variables are set to a non-empty value.
1043#[must_use]
1044pub fn env_for(name: &str) -> Option<String> {
1045    let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
1046        "deepseek" => &["DEEPSEEK_API_KEY"],
1047        "openrouter" => &["OPENROUTER_API_KEY"],
1048        "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
1049            &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
1050        }
1051        "novita" | "novita-ai" | "novita_ai" => &["NOVITA_API_KEY"],
1052        "together" | "together-ai" | "together_ai" | "togetherai" => &["TOGETHER_API_KEY"],
1053        "deepinfra" | "deep-infra" | "deep_infra" => &["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
1054        // NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
1055        // catalog endpoint accepts the same DeepSeek-issued key when no
1056        // dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
1057        "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
1058            &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
1059        }
1060        "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
1061        "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
1062        | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
1063        "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
1064        "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
1065        "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
1066        "vllm" | "v-llm" => &["VLLM_API_KEY"],
1067        "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
1068        "openai" => &["OPENAI_API_KEY"],
1069        "anthropic" | "claude" => &["ANTHROPIC_API_KEY"],
1070        "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
1071        "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
1072        | "volcengineark" => &[
1073            "VOLCENGINE_API_KEY",
1074            "VOLCENGINE_ARK_API_KEY",
1075            "ARK_API_KEY",
1076        ],
1077        "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
1078        | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
1079            "WANJIE_ARK_API_KEY",
1080            "WANJIE_API_KEY",
1081            "WANJIE_MAAS_API_KEY",
1082        ],
1083        "sakana" | "sakana-ai" | "sakana_ai" | "fugu" => &["FUGU_API_KEY", "SAKANA_API_KEY"],
1084        "longcat" | "long-cat" | "meituan-longcat" | "meituan" => &["LONGCAT_API_KEY"],
1085        "opencode-go" | "opencode_go" | "opencodego" => &["OPENCODE_GO_API_KEY"],
1086        "opencode-zen" | "opencode_zen" | "opencodezen" | "zen" | "opencode" => {
1087            &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"]
1088        }
1089        "meta" | "meta-ai" | "meta_ai" | "meta-model-api" | "meta_model_api" | "muse"
1090        | "muse-spark" => &["META_MODEL_API_KEY", "MODEL_API_KEY"],
1091        "xai" | "x-ai" | "x_ai" | "grok" => &["XAI_API_KEY"],
1092        "telecomjs" | "telecom-js" | "telecom_js" | "telecomjs-cn" | "tokenhub" => {
1093            &["TELECOMJS_API_KEY"]
1094        }
1095        _ => return None,
1096    };
1097    for var in candidates {
1098        if let Ok(value) = std::env::var(var)
1099            && !value.trim().is_empty()
1100        {
1101            return Some(value);
1102        }
1103    }
1104    None
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109    use super::*;
1110    use std::sync::{Mutex, OnceLock};
1111
1112    /// Serialise env-mutating tests: tests in this module poke
1113    /// `DEEPSEEK_API_KEY` etc., which is process-global.
1114    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1115        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1116        LOCK.get_or_init(|| Mutex::new(()))
1117            .lock()
1118            .unwrap_or_else(|p| p.into_inner())
1119    }
1120
1121    fn clear_known_envs() {
1122        for var in [
1123            "CODEWHALE_HOME",
1124            "DEEPSEEK_API_KEY",
1125            "OPENROUTER_API_KEY",
1126            "NOVITA_API_KEY",
1127            "NVIDIA_API_KEY",
1128            "NVIDIA_NIM_API_KEY",
1129            "FIREWORKS_API_KEY",
1130            "TOGETHER_API_KEY",
1131            "DEEPINFRA_API_KEY",
1132            "DEEPINFRA_TOKEN",
1133            "SILICONFLOW_API_KEY",
1134            "ARCEE_API_KEY",
1135            "SGLANG_API_KEY",
1136            "VLLM_API_KEY",
1137            "OLLAMA_API_KEY",
1138            "OPENAI_API_KEY",
1139            "ATLASCLOUD_API_KEY",
1140            "WANJIE_ARK_API_KEY",
1141            "WANJIE_API_KEY",
1142            "WANJIE_MAAS_API_KEY",
1143            "XIAOMI_MIMO_API_KEY",
1144            "XIAOMI_API_KEY",
1145            "MIMO_API_KEY",
1146            "FUGU_API_KEY",
1147            "SAKANA_API_KEY",
1148            "LONGCAT_API_KEY",
1149            "OPENCODE_GO_API_KEY",
1150            "OPENCODE_ZEN_API_KEY",
1151            "OPENCODE_API_KEY",
1152            "META_MODEL_API_KEY",
1153            "MODEL_API_KEY",
1154            "XAI_API_KEY",
1155            "TELECOMJS_API_KEY",
1156            SECRET_BACKEND_ENV,
1157            LEGACY_SECRET_BACKEND_ENV,
1158        ] {
1159            // Safety: tests serialise on env_lock(); the broader
1160            // workspace has the same pattern in `crates/config`.
1161            unsafe { std::env::remove_var(var) };
1162        }
1163    }
1164
1165    struct EnvVarGuard {
1166        name: &'static str,
1167        previous: Option<std::ffi::OsString>,
1168    }
1169
1170    impl EnvVarGuard {
1171        fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
1172            let previous = std::env::var_os(name);
1173            unsafe { std::env::set_var(name, value) };
1174            Self { name, previous }
1175        }
1176    }
1177
1178    impl Drop for EnvVarGuard {
1179        fn drop(&mut self) {
1180            match self.previous.take() {
1181                Some(value) => unsafe { std::env::set_var(self.name, value) },
1182                None => unsafe { std::env::remove_var(self.name) },
1183            }
1184        }
1185    }
1186
1187    #[test]
1188    fn backend_selection_defaults_to_file() {
1189        assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
1190        assert_eq!(
1191            secret_backend_selection(Some("")),
1192            SecretBackendSelection::File
1193        );
1194        assert_eq!(
1195            secret_backend_selection(Some("  file  ")),
1196            SecretBackendSelection::File
1197        );
1198    }
1199
1200    #[test]
1201    fn backend_selection_accepts_explicit_system_keyring() {
1202        assert_eq!(
1203            secret_backend_selection(Some("system")),
1204            SecretBackendSelection::System
1205        );
1206        assert_eq!(
1207            secret_backend_selection(Some("keyring")),
1208            SecretBackendSelection::System
1209        );
1210        assert_eq!(
1211            secret_backend_selection(Some("os-keyring")),
1212            SecretBackendSelection::System
1213        );
1214    }
1215
1216    #[test]
1217    fn auto_detect_is_file_backed_by_default() {
1218        let _lock = env_lock();
1219        clear_known_envs();
1220        let tmp = tempfile::tempdir().unwrap();
1221        let _home = EnvVarGuard::set("HOME", tmp.path());
1222        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1223
1224        let secrets = Secrets::auto_detect();
1225
1226        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1227    }
1228
1229    #[test]
1230    fn auto_detect_honors_explicit_file_backend() {
1231        let _lock = env_lock();
1232        clear_known_envs();
1233        let tmp = tempfile::tempdir().unwrap();
1234        let _home = EnvVarGuard::set("HOME", tmp.path());
1235        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1236        // Safety: env mutation guarded by env_lock().
1237        unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
1238
1239        let secrets = Secrets::auto_detect();
1240
1241        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1242        // Safety: env mutation guarded by env_lock().
1243        unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
1244    }
1245
1246    #[test]
1247    fn read_only_auto_detect_reads_legacy_without_migrating_or_allowing_writes() {
1248        let _lock = env_lock();
1249        clear_known_envs();
1250        let tmp = tempfile::tempdir().unwrap();
1251        let _home = EnvVarGuard::set("HOME", tmp.path());
1252        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1253        let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1254        let legacy = tmp
1255            .path()
1256            .join(".deepseek")
1257            .join("secrets")
1258            .join("secrets.json");
1259        let primary = tmp
1260            .path()
1261            .join(".codewhale")
1262            .join("secrets")
1263            .join("secrets.json");
1264        FileKeyringStore::new(&legacy)
1265            .set("moonshot", "fixture-legacy-value")
1266            .unwrap();
1267
1268        let secrets = Secrets::auto_detect_read_only();
1269
1270        assert_eq!(
1271            secrets.get("moonshot").unwrap().as_deref(),
1272            Some("fixture-legacy-value")
1273        );
1274        assert!(
1275            !primary.exists(),
1276            "diagnostic lookup must not migrate the legacy store"
1277        );
1278        assert!(
1279            matches!(
1280                secrets.set("moonshot", "replacement"),
1281                Err(SecretsError::ReadOnly)
1282            ),
1283            "the diagnostic secret facade must refuse writes"
1284        );
1285        assert!(
1286            !primary.exists(),
1287            "a refused diagnostic write must not create the primary store"
1288        );
1289    }
1290
1291    #[test]
1292    fn read_only_auto_detect_respects_explicit_codewhale_home_isolation() {
1293        let _lock = env_lock();
1294        clear_known_envs();
1295        let tmp = tempfile::tempdir().unwrap();
1296        let codewhale_home = tmp.path().join("isolated-codewhale-home");
1297        let _home = EnvVarGuard::set("HOME", tmp.path());
1298        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1299        let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1300        let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1301        let legacy = tmp
1302            .path()
1303            .join(".deepseek")
1304            .join("secrets")
1305            .join("secrets.json");
1306        let primary = codewhale_home.join("secrets").join("secrets.json");
1307        FileKeyringStore::new(&legacy)
1308            .set("deepseek", "synthetic-ambient-legacy-value")
1309            .unwrap();
1310
1311        let secrets = Secrets::auto_detect_read_only();
1312
1313        assert_eq!(
1314            secrets.get("deepseek").unwrap(),
1315            None,
1316            "an explicit CODEWHALE_HOME must not read ambient legacy secrets"
1317        );
1318        assert!(
1319            !primary.exists(),
1320            "diagnostic lookup must not create an isolated primary store"
1321        );
1322        assert!(
1323            matches!(
1324                secrets.set("deepseek", "replacement"),
1325                Err(SecretsError::ReadOnly)
1326            ),
1327            "the isolated diagnostic facade must refuse writes"
1328        );
1329        assert!(
1330            !primary.exists(),
1331            "a refused isolated diagnostic write must not create the primary store"
1332        );
1333    }
1334
1335    #[test]
1336    fn read_only_auto_detect_reads_the_explicit_primary_store() {
1337        let _lock = env_lock();
1338        clear_known_envs();
1339        let tmp = tempfile::tempdir().unwrap();
1340        let codewhale_home = tmp.path().join("isolated-codewhale-home");
1341        let _home = EnvVarGuard::set("HOME", tmp.path());
1342        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1343        let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1344        let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1345        let primary = codewhale_home.join("secrets").join("secrets.json");
1346        FileKeyringStore::new(&primary)
1347            .set("deepseek", "synthetic-isolated-primary-value")
1348            .unwrap();
1349
1350        let secrets = Secrets::auto_detect_read_only();
1351
1352        assert_eq!(
1353            secrets.get("deepseek").unwrap().as_deref(),
1354            Some("synthetic-isolated-primary-value")
1355        );
1356    }
1357
1358    #[test]
1359    fn auto_detect_honors_legacy_backend_env_alias() {
1360        let _lock = env_lock();
1361        clear_known_envs();
1362        let tmp = tempfile::tempdir().unwrap();
1363        let _home = EnvVarGuard::set("HOME", tmp.path());
1364        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1365        unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
1366
1367        let secrets = Secrets::auto_detect();
1368
1369        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1370        clear_known_envs();
1371    }
1372
1373    #[test]
1374    fn file_default_path_uses_codewhale_home() {
1375        let _lock = env_lock();
1376        clear_known_envs();
1377        let tmp = tempfile::tempdir().unwrap();
1378        let _home = EnvVarGuard::set("HOME", tmp.path());
1379        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1380
1381        let path = FileKeyringStore::default_path().unwrap();
1382
1383        assert_eq!(
1384            path,
1385            tmp.path()
1386                .join(".codewhale")
1387                .join("secrets")
1388                .join("secrets.json")
1389        );
1390    }
1391
1392    #[test]
1393    fn file_default_path_honors_codewhale_home() {
1394        let _lock = env_lock();
1395        clear_known_envs();
1396        let tmp = tempfile::tempdir().unwrap();
1397        let custom = tmp.path().join("custom-codewhale");
1398        let _home = EnvVarGuard::set("HOME", tmp.path());
1399        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1400        let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
1401
1402        let path = FileKeyringStore::default_path().unwrap();
1403
1404        assert_eq!(path, custom.join("secrets").join("secrets.json"));
1405    }
1406
1407    #[test]
1408    fn file_default_path_migrates_legacy_entries_to_codewhale() {
1409        let _lock = env_lock();
1410        clear_known_envs();
1411        let tmp = tempfile::tempdir().unwrap();
1412        let _home = EnvVarGuard::set("HOME", tmp.path());
1413        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1414        let legacy = tmp
1415            .path()
1416            .join(".deepseek")
1417            .join("secrets")
1418            .join("secrets.json");
1419        FileKeyringStore::new(legacy.clone())
1420            .set("xiaomi-mimo", "legacy-mimo")
1421            .unwrap();
1422
1423        let primary = FileKeyringStore::default_path().unwrap();
1424        let primary_store = FileKeyringStore::new(primary.clone());
1425
1426        assert_eq!(
1427            primary,
1428            tmp.path()
1429                .join(".codewhale")
1430                .join("secrets")
1431                .join("secrets.json")
1432        );
1433        assert_eq!(
1434            primary_store.get("xiaomi-mimo").unwrap().as_deref(),
1435            Some("legacy-mimo")
1436        );
1437        assert!(
1438            legacy.exists(),
1439            "migration copies; it does not delete legacy data"
1440        );
1441    }
1442
1443    #[test]
1444    fn file_default_path_migration_preserves_primary_values() {
1445        let _lock = env_lock();
1446        clear_known_envs();
1447        let tmp = tempfile::tempdir().unwrap();
1448        let _home = EnvVarGuard::set("HOME", tmp.path());
1449        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1450        let legacy = tmp
1451            .path()
1452            .join(".deepseek")
1453            .join("secrets")
1454            .join("secrets.json");
1455        let primary = tmp
1456            .path()
1457            .join(".codewhale")
1458            .join("secrets")
1459            .join("secrets.json");
1460        FileKeyringStore::new(legacy)
1461            .set("openrouter", "legacy-openrouter")
1462            .unwrap();
1463        let primary_store = FileKeyringStore::new(primary.clone());
1464        primary_store
1465            .set("openrouter", "primary-openrouter")
1466            .unwrap();
1467
1468        let resolved = FileKeyringStore::default_path().unwrap();
1469
1470        assert_eq!(resolved, primary);
1471        assert_eq!(
1472            primary_store.get("openrouter").unwrap().as_deref(),
1473            Some("primary-openrouter")
1474        );
1475    }
1476
1477    #[test]
1478    fn in_memory_store_round_trips() {
1479        let store = InMemoryKeyringStore::new();
1480        assert_eq!(store.get("deepseek").unwrap(), None);
1481        store.set("deepseek", "sk-test").unwrap();
1482        assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
1483        store.set("deepseek", "sk-replaced").unwrap();
1484        assert_eq!(
1485            store.get("deepseek").unwrap(),
1486            Some("sk-replaced".to_string())
1487        );
1488        store.delete("deepseek").unwrap();
1489        assert_eq!(store.get("deepseek").unwrap(), None);
1490        // Deleting an absent key is a no-op.
1491        store.delete("missing").unwrap();
1492    }
1493
1494    #[test]
1495    fn resolve_prefers_keyring_over_env() {
1496        let _lock = env_lock();
1497        clear_known_envs();
1498        // Safety: env mutation guarded by env_lock().
1499        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1500
1501        let store = Arc::new(InMemoryKeyringStore::new());
1502        store.set("deepseek", "ring-key").unwrap();
1503        let secrets = Secrets::new(store);
1504
1505        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1506        assert_eq!(
1507            secrets.resolve_with_source("deepseek"),
1508            Some(("ring-key".to_string(), SecretSource::Keyring))
1509        );
1510        // Safety: env mutation guarded by env_lock().
1511        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1512    }
1513
1514    #[test]
1515    fn resolve_falls_back_to_env_when_keyring_empty() {
1516        let _lock = env_lock();
1517        clear_known_envs();
1518        // Safety: env mutation guarded by env_lock().
1519        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1520
1521        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1522        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1523        assert_eq!(
1524            secrets.resolve_with_source("deepseek"),
1525            Some(("env-fallback".to_string(), SecretSource::Env))
1526        );
1527        // Safety: env mutation guarded by env_lock().
1528        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1529    }
1530
1531    #[test]
1532    fn resolve_returns_none_when_both_layers_empty() {
1533        let _lock = env_lock();
1534        clear_known_envs();
1535        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1536        assert_eq!(secrets.resolve("deepseek"), None);
1537    }
1538
1539    #[test]
1540    fn resolve_treats_blank_keyring_value_as_unset() {
1541        let _lock = env_lock();
1542        clear_known_envs();
1543        // Safety: env mutation guarded by env_lock().
1544        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1545
1546        let store = Arc::new(InMemoryKeyringStore::new());
1547        store.set("deepseek", "   ").unwrap();
1548        let secrets = Secrets::new(store);
1549        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1550        // Safety: env mutation guarded by env_lock().
1551        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1552    }
1553
1554    #[test]
1555    fn nvidia_env_aliases_resolve() {
1556        let _lock = env_lock();
1557        clear_known_envs();
1558        // Safety: env mutation guarded by env_lock().
1559        unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1560        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1561        assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1562        assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1563        // Safety: env mutation guarded by env_lock().
1564        unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1565    }
1566
1567    #[test]
1568    fn atlascloud_env_aliases_resolve() {
1569        let _guard = env_lock();
1570        clear_known_envs();
1571        unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1572
1573        assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1574        assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1575        assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1576
1577        clear_known_envs();
1578    }
1579
1580    #[test]
1581    fn sakana_env_aliases_resolve() {
1582        let _guard = env_lock();
1583        clear_known_envs();
1584        unsafe { std::env::set_var("FUGU_API_KEY", "fugu-key") };
1585
1586        assert_eq!(env_for("sakana").as_deref(), Some("fugu-key"));
1587        assert_eq!(env_for("sakana-ai").as_deref(), Some("fugu-key"));
1588        assert_eq!(env_for("sakana_ai").as_deref(), Some("fugu-key"));
1589        assert_eq!(env_for("fugu").as_deref(), Some("fugu-key"));
1590
1591        clear_known_envs();
1592        unsafe { std::env::set_var("SAKANA_API_KEY", "sakana-key") };
1593        assert_eq!(env_for("sakana").as_deref(), Some("sakana-key"));
1594
1595        clear_known_envs();
1596    }
1597
1598    #[test]
1599    fn wanjie_ark_env_aliases_resolve() {
1600        let _guard = env_lock();
1601        clear_known_envs();
1602        unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1603
1604        assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1605        assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1606        assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1607
1608        clear_known_envs();
1609    }
1610
1611    #[test]
1612    fn xai_env_aliases_resolve() {
1613        let _guard = env_lock();
1614        clear_known_envs();
1615        unsafe { std::env::set_var("XAI_API_KEY", "xai-key") };
1616
1617        assert_eq!(env_for("xai").as_deref(), Some("xai-key"));
1618        assert_eq!(env_for("x-ai").as_deref(), Some("xai-key"));
1619        assert_eq!(env_for("x_ai").as_deref(), Some("xai-key"));
1620        assert_eq!(env_for("grok").as_deref(), Some("xai-key"));
1621
1622        clear_known_envs();
1623    }
1624
1625    #[test]
1626    fn telecomjs_env_aliases_resolve() {
1627        let _guard = env_lock();
1628        clear_known_envs();
1629        unsafe { std::env::set_var("TELECOMJS_API_KEY", "telecom-key") };
1630
1631        for alias in [
1632            "telecomjs",
1633            "telecom-js",
1634            "telecom_js",
1635            "telecomjs-cn",
1636            "tokenhub",
1637        ] {
1638            assert_eq!(env_for(alias).as_deref(), Some("telecom-key"), "{alias}");
1639        }
1640
1641        clear_known_envs();
1642    }
1643
1644    #[test]
1645    fn opencode_go_env_aliases_resolve() {
1646        let _guard = env_lock();
1647        clear_known_envs();
1648        unsafe { std::env::set_var("OPENCODE_GO_API_KEY", "go-key") };
1649
1650        for alias in ["opencode-go", "opencode_go", "opencodego"] {
1651            assert_eq!(env_for(alias).as_deref(), Some("go-key"), "{alias}");
1652        }
1653
1654        clear_known_envs();
1655    }
1656
1657    #[test]
1658    fn opencode_zen_env_aliases_resolve() {
1659        let _guard = env_lock();
1660        clear_known_envs();
1661        unsafe { std::env::set_var("OPENCODE_ZEN_API_KEY", "zen-key") };
1662
1663        for alias in [
1664            "opencode-zen",
1665            "opencode_zen",
1666            "opencodezen",
1667            "zen",
1668            "opencode",
1669        ] {
1670            assert_eq!(env_for(alias).as_deref(), Some("zen-key"), "{alias}");
1671        }
1672
1673        clear_known_envs();
1674    }
1675
1676    #[test]
1677    fn meta_model_api_env_aliases_resolve() {
1678        let _guard = env_lock();
1679        clear_known_envs();
1680        unsafe { std::env::set_var("MODEL_API_KEY", "meta-key") };
1681
1682        for alias in [
1683            "meta",
1684            "meta-ai",
1685            "meta_ai",
1686            "meta-model-api",
1687            "meta_model_api",
1688            "muse",
1689            "muse-spark",
1690        ] {
1691            assert_eq!(env_for(alias).as_deref(), Some("meta-key"), "{alias}");
1692        }
1693
1694        clear_known_envs();
1695        unsafe { std::env::set_var("META_MODEL_API_KEY", "meta-prefixed-key") };
1696        assert_eq!(env_for("meta").as_deref(), Some("meta-prefixed-key"),);
1697
1698        clear_known_envs();
1699    }
1700
1701    #[test]
1702    fn xiaomi_mimo_env_aliases_resolve() {
1703        let _guard = env_lock();
1704        clear_known_envs();
1705        unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1706
1707        assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1708        assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1709        assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1710        assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1711
1712        clear_known_envs();
1713
1714        unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1715        assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1716        clear_known_envs();
1717    }
1718
1719    #[test]
1720    fn fireworks_env_aliases_resolve() {
1721        let _lock = env_lock();
1722        clear_known_envs();
1723        // Safety: env mutation guarded by env_lock().
1724        unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1725
1726        assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1727        assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1728        // Safety: env mutation guarded by env_lock().
1729        unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1730    }
1731
1732    #[test]
1733    fn together_env_aliases_resolve() {
1734        let _lock = env_lock();
1735        clear_known_envs();
1736        // Safety: env mutation guarded by env_lock().
1737        unsafe { std::env::set_var("TOGETHER_API_KEY", "together-key") };
1738
1739        // Canonical id plus the legacy hyphen/underscore spellings AND the
1740        // separator-free `togetherai` id Models.dev publishes must all resolve.
1741        assert_eq!(env_for("together").as_deref(), Some("together-key"));
1742        assert_eq!(env_for("together-ai").as_deref(), Some("together-key"));
1743        assert_eq!(env_for("together_ai").as_deref(), Some("together-key"));
1744        assert_eq!(env_for("togetherai").as_deref(), Some("together-key"));
1745        // Safety: env mutation guarded by env_lock().
1746        unsafe { std::env::remove_var("TOGETHER_API_KEY") };
1747    }
1748
1749    #[test]
1750    fn deepinfra_env_aliases_resolve() {
1751        let _lock = env_lock();
1752        clear_known_envs();
1753        // Safety: env mutation guarded by env_lock().
1754        unsafe { std::env::set_var("DEEPINFRA_API_KEY", "di-key") };
1755
1756        assert_eq!(env_for("deepinfra").as_deref(), Some("di-key"));
1757        assert_eq!(env_for("deep-infra").as_deref(), Some("di-key"));
1758        assert_eq!(env_for("deep_infra").as_deref(), Some("di-key"));
1759        // Safety: env mutation guarded by env_lock().
1760        unsafe { std::env::remove_var("DEEPINFRA_API_KEY") };
1761
1762        // The DEEPINFRA_TOKEN fallback is honored when the primary key is unset.
1763        // Safety: env mutation guarded by env_lock().
1764        unsafe { std::env::set_var("DEEPINFRA_TOKEN", "di-token") };
1765        assert_eq!(env_for("deepinfra").as_deref(), Some("di-token"));
1766        // Safety: env mutation guarded by env_lock().
1767        unsafe { std::env::remove_var("DEEPINFRA_TOKEN") };
1768    }
1769
1770    #[test]
1771    fn novita_env_aliases_resolve() {
1772        let _lock = env_lock();
1773        clear_known_envs();
1774        // Safety: env mutation guarded by env_lock().
1775        unsafe { std::env::set_var("NOVITA_API_KEY", "novita-key") };
1776
1777        assert_eq!(env_for("novita").as_deref(), Some("novita-key"));
1778        // `novita-ai` is the Models.dev provider id (Refs #4186).
1779        assert_eq!(env_for("novita-ai").as_deref(), Some("novita-key"));
1780        assert_eq!(env_for("novita_ai").as_deref(), Some("novita-key"));
1781        // Safety: env mutation guarded by env_lock().
1782        unsafe { std::env::remove_var("NOVITA_API_KEY") };
1783    }
1784
1785    #[test]
1786    fn siliconflow_env_aliases_resolve() {
1787        let _lock = env_lock();
1788        clear_known_envs();
1789        // Safety: env mutation guarded by env_lock().
1790        unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
1791
1792        assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
1793        assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
1794        assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
1795        assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
1796        assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
1797        // Safety: env mutation guarded by env_lock().
1798        unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
1799    }
1800
1801    #[test]
1802    fn arcee_env_aliases_resolve() {
1803        let _lock = env_lock();
1804        clear_known_envs();
1805        // Safety: env mutation guarded by env_lock().
1806        unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
1807
1808        assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
1809        assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
1810        assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
1811        // Safety: env mutation guarded by env_lock().
1812        unsafe { std::env::remove_var("ARCEE_API_KEY") };
1813    }
1814
1815    #[test]
1816    fn moonshot_kimi_env_aliases_resolve() {
1817        let _lock = env_lock();
1818        clear_known_envs();
1819        // Safety: env mutation guarded by env_lock().
1820        unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
1821
1822        assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
1823        assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
1824        assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
1825        assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
1826        // Safety: env mutation guarded by env_lock().
1827        unsafe { std::env::remove_var("KIMI_API_KEY") };
1828    }
1829
1830    #[test]
1831    fn sglang_env_aliases_resolve() {
1832        let _lock = env_lock();
1833        clear_known_envs();
1834        // Safety: env mutation guarded by env_lock().
1835        unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
1836
1837        assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
1838        assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
1839        // Safety: env mutation guarded by env_lock().
1840        unsafe { std::env::remove_var("SGLANG_API_KEY") };
1841    }
1842
1843    #[test]
1844    fn vllm_env_aliases_resolve() {
1845        let _lock = env_lock();
1846        clear_known_envs();
1847        // Safety: env mutation guarded by env_lock().
1848        unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
1849
1850        assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
1851        assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
1852        // Safety: env mutation guarded by env_lock().
1853        unsafe { std::env::remove_var("VLLM_API_KEY") };
1854    }
1855
1856    #[test]
1857    fn ollama_env_aliases_resolve() {
1858        let _lock = env_lock();
1859        clear_known_envs();
1860        // Safety: env mutation guarded by env_lock().
1861        unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
1862
1863        assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
1864        assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
1865        // Safety: env mutation guarded by env_lock().
1866        unsafe { std::env::remove_var("OLLAMA_API_KEY") };
1867    }
1868
1869    #[cfg(unix)]
1870    #[test]
1871    fn file_store_round_trips_with_secure_perms() {
1872        use std::os::unix::fs::PermissionsExt;
1873
1874        let tmp = tempfile::tempdir().unwrap();
1875        let path = tmp.path().join("nested").join("secrets.json");
1876        let store = FileKeyringStore::new(path.clone());
1877        assert_eq!(store.get("deepseek").unwrap(), None);
1878        store.set("deepseek", "sk-disk").unwrap();
1879        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1880
1881        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1882        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
1883
1884        store.set("openrouter", "or-disk").unwrap();
1885        assert_eq!(
1886            store.get("openrouter").unwrap(),
1887            Some("or-disk".to_string())
1888        );
1889        // First entry must still be intact.
1890        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1891
1892        store.delete("deepseek").unwrap();
1893        assert_eq!(store.get("deepseek").unwrap(), None);
1894    }
1895
1896    #[cfg(unix)]
1897    #[test]
1898    fn file_store_rejects_world_readable_file() {
1899        use std::os::unix::fs::PermissionsExt;
1900        let tmp = tempfile::tempdir().unwrap();
1901        let path = tmp.path().join("secrets.json");
1902        fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
1903        let mut perms = fs::metadata(&path).unwrap().permissions();
1904        perms.set_mode(0o644);
1905        fs::set_permissions(&path, perms).unwrap();
1906
1907        let store = FileKeyringStore::new(path);
1908        let err = store.get("deepseek").unwrap_err();
1909        assert!(
1910            matches!(err, SecretsError::InsecurePermissions { .. }),
1911            "unexpected error: {err}"
1912        );
1913    }
1914
1915    // Regression for #281: `set` and `delete` used to call
1916    // `load_unlocked().unwrap_or_default()`, which silently wiped every
1917    // existing secret whenever the read failed (insecure permissions,
1918    // corrupt JSON, or any other I/O error).
1919
1920    #[cfg(unix)]
1921    #[test]
1922    fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
1923        use std::os::unix::fs::PermissionsExt;
1924        let tmp = tempfile::tempdir().unwrap();
1925        let path = tmp.path().join("secrets.json");
1926        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1927        fs::write(&path, original).unwrap();
1928        let mut perms = fs::metadata(&path).unwrap().permissions();
1929        perms.set_mode(0o644);
1930        fs::set_permissions(&path, perms).unwrap();
1931
1932        let store = FileKeyringStore::new(path.clone());
1933        let err = store.set("openrouter", "or-new").unwrap_err();
1934        assert!(
1935            matches!(err, SecretsError::InsecurePermissions { .. }),
1936            "set must surface the read error rather than overwriting; got: {err}"
1937        );
1938
1939        let on_disk = fs::read_to_string(&path).unwrap();
1940        assert_eq!(
1941            on_disk, original,
1942            "set must not modify the file when load_unlocked errored"
1943        );
1944    }
1945
1946    #[cfg(unix)]
1947    #[test]
1948    fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
1949        use std::os::unix::fs::PermissionsExt;
1950        let tmp = tempfile::tempdir().unwrap();
1951        let path = tmp.path().join("secrets.json");
1952        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1953        fs::write(&path, original).unwrap();
1954        let mut perms = fs::metadata(&path).unwrap().permissions();
1955        perms.set_mode(0o644);
1956        fs::set_permissions(&path, perms).unwrap();
1957
1958        let store = FileKeyringStore::new(path.clone());
1959        let err = store.delete("nvidia").unwrap_err();
1960        assert!(
1961            matches!(err, SecretsError::InsecurePermissions { .. }),
1962            "delete must surface the read error rather than wiping the file; got: {err}"
1963        );
1964        let on_disk = fs::read_to_string(&path).unwrap();
1965        assert_eq!(on_disk, original);
1966    }
1967
1968    #[test]
1969    fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
1970        let tmp = tempfile::tempdir().unwrap();
1971        let path = tmp.path().join("secrets.json");
1972        // Corrupt JSON. Permissions ok where unix; on Windows the perm-check
1973        // doesn't run so we exercise the json-error path directly.
1974        fs::write(&path, "{ this is not valid json").unwrap();
1975        #[cfg(unix)]
1976        {
1977            use std::os::unix::fs::PermissionsExt;
1978            let mut perms = fs::metadata(&path).unwrap().permissions();
1979            perms.set_mode(0o600);
1980            fs::set_permissions(&path, perms).unwrap();
1981        }
1982
1983        let store = FileKeyringStore::new(path.clone());
1984        let err = store.set("deepseek", "sk-new").unwrap_err();
1985        assert!(
1986            matches!(err, SecretsError::Json(_)),
1987            "set must surface the parse error rather than wiping the file; got: {err}"
1988        );
1989        let on_disk = fs::read_to_string(&path).unwrap();
1990        assert_eq!(on_disk, "{ this is not valid json");
1991    }
1992
1993    #[test]
1994    fn file_store_set_still_creates_file_when_missing() {
1995        // Regression guard: the #281 fix removed `unwrap_or_default()` from
1996        // the load call. Make sure the original first-write-creates-the-file
1997        // ergonomic still works — `load_unlocked` returns `Ok(default)` for
1998        // a missing file, so the `?` should pass through cleanly.
1999        let tmp = tempfile::tempdir().unwrap();
2000        let path = tmp.path().join("nested").join("secrets.json");
2001        let store = FileKeyringStore::new(path.clone());
2002
2003        store.set("deepseek", "sk-fresh").unwrap();
2004        assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
2005    }
2006
2007    #[test]
2008    fn file_store_default_path_uses_home() {
2009        let _lock = env_lock();
2010        clear_known_envs();
2011        let tmp = tempfile::tempdir().unwrap();
2012        let _home = EnvVarGuard::set("HOME", tmp.path());
2013        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2014
2015        let path = FileKeyringStore::default_path().unwrap();
2016        assert_eq!(
2017            path,
2018            tmp.path()
2019                .join(".codewhale")
2020                .join("secrets")
2021                .join("secrets.json")
2022        );
2023    }
2024
2025    #[test]
2026    fn default_path_with_explicit_codewhale_home_does_not_migrate_ambient_legacy() {
2027        // FR003-C001: explicit CODEWHALE_HOME must not silently import ambient
2028        // `$HOME/.deepseek/secrets` credentials into the isolated home.
2029        let _lock = env_lock();
2030        clear_known_envs();
2031        let tmp = tempfile::tempdir().unwrap();
2032        let codewhale_home = tmp.path().join("isolated-codewhale-home");
2033        let _home = EnvVarGuard::set("HOME", tmp.path());
2034        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2035        let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
2036        let legacy = tmp
2037            .path()
2038            .join(".deepseek")
2039            .join("secrets")
2040            .join("secrets.json");
2041        FileKeyringStore::new(&legacy)
2042            .set("deepseek", "synthetic-ambient-legacy-value")
2043            .unwrap();
2044
2045        let path = FileKeyringStore::default_path().unwrap();
2046        assert_eq!(path, codewhale_home.join("secrets").join("secrets.json"));
2047        assert!(
2048            !path.exists(),
2049            "explicit CODEWHALE_HOME must not create/migrate a primary store from ambient legacy"
2050        );
2051
2052        let secrets = Secrets::auto_detect();
2053        assert_eq!(
2054            secrets.get("deepseek").unwrap(),
2055            None,
2056            "explicit CODEWHALE_HOME must not surface ambient legacy credentials"
2057        );
2058    }
2059
2060    #[test]
2061    fn file_backed_default_refuses_relative_secret_path() {
2062        // FR003-C002: a relative fallback would resolve against the workspace
2063        // and risk committing credentials. It must be write-refusing instead.
2064        let secrets =
2065            Secrets::file_backed_from_default_path(Ok(PathBuf::from(".codewhale-secrets.json")));
2066        assert!(matches!(
2067            secrets.set("deepseek", "must-not-land-relative"),
2068            Err(SecretsError::ReadOnly)
2069        ));
2070        assert_eq!(
2071            secrets.get("deepseek").unwrap(),
2072            None,
2073            "unsafe relative fallback must not read a workspace secret file"
2074        );
2075    }
2076
2077    #[test]
2078    fn file_backed_default_refuses_writes_when_home_resolution_fails() {
2079        // Force the exact fallback branch instead of relying on the host's
2080        // dirs::home_dir(), which normally succeeds even with HOME unset.
2081        let err = SecretsError::Io(std::io::Error::new(
2082            std::io::ErrorKind::NotFound,
2083            "synthetic unresolved home",
2084        ));
2085        let secrets = Secrets::file_backed_from_default_path(Err(err));
2086        assert!(matches!(
2087            secrets.set("deepseek", "must-not-persist"),
2088            Err(SecretsError::ReadOnly)
2089        ));
2090        assert_eq!(secrets.get("deepseek").unwrap(), None);
2091    }
2092}