Skip to main content

codewhale_secrets/
lib.rs

1//! Secret storage for DeepSeek API keys.
2//!
3//! Provides a small abstraction (`KeyringStore`) plus a default
4//! file-based implementation (`FileKeyringStore`), an opt-in OS keyring
5//! implementation (`DefaultKeyringStore`), and an in-memory store for tests
6//! (`InMemoryKeyringStore`).
7//!
8//! Higher-level lookup through [`Secrets::resolve`] checks the secret store first
9//! and falls back to environment variables. Config-file precedence lives in the
10//! config crate so user-facing commands can keep `config -> secret store -> env`
11//! explicit at the call site.
12#![deny(missing_docs)]
13
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22/// Default OS keychain service name. macOS users can verify entries with
23/// `security find-generic-password -s deepseek -a <provider>`.
24pub const DEFAULT_SERVICE: &str = "deepseek";
25/// Select the secret storage backend. Supported values are `file` (default)
26/// and `system`/`keyring` for the OS credential store.
27pub const SECRET_BACKEND_ENV: &str = "DEEPSEEK_SECRET_BACKEND";
28
29/// Errors that may arise from a [`KeyringStore`] backend.
30#[derive(Debug, Error)]
31pub enum SecretsError {
32    /// Underlying OS keyring backend reported an error.
33    #[error("keyring backend error: {0}")]
34    Keyring(String),
35    /// File-backed fallback I/O error.
36    #[error("file-backed secret store I/O error: {0}")]
37    Io(#[from] std::io::Error),
38    /// File-backed fallback JSON (de)serialisation error.
39    #[error("file-backed secret store JSON error: {0}")]
40    Json(#[from] serde_json::Error),
41    /// Caught when a stored secret on disk has unsafe permissions.
42    #[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
43    InsecurePermissions {
44        /// Absolute path to the secrets file.
45        path: PathBuf,
46        /// Observed unix permission mode.
47        mode: u32,
48    },
49}
50
51/// Abstract secret store; concrete implementations may use the OS
52/// keyring, a JSON file under `~/.deepseek/secrets/`, or an in-memory
53/// map (tests).
54pub trait KeyringStore: Send + Sync {
55    /// Read a secret. Returns `Ok(None)` if no entry exists.
56    fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
57    /// Write a secret, replacing any existing value.
58    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
59    /// Remove a secret. Should not error if the entry is absent.
60    fn delete(&self, key: &str) -> Result<(), SecretsError>;
61    /// Short, human-readable name of the backend (used by `doctor`).
62    fn backend_name(&self) -> &'static str;
63}
64
65/// OS keyring backend (macOS Keychain, Windows Credential Manager,
66/// Linux Secret Service / kwallet). This backend is opt-in through
67/// [`SECRET_BACKEND_ENV`]. On platforms without a configured native
68/// keyring dependency, probing this backend returns an unsupported error so
69/// [`Secrets::auto_detect`] can fall back to [`FileKeyringStore`].
70#[derive(Debug, Clone)]
71pub struct DefaultKeyringStore {
72    /// Keyring service name (defaults to [`DEFAULT_SERVICE`]).
73    service: String,
74}
75
76impl Default for DefaultKeyringStore {
77    fn default() -> Self {
78        Self::new(DEFAULT_SERVICE)
79    }
80}
81
82impl DefaultKeyringStore {
83    /// Build a new store with the given service name.
84    #[must_use]
85    pub fn new(service: impl Into<String>) -> Self {
86        Self {
87            service: service.into(),
88        }
89    }
90
91    /// Probe the OS keyring without writing anything. Returns `Ok(())` if
92    /// a backend is reachable, otherwise an error describing why not.
93    pub fn probe(&self) -> Result<(), SecretsError> {
94        #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
95        {
96            // `Entry::new` is enough to validate the native macOS/Windows
97            // backend path. Avoid a dummy read there because it can trigger
98            // a second user-visible Keychain/Credential Manager access before
99            // the real provider key lookup.
100            let entry = keyring::Entry::new(&self.service, "__probe__")
101                .map_err(|err| SecretsError::Keyring(err.to_string()))?;
102            #[cfg(any(target_os = "macos", target_os = "windows"))]
103            {
104                let _ = entry;
105                Ok(())
106            }
107            #[cfg(not(any(target_os = "macos", target_os = "windows")))]
108            match entry.get_password() {
109                Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
110                Err(keyring::Error::PlatformFailure(err)) => {
111                    Err(SecretsError::Keyring(format!("platform failure: {err}")))
112                }
113                Err(keyring::Error::NoStorageAccess(err)) => {
114                    Err(SecretsError::Keyring(format!("no storage access: {err}")))
115                }
116                Err(other) => Err(SecretsError::Keyring(other.to_string())),
117            }
118        }
119        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
120        {
121            let _ = &self.service;
122            Err(SecretsError::Keyring(unsupported_keyring_message()))
123        }
124    }
125}
126
127impl KeyringStore for DefaultKeyringStore {
128    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
129        #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
130        {
131            let entry = keyring::Entry::new(&self.service, key)
132                .map_err(|err| SecretsError::Keyring(err.to_string()))?;
133            match entry.get_password() {
134                Ok(value) => Ok(Some(value)),
135                Err(keyring::Error::NoEntry) => Ok(None),
136                Err(err) => Err(SecretsError::Keyring(err.to_string())),
137            }
138        }
139        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
140        {
141            let _ = key;
142            Err(SecretsError::Keyring(unsupported_keyring_message()))
143        }
144    }
145
146    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
147        #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
148        {
149            let entry = keyring::Entry::new(&self.service, key)
150                .map_err(|err| SecretsError::Keyring(err.to_string()))?;
151            entry
152                .set_password(value)
153                .map_err(|err| SecretsError::Keyring(err.to_string()))
154        }
155        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
156        {
157            let _ = (key, value);
158            Err(SecretsError::Keyring(unsupported_keyring_message()))
159        }
160    }
161
162    fn delete(&self, key: &str) -> Result<(), SecretsError> {
163        #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
164        {
165            let entry = keyring::Entry::new(&self.service, key)
166                .map_err(|err| SecretsError::Keyring(err.to_string()))?;
167            match entry.delete_credential() {
168                Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
169                Err(err) => Err(SecretsError::Keyring(err.to_string())),
170            }
171        }
172        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
173        {
174            let _ = key;
175            Err(SecretsError::Keyring(unsupported_keyring_message()))
176        }
177    }
178
179    fn backend_name(&self) -> &'static str {
180        "system keyring"
181    }
182}
183
184#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
185fn unsupported_keyring_message() -> String {
186    "system keyring backend is unsupported on this platform".to_string()
187}
188
189/// In-memory keyring (tests only).
190#[derive(Debug, Default)]
191pub struct InMemoryKeyringStore {
192    entries: Mutex<HashMap<String, String>>,
193}
194
195impl InMemoryKeyringStore {
196    /// Create an empty store.
197    #[must_use]
198    pub fn new() -> Self {
199        Self::default()
200    }
201}
202
203impl KeyringStore for InMemoryKeyringStore {
204    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
205        let guard = self.entries.lock().map_err(|e| {
206            SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
207        })?;
208        Ok(guard.get(key).cloned())
209    }
210
211    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
212        let mut guard = self.entries.lock().map_err(|e| {
213            SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
214        })?;
215        guard.insert(key.to_string(), value.to_string());
216        Ok(())
217    }
218
219    fn delete(&self, key: &str) -> Result<(), SecretsError> {
220        let mut guard = self.entries.lock().map_err(|e| {
221            SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
222        })?;
223        guard.remove(key);
224        Ok(())
225    }
226
227    fn backend_name(&self) -> &'static str {
228        "in-memory (test)"
229    }
230}
231
232/// JSON-on-disk fallback for headless environments without a Secret
233/// Service / dbus. Stored at `<home>/.deepseek/secrets/secrets.json`
234/// with mode `0600`.
235#[derive(Debug, Clone)]
236pub struct FileKeyringStore {
237    /// Absolute path to the JSON file.
238    path: PathBuf,
239}
240
241#[derive(Debug, Default, Serialize, Deserialize)]
242struct FileSecretsBlob {
243    #[serde(default)]
244    entries: HashMap<String, String>,
245}
246
247impl FileKeyringStore {
248    /// Build a store backed by the given JSON file path.
249    #[must_use]
250    pub fn new(path: impl Into<PathBuf>) -> Self {
251        Self { path: path.into() }
252    }
253
254    /// Default path: `<home>/.deepseek/secrets/secrets.json`. Honours
255    /// `HOME` (Unix) and `USERPROFILE` (Windows) via the `dirs` crate.
256    pub fn default_path() -> Result<PathBuf, SecretsError> {
257        let home = dirs::home_dir().ok_or_else(|| {
258            SecretsError::Io(std::io::Error::new(
259                std::io::ErrorKind::NotFound,
260                "could not resolve home directory for FileKeyringStore",
261            ))
262        })?;
263        Ok(home.join(".deepseek").join("secrets").join("secrets.json"))
264    }
265
266    /// Path used for storage.
267    #[must_use]
268    pub fn path(&self) -> &Path {
269        &self.path
270    }
271
272    fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
273        if !self.path.exists() {
274            return Ok(FileSecretsBlob::default());
275        }
276        // Reject files with unsafe permissions on unix. On Windows the
277        // ACL model is too different to enforce here; the caller is
278        // responsible for placing the file in a per-user directory.
279        #[cfg(unix)]
280        {
281            use std::os::unix::fs::PermissionsExt;
282            let meta = fs::metadata(&self.path)?;
283            let mode = meta.permissions().mode() & 0o777;
284            if mode & 0o077 != 0 {
285                return Err(SecretsError::InsecurePermissions {
286                    path: self.path.clone(),
287                    mode,
288                });
289            }
290        }
291        let raw = fs::read_to_string(&self.path)?;
292        if raw.trim().is_empty() {
293            return Ok(FileSecretsBlob::default());
294        }
295        let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
296        Ok(blob)
297    }
298
299    fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
300        if let Some(parent) = self.path.parent() {
301            fs::create_dir_all(parent)?;
302            #[cfg(unix)]
303            {
304                use std::os::unix::fs::PermissionsExt;
305                let mut perms = fs::metadata(parent)?.permissions();
306                perms.set_mode(0o700);
307                let _ = fs::set_permissions(parent, perms);
308            }
309        }
310        let body = serde_json::to_string_pretty(blob)?;
311        fs::write(&self.path, body)?;
312        #[cfg(unix)]
313        {
314            use std::os::unix::fs::PermissionsExt;
315            // Best-effort 0o600 — matches the parent-dir chmod above which
316            // is also `let _ = ...`. Filesystems that don't support Unix
317            // chmod (Docker bind-mounts of NTFS, network shares — #897)
318            // would otherwise fail the whole save here even though the
319            // blob already wrote successfully. The host's native ACLs
320            // are doing access control in those environments.
321            if let Ok(meta) = fs::metadata(&self.path) {
322                let mut perms = meta.permissions();
323                perms.set_mode(0o600);
324                let _ = fs::set_permissions(&self.path, perms);
325            }
326        }
327        Ok(())
328    }
329}
330
331impl KeyringStore for FileKeyringStore {
332    fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
333        let blob = self.load_unlocked()?;
334        Ok(blob.entries.get(key).cloned())
335    }
336
337    fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
338        // load_unlocked already returns Ok(default) for a missing file, so the
339        // first-write-creates-the-file path is preserved. Any other Err
340        // (insecure permissions, corrupt JSON, transient I/O) MUST surface to
341        // the caller — propagating it via `unwrap_or_default()` silently
342        // wipes every previously stored secret on the next `store_unlocked`.
343        let mut blob = self.load_unlocked()?;
344        blob.entries.insert(key.to_string(), value.to_string());
345        self.store_unlocked(&blob)
346    }
347
348    fn delete(&self, key: &str) -> Result<(), SecretsError> {
349        // Same invariant as `set`: never fall back to an empty blob on read
350        // error, or `delete <one-key>` becomes `delete <every-key>`.
351        let mut blob = self.load_unlocked()?;
352        blob.entries.remove(key);
353        self.store_unlocked(&blob)
354    }
355
356    fn backend_name(&self) -> &'static str {
357        "file-based (~/.deepseek/secrets/)"
358    }
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362enum SecretBackendSelection {
363    File,
364    System,
365    Unknown,
366}
367
368fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
369    match value.map(str::trim).filter(|value| !value.is_empty()) {
370        None => SecretBackendSelection::File,
371        Some(value) => match value.to_ascii_lowercase().as_str() {
372            "file" | "local" | "json" => SecretBackendSelection::File,
373            "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
374            _ => SecretBackendSelection::Unknown,
375        },
376    }
377}
378
379/// High-level façade combining a [`KeyringStore`] with environment
380/// variable fallbacks.
381///
382/// Lookup precedence: **secret store → env → none**. Callers that also have
383/// a TOML config layer must wire that themselves at the very end of
384/// the chain.
385#[derive(Clone)]
386pub struct Secrets {
387    /// Underlying secret store.
388    pub store: Arc<dyn KeyringStore>,
389    /// Owner identifier within the secret store (typically "deepseek"); the
390    /// `key` parameter passed to `resolve` is mapped to a slot in the
391    /// store as-is, while envs are looked up by canonical name.
392    service: String,
393}
394
395/// Source layer that provided a resolved secret.
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub enum SecretSource {
398    /// The configured secret-store backend returned the secret.
399    Keyring,
400    /// A process environment variable returned the secret.
401    Env,
402}
403
404impl std::fmt::Debug for Secrets {
405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406        f.debug_struct("Secrets")
407            .field("backend", &self.store.backend_name())
408            .field("service", &self.service)
409            .finish()
410    }
411}
412
413impl Secrets {
414    /// Build a new façade around a store.
415    #[must_use]
416    pub fn new(store: Arc<dyn KeyringStore>) -> Self {
417        Self {
418            store,
419            service: DEFAULT_SERVICE.to_string(),
420        }
421    }
422
423    /// Construct the default backend. The prompt-free default is
424    /// [`FileKeyringStore`] under `~/.deepseek/secrets/`. Set
425    /// [`SECRET_BACKEND_ENV`] to `system` or `keyring` to opt into the OS
426    /// credential store.
427    pub fn auto_detect() -> Self {
428        match secret_backend_selection(std::env::var(SECRET_BACKEND_ENV).ok().as_deref()) {
429            SecretBackendSelection::File => Self::file_backed_default(),
430            SecretBackendSelection::Unknown => {
431                tracing::warn!(
432                    "{SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
433                );
434                Self::file_backed_default()
435            }
436            SecretBackendSelection::System => {
437                let default_store = DefaultKeyringStore::default();
438                match default_store.probe() {
439                    Ok(()) => Self::new(Arc::new(default_store)),
440                    Err(err) => {
441                        tracing::warn!(
442                            "OS keyring unavailable ({err}); falling back to file-backed secret store"
443                        );
444                        Self::file_backed_default()
445                    }
446                }
447            }
448        }
449    }
450
451    fn file_backed_default() -> Self {
452        let path = FileKeyringStore::default_path()
453            .unwrap_or_else(|_| PathBuf::from(".deepseek-secrets.json"));
454        Self::new(Arc::new(FileKeyringStore::new(path)))
455    }
456
457    /// Construct the file-backed default backend directly.
458    #[must_use]
459    pub fn file_backed() -> Self {
460        Self::file_backed_default()
461    }
462
463    /// Construct the opt-in OS credential backend, falling back to the
464    /// file-backed store when the platform backend is unavailable.
465    #[must_use]
466    pub fn system_keyring() -> Self {
467        let default_store = DefaultKeyringStore::default();
468        match default_store.probe() {
469            Ok(()) => Self::new(Arc::new(default_store)),
470            Err(err) => {
471                tracing::warn!(
472                    "OS keyring unavailable ({err}); falling back to file-backed secret store"
473                );
474                Self::file_backed_default()
475            }
476        }
477    }
478
479    /// Backend label, suitable for `doctor` output.
480    #[must_use]
481    pub fn backend_name(&self) -> &'static str {
482        self.store.backend_name()
483    }
484
485    /// Resolve a secret with `secret store → env → none` precedence.
486    ///
487    /// `name` is the canonical provider name or a supported provider alias.
488    /// Empty strings on either layer are treated as "not set".
489    #[must_use]
490    pub fn resolve(&self, name: &str) -> Option<String> {
491        self.resolve_with_source(name).map(|(value, _)| value)
492    }
493
494    /// Resolve a secret and report which layer supplied it.
495    #[must_use]
496    pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
497        if let Ok(Some(v)) = self.store.get(name)
498            && !v.trim().is_empty()
499        {
500            return Some((v, SecretSource::Keyring));
501        }
502        env_for(name).map(|value| (value, SecretSource::Env))
503    }
504
505    /// Convenience: write a secret through the underlying store.
506    pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
507        self.store.set(name, value)
508    }
509
510    /// Convenience: delete a secret through the underlying store.
511    pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
512        self.store.delete(name)
513    }
514
515    /// Convenience: read a secret directly (no env fallback).
516    pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
517        self.store.get(name)
518    }
519}
520
521/// Map a canonical provider name to its environment variable, returning
522/// the value if non-empty.
523#[must_use]
524pub fn env_for(name: &str) -> Option<String> {
525    let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
526        "deepseek" => &["DEEPSEEK_API_KEY"],
527        "openrouter" => &["OPENROUTER_API_KEY"],
528        "novita" => &["NOVITA_API_KEY"],
529        // NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
530        // catalog endpoint accepts the same DeepSeek-issued key when no
531        // dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
532        "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
533            &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
534        }
535        "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
536        "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
537        "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
538        "vllm" | "v-llm" => &["VLLM_API_KEY"],
539        "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
540        "xiaomi" | "mimo" | "xiaomi-mimo" => &["MIMO_API_KEY"],
541        "openai" => &["OPENAI_API_KEY"],
542        "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
543        "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
544        | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
545            "WANJIE_ARK_API_KEY",
546            "WANJIE_API_KEY",
547            "WANJIE_MAAS_API_KEY",
548        ],
549        _ => return None,
550    };
551    for var in candidates {
552        if let Ok(value) = std::env::var(var)
553            && !value.trim().is_empty()
554        {
555            return Some(value);
556        }
557    }
558    None
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use std::sync::{Mutex, OnceLock};
565
566    /// Serialise env-mutating tests: tests in this module poke
567    /// `DEEPSEEK_API_KEY` etc., which is process-global.
568    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
569        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
570        LOCK.get_or_init(|| Mutex::new(()))
571            .lock()
572            .unwrap_or_else(|p| p.into_inner())
573    }
574
575    fn clear_known_envs() {
576        for var in [
577            "DEEPSEEK_API_KEY",
578            "OPENROUTER_API_KEY",
579            "NOVITA_API_KEY",
580            "NVIDIA_API_KEY",
581            "NVIDIA_NIM_API_KEY",
582            "FIREWORKS_API_KEY",
583            "SGLANG_API_KEY",
584            "VLLM_API_KEY",
585            "OLLAMA_API_KEY",
586            "OPENAI_API_KEY",
587            "ATLASCLOUD_API_KEY",
588            "WANJIE_ARK_API_KEY",
589            "WANJIE_API_KEY",
590            "WANJIE_MAAS_API_KEY",
591            SECRET_BACKEND_ENV,
592        ] {
593            // Safety: tests serialise on env_lock(); the broader
594            // workspace has the same pattern in `crates/config`.
595            unsafe { std::env::remove_var(var) };
596        }
597    }
598
599    #[test]
600    fn backend_selection_defaults_to_file() {
601        assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
602        assert_eq!(
603            secret_backend_selection(Some("")),
604            SecretBackendSelection::File
605        );
606        assert_eq!(
607            secret_backend_selection(Some("  file  ")),
608            SecretBackendSelection::File
609        );
610    }
611
612    #[test]
613    fn backend_selection_accepts_explicit_system_keyring() {
614        assert_eq!(
615            secret_backend_selection(Some("system")),
616            SecretBackendSelection::System
617        );
618        assert_eq!(
619            secret_backend_selection(Some("keyring")),
620            SecretBackendSelection::System
621        );
622        assert_eq!(
623            secret_backend_selection(Some("os-keyring")),
624            SecretBackendSelection::System
625        );
626    }
627
628    #[test]
629    fn auto_detect_is_file_backed_by_default() {
630        let _lock = env_lock();
631        clear_known_envs();
632
633        let secrets = Secrets::auto_detect();
634
635        assert_eq!(secrets.backend_name(), "file-based (~/.deepseek/secrets/)");
636    }
637
638    #[test]
639    fn auto_detect_honors_explicit_file_backend() {
640        let _lock = env_lock();
641        clear_known_envs();
642        // Safety: env mutation guarded by env_lock().
643        unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
644
645        let secrets = Secrets::auto_detect();
646
647        assert_eq!(secrets.backend_name(), "file-based (~/.deepseek/secrets/)");
648        // Safety: env mutation guarded by env_lock().
649        unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
650    }
651
652    #[test]
653    fn in_memory_store_round_trips() {
654        let store = InMemoryKeyringStore::new();
655        assert_eq!(store.get("deepseek").unwrap(), None);
656        store.set("deepseek", "sk-test").unwrap();
657        assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
658        store.set("deepseek", "sk-replaced").unwrap();
659        assert_eq!(
660            store.get("deepseek").unwrap(),
661            Some("sk-replaced".to_string())
662        );
663        store.delete("deepseek").unwrap();
664        assert_eq!(store.get("deepseek").unwrap(), None);
665        // Deleting an absent key is a no-op.
666        store.delete("missing").unwrap();
667    }
668
669    #[test]
670    fn resolve_prefers_keyring_over_env() {
671        let _lock = env_lock();
672        clear_known_envs();
673        // Safety: env mutation guarded by env_lock().
674        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
675
676        let store = Arc::new(InMemoryKeyringStore::new());
677        store.set("deepseek", "ring-key").unwrap();
678        let secrets = Secrets::new(store);
679
680        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
681        assert_eq!(
682            secrets.resolve_with_source("deepseek"),
683            Some(("ring-key".to_string(), SecretSource::Keyring))
684        );
685        // Safety: env mutation guarded by env_lock().
686        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
687    }
688
689    #[test]
690    fn resolve_falls_back_to_env_when_keyring_empty() {
691        let _lock = env_lock();
692        clear_known_envs();
693        // Safety: env mutation guarded by env_lock().
694        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
695
696        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
697        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
698        assert_eq!(
699            secrets.resolve_with_source("deepseek"),
700            Some(("env-fallback".to_string(), SecretSource::Env))
701        );
702        // Safety: env mutation guarded by env_lock().
703        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
704    }
705
706    #[test]
707    fn resolve_returns_none_when_both_layers_empty() {
708        let _lock = env_lock();
709        clear_known_envs();
710        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
711        assert_eq!(secrets.resolve("deepseek"), None);
712    }
713
714    #[test]
715    fn resolve_treats_blank_keyring_value_as_unset() {
716        let _lock = env_lock();
717        clear_known_envs();
718        // Safety: env mutation guarded by env_lock().
719        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
720
721        let store = Arc::new(InMemoryKeyringStore::new());
722        store.set("deepseek", "   ").unwrap();
723        let secrets = Secrets::new(store);
724        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
725        // Safety: env mutation guarded by env_lock().
726        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
727    }
728
729    #[test]
730    fn nvidia_env_aliases_resolve() {
731        let _lock = env_lock();
732        clear_known_envs();
733        // Safety: env mutation guarded by env_lock().
734        unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
735        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
736        assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
737        assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
738        // Safety: env mutation guarded by env_lock().
739        unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
740    }
741
742    #[test]
743    fn atlascloud_env_aliases_resolve() {
744        let _guard = env_lock();
745        clear_known_envs();
746        unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
747
748        assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
749        assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
750        assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
751
752        clear_known_envs();
753    }
754
755    #[test]
756    fn wanjie_ark_env_aliases_resolve() {
757        let _guard = env_lock();
758        clear_known_envs();
759        unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
760
761        assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
762        assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
763        assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
764
765        clear_known_envs();
766    }
767
768    #[test]
769    fn fireworks_env_aliases_resolve() {
770        let _lock = env_lock();
771        clear_known_envs();
772        // Safety: env mutation guarded by env_lock().
773        unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
774
775        assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
776        assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
777        // Safety: env mutation guarded by env_lock().
778        unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
779    }
780
781    #[test]
782    fn moonshot_kimi_env_aliases_resolve() {
783        let _lock = env_lock();
784        clear_known_envs();
785        // Safety: env mutation guarded by env_lock().
786        unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
787
788        assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
789        assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
790        assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
791        assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
792        // Safety: env mutation guarded by env_lock().
793        unsafe { std::env::remove_var("KIMI_API_KEY") };
794    }
795
796    #[test]
797    fn sglang_env_aliases_resolve() {
798        let _lock = env_lock();
799        clear_known_envs();
800        // Safety: env mutation guarded by env_lock().
801        unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
802
803        assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
804        assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
805        // Safety: env mutation guarded by env_lock().
806        unsafe { std::env::remove_var("SGLANG_API_KEY") };
807    }
808
809    #[test]
810    fn vllm_env_aliases_resolve() {
811        let _lock = env_lock();
812        clear_known_envs();
813        // Safety: env mutation guarded by env_lock().
814        unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
815
816        assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
817        assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
818        // Safety: env mutation guarded by env_lock().
819        unsafe { std::env::remove_var("VLLM_API_KEY") };
820    }
821
822    #[test]
823    fn ollama_env_aliases_resolve() {
824        let _lock = env_lock();
825        clear_known_envs();
826        // Safety: env mutation guarded by env_lock().
827        unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
828
829        assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
830        assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
831        // Safety: env mutation guarded by env_lock().
832        unsafe { std::env::remove_var("OLLAMA_API_KEY") };
833    }
834
835    #[cfg(unix)]
836    #[test]
837    fn file_store_round_trips_with_secure_perms() {
838        use std::os::unix::fs::PermissionsExt;
839
840        let tmp = tempfile::tempdir().unwrap();
841        let path = tmp.path().join("nested").join("secrets.json");
842        let store = FileKeyringStore::new(path.clone());
843        assert_eq!(store.get("deepseek").unwrap(), None);
844        store.set("deepseek", "sk-disk").unwrap();
845        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
846
847        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
848        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
849
850        store.set("openrouter", "or-disk").unwrap();
851        assert_eq!(
852            store.get("openrouter").unwrap(),
853            Some("or-disk".to_string())
854        );
855        // First entry must still be intact.
856        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
857
858        store.delete("deepseek").unwrap();
859        assert_eq!(store.get("deepseek").unwrap(), None);
860    }
861
862    #[cfg(unix)]
863    #[test]
864    fn file_store_rejects_world_readable_file() {
865        use std::os::unix::fs::PermissionsExt;
866        let tmp = tempfile::tempdir().unwrap();
867        let path = tmp.path().join("secrets.json");
868        fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
869        let mut perms = fs::metadata(&path).unwrap().permissions();
870        perms.set_mode(0o644);
871        fs::set_permissions(&path, perms).unwrap();
872
873        let store = FileKeyringStore::new(path);
874        let err = store.get("deepseek").unwrap_err();
875        assert!(
876            matches!(err, SecretsError::InsecurePermissions { .. }),
877            "unexpected error: {err}"
878        );
879    }
880
881    // Regression for #281: `set` and `delete` used to call
882    // `load_unlocked().unwrap_or_default()`, which silently wiped every
883    // existing secret whenever the read failed (insecure permissions,
884    // corrupt JSON, or any other I/O error).
885
886    #[cfg(unix)]
887    #[test]
888    fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
889        use std::os::unix::fs::PermissionsExt;
890        let tmp = tempfile::tempdir().unwrap();
891        let path = tmp.path().join("secrets.json");
892        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
893        fs::write(&path, original).unwrap();
894        let mut perms = fs::metadata(&path).unwrap().permissions();
895        perms.set_mode(0o644);
896        fs::set_permissions(&path, perms).unwrap();
897
898        let store = FileKeyringStore::new(path.clone());
899        let err = store.set("openrouter", "or-new").unwrap_err();
900        assert!(
901            matches!(err, SecretsError::InsecurePermissions { .. }),
902            "set must surface the read error rather than overwriting; got: {err}"
903        );
904
905        let on_disk = fs::read_to_string(&path).unwrap();
906        assert_eq!(
907            on_disk, original,
908            "set must not modify the file when load_unlocked errored"
909        );
910    }
911
912    #[cfg(unix)]
913    #[test]
914    fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
915        use std::os::unix::fs::PermissionsExt;
916        let tmp = tempfile::tempdir().unwrap();
917        let path = tmp.path().join("secrets.json");
918        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
919        fs::write(&path, original).unwrap();
920        let mut perms = fs::metadata(&path).unwrap().permissions();
921        perms.set_mode(0o644);
922        fs::set_permissions(&path, perms).unwrap();
923
924        let store = FileKeyringStore::new(path.clone());
925        let err = store.delete("nvidia").unwrap_err();
926        assert!(
927            matches!(err, SecretsError::InsecurePermissions { .. }),
928            "delete must surface the read error rather than wiping the file; got: {err}"
929        );
930        let on_disk = fs::read_to_string(&path).unwrap();
931        assert_eq!(on_disk, original);
932    }
933
934    #[test]
935    fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
936        let tmp = tempfile::tempdir().unwrap();
937        let path = tmp.path().join("secrets.json");
938        // Corrupt JSON. Permissions ok where unix; on Windows the perm-check
939        // doesn't run so we exercise the json-error path directly.
940        fs::write(&path, "{ this is not valid json").unwrap();
941        #[cfg(unix)]
942        {
943            use std::os::unix::fs::PermissionsExt;
944            let mut perms = fs::metadata(&path).unwrap().permissions();
945            perms.set_mode(0o600);
946            fs::set_permissions(&path, perms).unwrap();
947        }
948
949        let store = FileKeyringStore::new(path.clone());
950        let err = store.set("deepseek", "sk-new").unwrap_err();
951        assert!(
952            matches!(err, SecretsError::Json(_)),
953            "set must surface the parse error rather than wiping the file; got: {err}"
954        );
955        let on_disk = fs::read_to_string(&path).unwrap();
956        assert_eq!(on_disk, "{ this is not valid json");
957    }
958
959    #[test]
960    fn file_store_set_still_creates_file_when_missing() {
961        // Regression guard: the #281 fix removed `unwrap_or_default()` from
962        // the load call. Make sure the original first-write-creates-the-file
963        // ergonomic still works — `load_unlocked` returns `Ok(default)` for
964        // a missing file, so the `?` should pass through cleanly.
965        let tmp = tempfile::tempdir().unwrap();
966        let path = tmp.path().join("nested").join("secrets.json");
967        let store = FileKeyringStore::new(path.clone());
968
969        store.set("deepseek", "sk-fresh").unwrap();
970        assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
971    }
972
973    #[test]
974    fn file_store_default_path_uses_home() {
975        // We don't override HOME here (other tests do); we just check the
976        // shape of the path is `<home>/.deepseek/secrets/secrets.json`.
977        let path = FileKeyringStore::default_path().unwrap();
978        assert!(
979            path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
980            "unexpected default path: {}",
981            path.display()
982        );
983    }
984}