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