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        "meta" | "meta-ai" | "meta_ai" | "meta-model-api" | "meta_model_api" | "muse"
1087        | "muse-spark" => &["META_MODEL_API_KEY", "MODEL_API_KEY"],
1088        "xai" | "x-ai" | "x_ai" | "grok" => &["XAI_API_KEY"],
1089        "telecomjs" | "telecom-js" | "telecom_js" | "telecomjs-cn" | "tokenhub" => {
1090            &["TELECOMJS_API_KEY"]
1091        }
1092        _ => return None,
1093    };
1094    for var in candidates {
1095        if let Ok(value) = std::env::var(var)
1096            && !value.trim().is_empty()
1097        {
1098            return Some(value);
1099        }
1100    }
1101    None
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107    use std::sync::{Mutex, OnceLock};
1108
1109    /// Serialise env-mutating tests: tests in this module poke
1110    /// `DEEPSEEK_API_KEY` etc., which is process-global.
1111    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1112        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1113        LOCK.get_or_init(|| Mutex::new(()))
1114            .lock()
1115            .unwrap_or_else(|p| p.into_inner())
1116    }
1117
1118    fn clear_known_envs() {
1119        for var in [
1120            "CODEWHALE_HOME",
1121            "DEEPSEEK_API_KEY",
1122            "OPENROUTER_API_KEY",
1123            "NOVITA_API_KEY",
1124            "NVIDIA_API_KEY",
1125            "NVIDIA_NIM_API_KEY",
1126            "FIREWORKS_API_KEY",
1127            "TOGETHER_API_KEY",
1128            "DEEPINFRA_API_KEY",
1129            "DEEPINFRA_TOKEN",
1130            "SILICONFLOW_API_KEY",
1131            "ARCEE_API_KEY",
1132            "SGLANG_API_KEY",
1133            "VLLM_API_KEY",
1134            "OLLAMA_API_KEY",
1135            "OPENAI_API_KEY",
1136            "ATLASCLOUD_API_KEY",
1137            "WANJIE_ARK_API_KEY",
1138            "WANJIE_API_KEY",
1139            "WANJIE_MAAS_API_KEY",
1140            "XIAOMI_MIMO_API_KEY",
1141            "XIAOMI_API_KEY",
1142            "MIMO_API_KEY",
1143            "FUGU_API_KEY",
1144            "SAKANA_API_KEY",
1145            "LONGCAT_API_KEY",
1146            "OPENCODE_GO_API_KEY",
1147            "META_MODEL_API_KEY",
1148            "MODEL_API_KEY",
1149            "XAI_API_KEY",
1150            "TELECOMJS_API_KEY",
1151            SECRET_BACKEND_ENV,
1152            LEGACY_SECRET_BACKEND_ENV,
1153        ] {
1154            // Safety: tests serialise on env_lock(); the broader
1155            // workspace has the same pattern in `crates/config`.
1156            unsafe { std::env::remove_var(var) };
1157        }
1158    }
1159
1160    struct EnvVarGuard {
1161        name: &'static str,
1162        previous: Option<std::ffi::OsString>,
1163    }
1164
1165    impl EnvVarGuard {
1166        fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
1167            let previous = std::env::var_os(name);
1168            unsafe { std::env::set_var(name, value) };
1169            Self { name, previous }
1170        }
1171    }
1172
1173    impl Drop for EnvVarGuard {
1174        fn drop(&mut self) {
1175            match self.previous.take() {
1176                Some(value) => unsafe { std::env::set_var(self.name, value) },
1177                None => unsafe { std::env::remove_var(self.name) },
1178            }
1179        }
1180    }
1181
1182    #[test]
1183    fn backend_selection_defaults_to_file() {
1184        assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
1185        assert_eq!(
1186            secret_backend_selection(Some("")),
1187            SecretBackendSelection::File
1188        );
1189        assert_eq!(
1190            secret_backend_selection(Some("  file  ")),
1191            SecretBackendSelection::File
1192        );
1193    }
1194
1195    #[test]
1196    fn backend_selection_accepts_explicit_system_keyring() {
1197        assert_eq!(
1198            secret_backend_selection(Some("system")),
1199            SecretBackendSelection::System
1200        );
1201        assert_eq!(
1202            secret_backend_selection(Some("keyring")),
1203            SecretBackendSelection::System
1204        );
1205        assert_eq!(
1206            secret_backend_selection(Some("os-keyring")),
1207            SecretBackendSelection::System
1208        );
1209    }
1210
1211    #[test]
1212    fn auto_detect_is_file_backed_by_default() {
1213        let _lock = env_lock();
1214        clear_known_envs();
1215        let tmp = tempfile::tempdir().unwrap();
1216        let _home = EnvVarGuard::set("HOME", tmp.path());
1217        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1218
1219        let secrets = Secrets::auto_detect();
1220
1221        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1222    }
1223
1224    #[test]
1225    fn auto_detect_honors_explicit_file_backend() {
1226        let _lock = env_lock();
1227        clear_known_envs();
1228        let tmp = tempfile::tempdir().unwrap();
1229        let _home = EnvVarGuard::set("HOME", tmp.path());
1230        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1231        // Safety: env mutation guarded by env_lock().
1232        unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
1233
1234        let secrets = Secrets::auto_detect();
1235
1236        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1237        // Safety: env mutation guarded by env_lock().
1238        unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
1239    }
1240
1241    #[test]
1242    fn read_only_auto_detect_reads_legacy_without_migrating_or_allowing_writes() {
1243        let _lock = env_lock();
1244        clear_known_envs();
1245        let tmp = tempfile::tempdir().unwrap();
1246        let _home = EnvVarGuard::set("HOME", tmp.path());
1247        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1248        let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1249        let legacy = tmp
1250            .path()
1251            .join(".deepseek")
1252            .join("secrets")
1253            .join("secrets.json");
1254        let primary = tmp
1255            .path()
1256            .join(".codewhale")
1257            .join("secrets")
1258            .join("secrets.json");
1259        FileKeyringStore::new(&legacy)
1260            .set("moonshot", "fixture-legacy-value")
1261            .unwrap();
1262
1263        let secrets = Secrets::auto_detect_read_only();
1264
1265        assert_eq!(
1266            secrets.get("moonshot").unwrap().as_deref(),
1267            Some("fixture-legacy-value")
1268        );
1269        assert!(
1270            !primary.exists(),
1271            "diagnostic lookup must not migrate the legacy store"
1272        );
1273        assert!(
1274            matches!(
1275                secrets.set("moonshot", "replacement"),
1276                Err(SecretsError::ReadOnly)
1277            ),
1278            "the diagnostic secret facade must refuse writes"
1279        );
1280        assert!(
1281            !primary.exists(),
1282            "a refused diagnostic write must not create the primary store"
1283        );
1284    }
1285
1286    #[test]
1287    fn read_only_auto_detect_respects_explicit_codewhale_home_isolation() {
1288        let _lock = env_lock();
1289        clear_known_envs();
1290        let tmp = tempfile::tempdir().unwrap();
1291        let codewhale_home = tmp.path().join("isolated-codewhale-home");
1292        let _home = EnvVarGuard::set("HOME", tmp.path());
1293        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1294        let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1295        let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1296        let legacy = tmp
1297            .path()
1298            .join(".deepseek")
1299            .join("secrets")
1300            .join("secrets.json");
1301        let primary = codewhale_home.join("secrets").join("secrets.json");
1302        FileKeyringStore::new(&legacy)
1303            .set("deepseek", "synthetic-ambient-legacy-value")
1304            .unwrap();
1305
1306        let secrets = Secrets::auto_detect_read_only();
1307
1308        assert_eq!(
1309            secrets.get("deepseek").unwrap(),
1310            None,
1311            "an explicit CODEWHALE_HOME must not read ambient legacy secrets"
1312        );
1313        assert!(
1314            !primary.exists(),
1315            "diagnostic lookup must not create an isolated primary store"
1316        );
1317        assert!(
1318            matches!(
1319                secrets.set("deepseek", "replacement"),
1320                Err(SecretsError::ReadOnly)
1321            ),
1322            "the isolated diagnostic facade must refuse writes"
1323        );
1324        assert!(
1325            !primary.exists(),
1326            "a refused isolated diagnostic write must not create the primary store"
1327        );
1328    }
1329
1330    #[test]
1331    fn read_only_auto_detect_reads_the_explicit_primary_store() {
1332        let _lock = env_lock();
1333        clear_known_envs();
1334        let tmp = tempfile::tempdir().unwrap();
1335        let codewhale_home = tmp.path().join("isolated-codewhale-home");
1336        let _home = EnvVarGuard::set("HOME", tmp.path());
1337        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1338        let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1339        let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1340        let primary = codewhale_home.join("secrets").join("secrets.json");
1341        FileKeyringStore::new(&primary)
1342            .set("deepseek", "synthetic-isolated-primary-value")
1343            .unwrap();
1344
1345        let secrets = Secrets::auto_detect_read_only();
1346
1347        assert_eq!(
1348            secrets.get("deepseek").unwrap().as_deref(),
1349            Some("synthetic-isolated-primary-value")
1350        );
1351    }
1352
1353    #[test]
1354    fn auto_detect_honors_legacy_backend_env_alias() {
1355        let _lock = env_lock();
1356        clear_known_envs();
1357        let tmp = tempfile::tempdir().unwrap();
1358        let _home = EnvVarGuard::set("HOME", tmp.path());
1359        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1360        unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
1361
1362        let secrets = Secrets::auto_detect();
1363
1364        assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1365        clear_known_envs();
1366    }
1367
1368    #[test]
1369    fn file_default_path_uses_codewhale_home() {
1370        let _lock = env_lock();
1371        clear_known_envs();
1372        let tmp = tempfile::tempdir().unwrap();
1373        let _home = EnvVarGuard::set("HOME", tmp.path());
1374        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1375
1376        let path = FileKeyringStore::default_path().unwrap();
1377
1378        assert_eq!(
1379            path,
1380            tmp.path()
1381                .join(".codewhale")
1382                .join("secrets")
1383                .join("secrets.json")
1384        );
1385    }
1386
1387    #[test]
1388    fn file_default_path_honors_codewhale_home() {
1389        let _lock = env_lock();
1390        clear_known_envs();
1391        let tmp = tempfile::tempdir().unwrap();
1392        let custom = tmp.path().join("custom-codewhale");
1393        let _home = EnvVarGuard::set("HOME", tmp.path());
1394        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1395        let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
1396
1397        let path = FileKeyringStore::default_path().unwrap();
1398
1399        assert_eq!(path, custom.join("secrets").join("secrets.json"));
1400    }
1401
1402    #[test]
1403    fn file_default_path_migrates_legacy_entries_to_codewhale() {
1404        let _lock = env_lock();
1405        clear_known_envs();
1406        let tmp = tempfile::tempdir().unwrap();
1407        let _home = EnvVarGuard::set("HOME", tmp.path());
1408        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1409        let legacy = tmp
1410            .path()
1411            .join(".deepseek")
1412            .join("secrets")
1413            .join("secrets.json");
1414        FileKeyringStore::new(legacy.clone())
1415            .set("xiaomi-mimo", "legacy-mimo")
1416            .unwrap();
1417
1418        let primary = FileKeyringStore::default_path().unwrap();
1419        let primary_store = FileKeyringStore::new(primary.clone());
1420
1421        assert_eq!(
1422            primary,
1423            tmp.path()
1424                .join(".codewhale")
1425                .join("secrets")
1426                .join("secrets.json")
1427        );
1428        assert_eq!(
1429            primary_store.get("xiaomi-mimo").unwrap().as_deref(),
1430            Some("legacy-mimo")
1431        );
1432        assert!(
1433            legacy.exists(),
1434            "migration copies; it does not delete legacy data"
1435        );
1436    }
1437
1438    #[test]
1439    fn file_default_path_migration_preserves_primary_values() {
1440        let _lock = env_lock();
1441        clear_known_envs();
1442        let tmp = tempfile::tempdir().unwrap();
1443        let _home = EnvVarGuard::set("HOME", tmp.path());
1444        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1445        let legacy = tmp
1446            .path()
1447            .join(".deepseek")
1448            .join("secrets")
1449            .join("secrets.json");
1450        let primary = tmp
1451            .path()
1452            .join(".codewhale")
1453            .join("secrets")
1454            .join("secrets.json");
1455        FileKeyringStore::new(legacy)
1456            .set("openrouter", "legacy-openrouter")
1457            .unwrap();
1458        let primary_store = FileKeyringStore::new(primary.clone());
1459        primary_store
1460            .set("openrouter", "primary-openrouter")
1461            .unwrap();
1462
1463        let resolved = FileKeyringStore::default_path().unwrap();
1464
1465        assert_eq!(resolved, primary);
1466        assert_eq!(
1467            primary_store.get("openrouter").unwrap().as_deref(),
1468            Some("primary-openrouter")
1469        );
1470    }
1471
1472    #[test]
1473    fn in_memory_store_round_trips() {
1474        let store = InMemoryKeyringStore::new();
1475        assert_eq!(store.get("deepseek").unwrap(), None);
1476        store.set("deepseek", "sk-test").unwrap();
1477        assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
1478        store.set("deepseek", "sk-replaced").unwrap();
1479        assert_eq!(
1480            store.get("deepseek").unwrap(),
1481            Some("sk-replaced".to_string())
1482        );
1483        store.delete("deepseek").unwrap();
1484        assert_eq!(store.get("deepseek").unwrap(), None);
1485        // Deleting an absent key is a no-op.
1486        store.delete("missing").unwrap();
1487    }
1488
1489    #[test]
1490    fn resolve_prefers_keyring_over_env() {
1491        let _lock = env_lock();
1492        clear_known_envs();
1493        // Safety: env mutation guarded by env_lock().
1494        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1495
1496        let store = Arc::new(InMemoryKeyringStore::new());
1497        store.set("deepseek", "ring-key").unwrap();
1498        let secrets = Secrets::new(store);
1499
1500        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1501        assert_eq!(
1502            secrets.resolve_with_source("deepseek"),
1503            Some(("ring-key".to_string(), SecretSource::Keyring))
1504        );
1505        // Safety: env mutation guarded by env_lock().
1506        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1507    }
1508
1509    #[test]
1510    fn resolve_falls_back_to_env_when_keyring_empty() {
1511        let _lock = env_lock();
1512        clear_known_envs();
1513        // Safety: env mutation guarded by env_lock().
1514        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1515
1516        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1517        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1518        assert_eq!(
1519            secrets.resolve_with_source("deepseek"),
1520            Some(("env-fallback".to_string(), SecretSource::Env))
1521        );
1522        // Safety: env mutation guarded by env_lock().
1523        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1524    }
1525
1526    #[test]
1527    fn resolve_returns_none_when_both_layers_empty() {
1528        let _lock = env_lock();
1529        clear_known_envs();
1530        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1531        assert_eq!(secrets.resolve("deepseek"), None);
1532    }
1533
1534    #[test]
1535    fn resolve_treats_blank_keyring_value_as_unset() {
1536        let _lock = env_lock();
1537        clear_known_envs();
1538        // Safety: env mutation guarded by env_lock().
1539        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1540
1541        let store = Arc::new(InMemoryKeyringStore::new());
1542        store.set("deepseek", "   ").unwrap();
1543        let secrets = Secrets::new(store);
1544        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1545        // Safety: env mutation guarded by env_lock().
1546        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1547    }
1548
1549    #[test]
1550    fn nvidia_env_aliases_resolve() {
1551        let _lock = env_lock();
1552        clear_known_envs();
1553        // Safety: env mutation guarded by env_lock().
1554        unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1555        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1556        assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1557        assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1558        // Safety: env mutation guarded by env_lock().
1559        unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1560    }
1561
1562    #[test]
1563    fn atlascloud_env_aliases_resolve() {
1564        let _guard = env_lock();
1565        clear_known_envs();
1566        unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1567
1568        assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1569        assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1570        assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1571
1572        clear_known_envs();
1573    }
1574
1575    #[test]
1576    fn sakana_env_aliases_resolve() {
1577        let _guard = env_lock();
1578        clear_known_envs();
1579        unsafe { std::env::set_var("FUGU_API_KEY", "fugu-key") };
1580
1581        assert_eq!(env_for("sakana").as_deref(), Some("fugu-key"));
1582        assert_eq!(env_for("sakana-ai").as_deref(), Some("fugu-key"));
1583        assert_eq!(env_for("sakana_ai").as_deref(), Some("fugu-key"));
1584        assert_eq!(env_for("fugu").as_deref(), Some("fugu-key"));
1585
1586        clear_known_envs();
1587        unsafe { std::env::set_var("SAKANA_API_KEY", "sakana-key") };
1588        assert_eq!(env_for("sakana").as_deref(), Some("sakana-key"));
1589
1590        clear_known_envs();
1591    }
1592
1593    #[test]
1594    fn wanjie_ark_env_aliases_resolve() {
1595        let _guard = env_lock();
1596        clear_known_envs();
1597        unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1598
1599        assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1600        assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1601        assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1602
1603        clear_known_envs();
1604    }
1605
1606    #[test]
1607    fn xai_env_aliases_resolve() {
1608        let _guard = env_lock();
1609        clear_known_envs();
1610        unsafe { std::env::set_var("XAI_API_KEY", "xai-key") };
1611
1612        assert_eq!(env_for("xai").as_deref(), Some("xai-key"));
1613        assert_eq!(env_for("x-ai").as_deref(), Some("xai-key"));
1614        assert_eq!(env_for("x_ai").as_deref(), Some("xai-key"));
1615        assert_eq!(env_for("grok").as_deref(), Some("xai-key"));
1616
1617        clear_known_envs();
1618    }
1619
1620    #[test]
1621    fn telecomjs_env_aliases_resolve() {
1622        let _guard = env_lock();
1623        clear_known_envs();
1624        unsafe { std::env::set_var("TELECOMJS_API_KEY", "telecom-key") };
1625
1626        for alias in [
1627            "telecomjs",
1628            "telecom-js",
1629            "telecom_js",
1630            "telecomjs-cn",
1631            "tokenhub",
1632        ] {
1633            assert_eq!(env_for(alias).as_deref(), Some("telecom-key"), "{alias}");
1634        }
1635
1636        clear_known_envs();
1637    }
1638
1639    #[test]
1640    fn opencode_go_env_aliases_resolve() {
1641        let _guard = env_lock();
1642        clear_known_envs();
1643        unsafe { std::env::set_var("OPENCODE_GO_API_KEY", "go-key") };
1644
1645        for alias in ["opencode-go", "opencode_go", "opencodego"] {
1646            assert_eq!(env_for(alias).as_deref(), Some("go-key"), "{alias}");
1647        }
1648
1649        clear_known_envs();
1650    }
1651
1652    #[test]
1653    fn meta_model_api_env_aliases_resolve() {
1654        let _guard = env_lock();
1655        clear_known_envs();
1656        unsafe { std::env::set_var("MODEL_API_KEY", "meta-key") };
1657
1658        for alias in [
1659            "meta",
1660            "meta-ai",
1661            "meta_ai",
1662            "meta-model-api",
1663            "meta_model_api",
1664            "muse",
1665            "muse-spark",
1666        ] {
1667            assert_eq!(env_for(alias).as_deref(), Some("meta-key"), "{alias}");
1668        }
1669
1670        clear_known_envs();
1671        unsafe { std::env::set_var("META_MODEL_API_KEY", "meta-prefixed-key") };
1672        assert_eq!(env_for("meta").as_deref(), Some("meta-prefixed-key"),);
1673
1674        clear_known_envs();
1675    }
1676
1677    #[test]
1678    fn xiaomi_mimo_env_aliases_resolve() {
1679        let _guard = env_lock();
1680        clear_known_envs();
1681        unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1682
1683        assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1684        assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1685        assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1686        assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1687
1688        clear_known_envs();
1689
1690        unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1691        assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1692        clear_known_envs();
1693    }
1694
1695    #[test]
1696    fn fireworks_env_aliases_resolve() {
1697        let _lock = env_lock();
1698        clear_known_envs();
1699        // Safety: env mutation guarded by env_lock().
1700        unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1701
1702        assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1703        assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1704        // Safety: env mutation guarded by env_lock().
1705        unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1706    }
1707
1708    #[test]
1709    fn together_env_aliases_resolve() {
1710        let _lock = env_lock();
1711        clear_known_envs();
1712        // Safety: env mutation guarded by env_lock().
1713        unsafe { std::env::set_var("TOGETHER_API_KEY", "together-key") };
1714
1715        // Canonical id plus the legacy hyphen/underscore spellings AND the
1716        // separator-free `togetherai` id Models.dev publishes must all resolve.
1717        assert_eq!(env_for("together").as_deref(), Some("together-key"));
1718        assert_eq!(env_for("together-ai").as_deref(), Some("together-key"));
1719        assert_eq!(env_for("together_ai").as_deref(), Some("together-key"));
1720        assert_eq!(env_for("togetherai").as_deref(), Some("together-key"));
1721        // Safety: env mutation guarded by env_lock().
1722        unsafe { std::env::remove_var("TOGETHER_API_KEY") };
1723    }
1724
1725    #[test]
1726    fn deepinfra_env_aliases_resolve() {
1727        let _lock = env_lock();
1728        clear_known_envs();
1729        // Safety: env mutation guarded by env_lock().
1730        unsafe { std::env::set_var("DEEPINFRA_API_KEY", "di-key") };
1731
1732        assert_eq!(env_for("deepinfra").as_deref(), Some("di-key"));
1733        assert_eq!(env_for("deep-infra").as_deref(), Some("di-key"));
1734        assert_eq!(env_for("deep_infra").as_deref(), Some("di-key"));
1735        // Safety: env mutation guarded by env_lock().
1736        unsafe { std::env::remove_var("DEEPINFRA_API_KEY") };
1737
1738        // The DEEPINFRA_TOKEN fallback is honored when the primary key is unset.
1739        // Safety: env mutation guarded by env_lock().
1740        unsafe { std::env::set_var("DEEPINFRA_TOKEN", "di-token") };
1741        assert_eq!(env_for("deepinfra").as_deref(), Some("di-token"));
1742        // Safety: env mutation guarded by env_lock().
1743        unsafe { std::env::remove_var("DEEPINFRA_TOKEN") };
1744    }
1745
1746    #[test]
1747    fn novita_env_aliases_resolve() {
1748        let _lock = env_lock();
1749        clear_known_envs();
1750        // Safety: env mutation guarded by env_lock().
1751        unsafe { std::env::set_var("NOVITA_API_KEY", "novita-key") };
1752
1753        assert_eq!(env_for("novita").as_deref(), Some("novita-key"));
1754        // `novita-ai` is the Models.dev provider id (Refs #4186).
1755        assert_eq!(env_for("novita-ai").as_deref(), Some("novita-key"));
1756        assert_eq!(env_for("novita_ai").as_deref(), Some("novita-key"));
1757        // Safety: env mutation guarded by env_lock().
1758        unsafe { std::env::remove_var("NOVITA_API_KEY") };
1759    }
1760
1761    #[test]
1762    fn siliconflow_env_aliases_resolve() {
1763        let _lock = env_lock();
1764        clear_known_envs();
1765        // Safety: env mutation guarded by env_lock().
1766        unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
1767
1768        assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
1769        assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
1770        assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
1771        assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
1772        assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
1773        // Safety: env mutation guarded by env_lock().
1774        unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
1775    }
1776
1777    #[test]
1778    fn arcee_env_aliases_resolve() {
1779        let _lock = env_lock();
1780        clear_known_envs();
1781        // Safety: env mutation guarded by env_lock().
1782        unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
1783
1784        assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
1785        assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
1786        assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
1787        // Safety: env mutation guarded by env_lock().
1788        unsafe { std::env::remove_var("ARCEE_API_KEY") };
1789    }
1790
1791    #[test]
1792    fn moonshot_kimi_env_aliases_resolve() {
1793        let _lock = env_lock();
1794        clear_known_envs();
1795        // Safety: env mutation guarded by env_lock().
1796        unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
1797
1798        assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
1799        assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
1800        assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
1801        assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
1802        // Safety: env mutation guarded by env_lock().
1803        unsafe { std::env::remove_var("KIMI_API_KEY") };
1804    }
1805
1806    #[test]
1807    fn sglang_env_aliases_resolve() {
1808        let _lock = env_lock();
1809        clear_known_envs();
1810        // Safety: env mutation guarded by env_lock().
1811        unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
1812
1813        assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
1814        assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
1815        // Safety: env mutation guarded by env_lock().
1816        unsafe { std::env::remove_var("SGLANG_API_KEY") };
1817    }
1818
1819    #[test]
1820    fn vllm_env_aliases_resolve() {
1821        let _lock = env_lock();
1822        clear_known_envs();
1823        // Safety: env mutation guarded by env_lock().
1824        unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
1825
1826        assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
1827        assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
1828        // Safety: env mutation guarded by env_lock().
1829        unsafe { std::env::remove_var("VLLM_API_KEY") };
1830    }
1831
1832    #[test]
1833    fn ollama_env_aliases_resolve() {
1834        let _lock = env_lock();
1835        clear_known_envs();
1836        // Safety: env mutation guarded by env_lock().
1837        unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
1838
1839        assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
1840        assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
1841        // Safety: env mutation guarded by env_lock().
1842        unsafe { std::env::remove_var("OLLAMA_API_KEY") };
1843    }
1844
1845    #[cfg(unix)]
1846    #[test]
1847    fn file_store_round_trips_with_secure_perms() {
1848        use std::os::unix::fs::PermissionsExt;
1849
1850        let tmp = tempfile::tempdir().unwrap();
1851        let path = tmp.path().join("nested").join("secrets.json");
1852        let store = FileKeyringStore::new(path.clone());
1853        assert_eq!(store.get("deepseek").unwrap(), None);
1854        store.set("deepseek", "sk-disk").unwrap();
1855        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1856
1857        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1858        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
1859
1860        store.set("openrouter", "or-disk").unwrap();
1861        assert_eq!(
1862            store.get("openrouter").unwrap(),
1863            Some("or-disk".to_string())
1864        );
1865        // First entry must still be intact.
1866        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1867
1868        store.delete("deepseek").unwrap();
1869        assert_eq!(store.get("deepseek").unwrap(), None);
1870    }
1871
1872    #[cfg(unix)]
1873    #[test]
1874    fn file_store_rejects_world_readable_file() {
1875        use std::os::unix::fs::PermissionsExt;
1876        let tmp = tempfile::tempdir().unwrap();
1877        let path = tmp.path().join("secrets.json");
1878        fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
1879        let mut perms = fs::metadata(&path).unwrap().permissions();
1880        perms.set_mode(0o644);
1881        fs::set_permissions(&path, perms).unwrap();
1882
1883        let store = FileKeyringStore::new(path);
1884        let err = store.get("deepseek").unwrap_err();
1885        assert!(
1886            matches!(err, SecretsError::InsecurePermissions { .. }),
1887            "unexpected error: {err}"
1888        );
1889    }
1890
1891    // Regression for #281: `set` and `delete` used to call
1892    // `load_unlocked().unwrap_or_default()`, which silently wiped every
1893    // existing secret whenever the read failed (insecure permissions,
1894    // corrupt JSON, or any other I/O error).
1895
1896    #[cfg(unix)]
1897    #[test]
1898    fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
1899        use std::os::unix::fs::PermissionsExt;
1900        let tmp = tempfile::tempdir().unwrap();
1901        let path = tmp.path().join("secrets.json");
1902        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1903        fs::write(&path, original).unwrap();
1904        let mut perms = fs::metadata(&path).unwrap().permissions();
1905        perms.set_mode(0o644);
1906        fs::set_permissions(&path, perms).unwrap();
1907
1908        let store = FileKeyringStore::new(path.clone());
1909        let err = store.set("openrouter", "or-new").unwrap_err();
1910        assert!(
1911            matches!(err, SecretsError::InsecurePermissions { .. }),
1912            "set must surface the read error rather than overwriting; got: {err}"
1913        );
1914
1915        let on_disk = fs::read_to_string(&path).unwrap();
1916        assert_eq!(
1917            on_disk, original,
1918            "set must not modify the file when load_unlocked errored"
1919        );
1920    }
1921
1922    #[cfg(unix)]
1923    #[test]
1924    fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
1925        use std::os::unix::fs::PermissionsExt;
1926        let tmp = tempfile::tempdir().unwrap();
1927        let path = tmp.path().join("secrets.json");
1928        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1929        fs::write(&path, original).unwrap();
1930        let mut perms = fs::metadata(&path).unwrap().permissions();
1931        perms.set_mode(0o644);
1932        fs::set_permissions(&path, perms).unwrap();
1933
1934        let store = FileKeyringStore::new(path.clone());
1935        let err = store.delete("nvidia").unwrap_err();
1936        assert!(
1937            matches!(err, SecretsError::InsecurePermissions { .. }),
1938            "delete must surface the read error rather than wiping the file; got: {err}"
1939        );
1940        let on_disk = fs::read_to_string(&path).unwrap();
1941        assert_eq!(on_disk, original);
1942    }
1943
1944    #[test]
1945    fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
1946        let tmp = tempfile::tempdir().unwrap();
1947        let path = tmp.path().join("secrets.json");
1948        // Corrupt JSON. Permissions ok where unix; on Windows the perm-check
1949        // doesn't run so we exercise the json-error path directly.
1950        fs::write(&path, "{ this is not valid json").unwrap();
1951        #[cfg(unix)]
1952        {
1953            use std::os::unix::fs::PermissionsExt;
1954            let mut perms = fs::metadata(&path).unwrap().permissions();
1955            perms.set_mode(0o600);
1956            fs::set_permissions(&path, perms).unwrap();
1957        }
1958
1959        let store = FileKeyringStore::new(path.clone());
1960        let err = store.set("deepseek", "sk-new").unwrap_err();
1961        assert!(
1962            matches!(err, SecretsError::Json(_)),
1963            "set must surface the parse error rather than wiping the file; got: {err}"
1964        );
1965        let on_disk = fs::read_to_string(&path).unwrap();
1966        assert_eq!(on_disk, "{ this is not valid json");
1967    }
1968
1969    #[test]
1970    fn file_store_set_still_creates_file_when_missing() {
1971        // Regression guard: the #281 fix removed `unwrap_or_default()` from
1972        // the load call. Make sure the original first-write-creates-the-file
1973        // ergonomic still works — `load_unlocked` returns `Ok(default)` for
1974        // a missing file, so the `?` should pass through cleanly.
1975        let tmp = tempfile::tempdir().unwrap();
1976        let path = tmp.path().join("nested").join("secrets.json");
1977        let store = FileKeyringStore::new(path.clone());
1978
1979        store.set("deepseek", "sk-fresh").unwrap();
1980        assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
1981    }
1982
1983    #[test]
1984    fn file_store_default_path_uses_home() {
1985        let _lock = env_lock();
1986        clear_known_envs();
1987        let tmp = tempfile::tempdir().unwrap();
1988        let _home = EnvVarGuard::set("HOME", tmp.path());
1989        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1990
1991        let path = FileKeyringStore::default_path().unwrap();
1992        assert_eq!(
1993            path,
1994            tmp.path()
1995                .join(".codewhale")
1996                .join("secrets")
1997                .join("secrets.json")
1998        );
1999    }
2000
2001    #[test]
2002    fn default_path_with_explicit_codewhale_home_does_not_migrate_ambient_legacy() {
2003        // FR003-C001: explicit CODEWHALE_HOME must not silently import ambient
2004        // `$HOME/.deepseek/secrets` credentials into the isolated home.
2005        let _lock = env_lock();
2006        clear_known_envs();
2007        let tmp = tempfile::tempdir().unwrap();
2008        let codewhale_home = tmp.path().join("isolated-codewhale-home");
2009        let _home = EnvVarGuard::set("HOME", tmp.path());
2010        let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2011        let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
2012        let legacy = tmp
2013            .path()
2014            .join(".deepseek")
2015            .join("secrets")
2016            .join("secrets.json");
2017        FileKeyringStore::new(&legacy)
2018            .set("deepseek", "synthetic-ambient-legacy-value")
2019            .unwrap();
2020
2021        let path = FileKeyringStore::default_path().unwrap();
2022        assert_eq!(path, codewhale_home.join("secrets").join("secrets.json"));
2023        assert!(
2024            !path.exists(),
2025            "explicit CODEWHALE_HOME must not create/migrate a primary store from ambient legacy"
2026        );
2027
2028        let secrets = Secrets::auto_detect();
2029        assert_eq!(
2030            secrets.get("deepseek").unwrap(),
2031            None,
2032            "explicit CODEWHALE_HOME must not surface ambient legacy credentials"
2033        );
2034    }
2035
2036    #[test]
2037    fn file_backed_default_refuses_relative_secret_path() {
2038        // FR003-C002: a relative fallback would resolve against the workspace
2039        // and risk committing credentials. It must be write-refusing instead.
2040        let secrets =
2041            Secrets::file_backed_from_default_path(Ok(PathBuf::from(".codewhale-secrets.json")));
2042        assert!(matches!(
2043            secrets.set("deepseek", "must-not-land-relative"),
2044            Err(SecretsError::ReadOnly)
2045        ));
2046        assert_eq!(
2047            secrets.get("deepseek").unwrap(),
2048            None,
2049            "unsafe relative fallback must not read a workspace secret file"
2050        );
2051    }
2052
2053    #[test]
2054    fn file_backed_default_refuses_writes_when_home_resolution_fails() {
2055        // Force the exact fallback branch instead of relying on the host's
2056        // dirs::home_dir(), which normally succeeds even with HOME unset.
2057        let err = SecretsError::Io(std::io::Error::new(
2058            std::io::ErrorKind::NotFound,
2059            "synthetic unresolved home",
2060        ));
2061        let secrets = Secrets::file_backed_from_default_path(Err(err));
2062        assert!(matches!(
2063            secrets.set("deepseek", "must-not-persist"),
2064            Err(SecretsError::ReadOnly)
2065        ));
2066        assert_eq!(secrets.get("deepseek").unwrap(), None);
2067    }
2068}