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