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        "openai" => &["OPENAI_API_KEY"],
541        "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
542        "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
543        | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
544            "WANJIE_ARK_API_KEY",
545            "WANJIE_API_KEY",
546            "WANJIE_MAAS_API_KEY",
547        ],
548        _ => return None,
549    };
550    for var in candidates {
551        if let Ok(value) = std::env::var(var)
552            && !value.trim().is_empty()
553        {
554            return Some(value);
555        }
556    }
557    None
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use std::sync::{Mutex, OnceLock};
564
565    /// Serialise env-mutating tests: tests in this module poke
566    /// `DEEPSEEK_API_KEY` etc., which is process-global.
567    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
568        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
569        LOCK.get_or_init(|| Mutex::new(()))
570            .lock()
571            .unwrap_or_else(|p| p.into_inner())
572    }
573
574    fn clear_known_envs() {
575        for var in [
576            "DEEPSEEK_API_KEY",
577            "OPENROUTER_API_KEY",
578            "NOVITA_API_KEY",
579            "NVIDIA_API_KEY",
580            "NVIDIA_NIM_API_KEY",
581            "FIREWORKS_API_KEY",
582            "SGLANG_API_KEY",
583            "VLLM_API_KEY",
584            "OLLAMA_API_KEY",
585            "OPENAI_API_KEY",
586            "ATLASCLOUD_API_KEY",
587            "WANJIE_ARK_API_KEY",
588            "WANJIE_API_KEY",
589            "WANJIE_MAAS_API_KEY",
590            SECRET_BACKEND_ENV,
591        ] {
592            // Safety: tests serialise on env_lock(); the broader
593            // workspace has the same pattern in `crates/config`.
594            unsafe { std::env::remove_var(var) };
595        }
596    }
597
598    #[test]
599    fn backend_selection_defaults_to_file() {
600        assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
601        assert_eq!(
602            secret_backend_selection(Some("")),
603            SecretBackendSelection::File
604        );
605        assert_eq!(
606            secret_backend_selection(Some("  file  ")),
607            SecretBackendSelection::File
608        );
609    }
610
611    #[test]
612    fn backend_selection_accepts_explicit_system_keyring() {
613        assert_eq!(
614            secret_backend_selection(Some("system")),
615            SecretBackendSelection::System
616        );
617        assert_eq!(
618            secret_backend_selection(Some("keyring")),
619            SecretBackendSelection::System
620        );
621        assert_eq!(
622            secret_backend_selection(Some("os-keyring")),
623            SecretBackendSelection::System
624        );
625    }
626
627    #[test]
628    fn auto_detect_is_file_backed_by_default() {
629        let _lock = env_lock();
630        clear_known_envs();
631
632        let secrets = Secrets::auto_detect();
633
634        assert_eq!(secrets.backend_name(), "file-based (~/.deepseek/secrets/)");
635    }
636
637    #[test]
638    fn auto_detect_honors_explicit_file_backend() {
639        let _lock = env_lock();
640        clear_known_envs();
641        // Safety: env mutation guarded by env_lock().
642        unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
643
644        let secrets = Secrets::auto_detect();
645
646        assert_eq!(secrets.backend_name(), "file-based (~/.deepseek/secrets/)");
647        // Safety: env mutation guarded by env_lock().
648        unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
649    }
650
651    #[test]
652    fn in_memory_store_round_trips() {
653        let store = InMemoryKeyringStore::new();
654        assert_eq!(store.get("deepseek").unwrap(), None);
655        store.set("deepseek", "sk-test").unwrap();
656        assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
657        store.set("deepseek", "sk-replaced").unwrap();
658        assert_eq!(
659            store.get("deepseek").unwrap(),
660            Some("sk-replaced".to_string())
661        );
662        store.delete("deepseek").unwrap();
663        assert_eq!(store.get("deepseek").unwrap(), None);
664        // Deleting an absent key is a no-op.
665        store.delete("missing").unwrap();
666    }
667
668    #[test]
669    fn resolve_prefers_keyring_over_env() {
670        let _lock = env_lock();
671        clear_known_envs();
672        // Safety: env mutation guarded by env_lock().
673        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
674
675        let store = Arc::new(InMemoryKeyringStore::new());
676        store.set("deepseek", "ring-key").unwrap();
677        let secrets = Secrets::new(store);
678
679        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
680        assert_eq!(
681            secrets.resolve_with_source("deepseek"),
682            Some(("ring-key".to_string(), SecretSource::Keyring))
683        );
684        // Safety: env mutation guarded by env_lock().
685        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
686    }
687
688    #[test]
689    fn resolve_falls_back_to_env_when_keyring_empty() {
690        let _lock = env_lock();
691        clear_known_envs();
692        // Safety: env mutation guarded by env_lock().
693        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
694
695        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
696        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
697        assert_eq!(
698            secrets.resolve_with_source("deepseek"),
699            Some(("env-fallback".to_string(), SecretSource::Env))
700        );
701        // Safety: env mutation guarded by env_lock().
702        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
703    }
704
705    #[test]
706    fn resolve_returns_none_when_both_layers_empty() {
707        let _lock = env_lock();
708        clear_known_envs();
709        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
710        assert_eq!(secrets.resolve("deepseek"), None);
711    }
712
713    #[test]
714    fn resolve_treats_blank_keyring_value_as_unset() {
715        let _lock = env_lock();
716        clear_known_envs();
717        // Safety: env mutation guarded by env_lock().
718        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
719
720        let store = Arc::new(InMemoryKeyringStore::new());
721        store.set("deepseek", "   ").unwrap();
722        let secrets = Secrets::new(store);
723        assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
724        // Safety: env mutation guarded by env_lock().
725        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
726    }
727
728    #[test]
729    fn nvidia_env_aliases_resolve() {
730        let _lock = env_lock();
731        clear_known_envs();
732        // Safety: env mutation guarded by env_lock().
733        unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
734        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
735        assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
736        assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
737        // Safety: env mutation guarded by env_lock().
738        unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
739    }
740
741    #[test]
742    fn atlascloud_env_aliases_resolve() {
743        let _guard = env_lock();
744        clear_known_envs();
745        unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
746
747        assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
748        assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
749        assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
750
751        clear_known_envs();
752    }
753
754    #[test]
755    fn wanjie_ark_env_aliases_resolve() {
756        let _guard = env_lock();
757        clear_known_envs();
758        unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
759
760        assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
761        assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
762        assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
763
764        clear_known_envs();
765    }
766
767    #[test]
768    fn fireworks_env_aliases_resolve() {
769        let _lock = env_lock();
770        clear_known_envs();
771        // Safety: env mutation guarded by env_lock().
772        unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
773
774        assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
775        assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
776        // Safety: env mutation guarded by env_lock().
777        unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
778    }
779
780    #[test]
781    fn moonshot_kimi_env_aliases_resolve() {
782        let _lock = env_lock();
783        clear_known_envs();
784        // Safety: env mutation guarded by env_lock().
785        unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
786
787        assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
788        assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
789        assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
790        assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
791        // Safety: env mutation guarded by env_lock().
792        unsafe { std::env::remove_var("KIMI_API_KEY") };
793    }
794
795    #[test]
796    fn sglang_env_aliases_resolve() {
797        let _lock = env_lock();
798        clear_known_envs();
799        // Safety: env mutation guarded by env_lock().
800        unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
801
802        assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
803        assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
804        // Safety: env mutation guarded by env_lock().
805        unsafe { std::env::remove_var("SGLANG_API_KEY") };
806    }
807
808    #[test]
809    fn vllm_env_aliases_resolve() {
810        let _lock = env_lock();
811        clear_known_envs();
812        // Safety: env mutation guarded by env_lock().
813        unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
814
815        assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
816        assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
817        // Safety: env mutation guarded by env_lock().
818        unsafe { std::env::remove_var("VLLM_API_KEY") };
819    }
820
821    #[test]
822    fn ollama_env_aliases_resolve() {
823        let _lock = env_lock();
824        clear_known_envs();
825        // Safety: env mutation guarded by env_lock().
826        unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
827
828        assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
829        assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
830        // Safety: env mutation guarded by env_lock().
831        unsafe { std::env::remove_var("OLLAMA_API_KEY") };
832    }
833
834    #[cfg(unix)]
835    #[test]
836    fn file_store_round_trips_with_secure_perms() {
837        use std::os::unix::fs::PermissionsExt;
838
839        let tmp = tempfile::tempdir().unwrap();
840        let path = tmp.path().join("nested").join("secrets.json");
841        let store = FileKeyringStore::new(path.clone());
842        assert_eq!(store.get("deepseek").unwrap(), None);
843        store.set("deepseek", "sk-disk").unwrap();
844        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
845
846        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
847        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
848
849        store.set("openrouter", "or-disk").unwrap();
850        assert_eq!(
851            store.get("openrouter").unwrap(),
852            Some("or-disk".to_string())
853        );
854        // First entry must still be intact.
855        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
856
857        store.delete("deepseek").unwrap();
858        assert_eq!(store.get("deepseek").unwrap(), None);
859    }
860
861    #[cfg(unix)]
862    #[test]
863    fn file_store_rejects_world_readable_file() {
864        use std::os::unix::fs::PermissionsExt;
865        let tmp = tempfile::tempdir().unwrap();
866        let path = tmp.path().join("secrets.json");
867        fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
868        let mut perms = fs::metadata(&path).unwrap().permissions();
869        perms.set_mode(0o644);
870        fs::set_permissions(&path, perms).unwrap();
871
872        let store = FileKeyringStore::new(path);
873        let err = store.get("deepseek").unwrap_err();
874        assert!(
875            matches!(err, SecretsError::InsecurePermissions { .. }),
876            "unexpected error: {err}"
877        );
878    }
879
880    // Regression for #281: `set` and `delete` used to call
881    // `load_unlocked().unwrap_or_default()`, which silently wiped every
882    // existing secret whenever the read failed (insecure permissions,
883    // corrupt JSON, or any other I/O error).
884
885    #[cfg(unix)]
886    #[test]
887    fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
888        use std::os::unix::fs::PermissionsExt;
889        let tmp = tempfile::tempdir().unwrap();
890        let path = tmp.path().join("secrets.json");
891        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
892        fs::write(&path, original).unwrap();
893        let mut perms = fs::metadata(&path).unwrap().permissions();
894        perms.set_mode(0o644);
895        fs::set_permissions(&path, perms).unwrap();
896
897        let store = FileKeyringStore::new(path.clone());
898        let err = store.set("openrouter", "or-new").unwrap_err();
899        assert!(
900            matches!(err, SecretsError::InsecurePermissions { .. }),
901            "set must surface the read error rather than overwriting; got: {err}"
902        );
903
904        let on_disk = fs::read_to_string(&path).unwrap();
905        assert_eq!(
906            on_disk, original,
907            "set must not modify the file when load_unlocked errored"
908        );
909    }
910
911    #[cfg(unix)]
912    #[test]
913    fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
914        use std::os::unix::fs::PermissionsExt;
915        let tmp = tempfile::tempdir().unwrap();
916        let path = tmp.path().join("secrets.json");
917        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
918        fs::write(&path, original).unwrap();
919        let mut perms = fs::metadata(&path).unwrap().permissions();
920        perms.set_mode(0o644);
921        fs::set_permissions(&path, perms).unwrap();
922
923        let store = FileKeyringStore::new(path.clone());
924        let err = store.delete("nvidia").unwrap_err();
925        assert!(
926            matches!(err, SecretsError::InsecurePermissions { .. }),
927            "delete must surface the read error rather than wiping the file; got: {err}"
928        );
929        let on_disk = fs::read_to_string(&path).unwrap();
930        assert_eq!(on_disk, original);
931    }
932
933    #[test]
934    fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
935        let tmp = tempfile::tempdir().unwrap();
936        let path = tmp.path().join("secrets.json");
937        // Corrupt JSON. Permissions ok where unix; on Windows the perm-check
938        // doesn't run so we exercise the json-error path directly.
939        fs::write(&path, "{ this is not valid json").unwrap();
940        #[cfg(unix)]
941        {
942            use std::os::unix::fs::PermissionsExt;
943            let mut perms = fs::metadata(&path).unwrap().permissions();
944            perms.set_mode(0o600);
945            fs::set_permissions(&path, perms).unwrap();
946        }
947
948        let store = FileKeyringStore::new(path.clone());
949        let err = store.set("deepseek", "sk-new").unwrap_err();
950        assert!(
951            matches!(err, SecretsError::Json(_)),
952            "set must surface the parse error rather than wiping the file; got: {err}"
953        );
954        let on_disk = fs::read_to_string(&path).unwrap();
955        assert_eq!(on_disk, "{ this is not valid json");
956    }
957
958    #[test]
959    fn file_store_set_still_creates_file_when_missing() {
960        // Regression guard: the #281 fix removed `unwrap_or_default()` from
961        // the load call. Make sure the original first-write-creates-the-file
962        // ergonomic still works — `load_unlocked` returns `Ok(default)` for
963        // a missing file, so the `?` should pass through cleanly.
964        let tmp = tempfile::tempdir().unwrap();
965        let path = tmp.path().join("nested").join("secrets.json");
966        let store = FileKeyringStore::new(path.clone());
967
968        store.set("deepseek", "sk-fresh").unwrap();
969        assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
970    }
971
972    #[test]
973    fn file_store_default_path_uses_home() {
974        // We don't override HOME here (other tests do); we just check the
975        // shape of the path is `<home>/.deepseek/secrets/secrets.json`.
976        let path = FileKeyringStore::default_path().unwrap();
977        assert!(
978            path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
979            "unexpected default path: {}",
980            path.display()
981        );
982    }
983}