Skip to main content

deepseek_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 (`"deepseek"`,
488    /// `"openrouter"`, `"novita"`, `"nvidia"`/`"nvidia-nim"`, `"openai"`,
489    /// or `"atlascloud"`).
490    /// Empty strings on either layer are treated as "not set".
491    #[must_use]
492    pub fn resolve(&self, name: &str) -> Option<String> {
493        self.resolve_with_source(name).map(|(value, _)| value)
494    }
495
496    /// Resolve a secret and report which layer supplied it.
497    #[must_use]
498    pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
499        if let Ok(Some(v)) = self.store.get(name)
500            && !v.trim().is_empty()
501        {
502            return Some((v, SecretSource::Keyring));
503        }
504        env_for(name).map(|value| (value, SecretSource::Env))
505    }
506
507    /// Convenience: write a secret through the underlying store.
508    pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
509        self.store.set(name, value)
510    }
511
512    /// Convenience: delete a secret through the underlying store.
513    pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
514        self.store.delete(name)
515    }
516
517    /// Convenience: read a secret directly (no env fallback).
518    pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
519        self.store.get(name)
520    }
521}
522
523/// Map a canonical provider name to its environment variable, returning
524/// the value if non-empty.
525#[must_use]
526pub fn env_for(name: &str) -> Option<String> {
527    let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
528        "deepseek" => &["DEEPSEEK_API_KEY"],
529        "openrouter" => &["OPENROUTER_API_KEY"],
530        "novita" => &["NOVITA_API_KEY"],
531        // NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
532        // catalog endpoint accepts the same DeepSeek-issued key when no
533        // dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
534        "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
535            &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
536        }
537        "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
538        "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
539        "vllm" | "v-llm" => &["VLLM_API_KEY"],
540        "ollama" | "ollama-local" => &["OLLAMA_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 sglang_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("SGLANG_API_KEY", "sglang-key") };
787
788        assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
789        assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
790        // Safety: env mutation guarded by env_lock().
791        unsafe { std::env::remove_var("SGLANG_API_KEY") };
792    }
793
794    #[test]
795    fn vllm_env_aliases_resolve() {
796        let _lock = env_lock();
797        clear_known_envs();
798        // Safety: env mutation guarded by env_lock().
799        unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
800
801        assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
802        assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
803        // Safety: env mutation guarded by env_lock().
804        unsafe { std::env::remove_var("VLLM_API_KEY") };
805    }
806
807    #[test]
808    fn ollama_env_aliases_resolve() {
809        let _lock = env_lock();
810        clear_known_envs();
811        // Safety: env mutation guarded by env_lock().
812        unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
813
814        assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
815        assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
816        // Safety: env mutation guarded by env_lock().
817        unsafe { std::env::remove_var("OLLAMA_API_KEY") };
818    }
819
820    #[cfg(unix)]
821    #[test]
822    fn file_store_round_trips_with_secure_perms() {
823        use std::os::unix::fs::PermissionsExt;
824
825        let tmp = tempfile::tempdir().unwrap();
826        let path = tmp.path().join("nested").join("secrets.json");
827        let store = FileKeyringStore::new(path.clone());
828        assert_eq!(store.get("deepseek").unwrap(), None);
829        store.set("deepseek", "sk-disk").unwrap();
830        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
831
832        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
833        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
834
835        store.set("openrouter", "or-disk").unwrap();
836        assert_eq!(
837            store.get("openrouter").unwrap(),
838            Some("or-disk".to_string())
839        );
840        // First entry must still be intact.
841        assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
842
843        store.delete("deepseek").unwrap();
844        assert_eq!(store.get("deepseek").unwrap(), None);
845    }
846
847    #[cfg(unix)]
848    #[test]
849    fn file_store_rejects_world_readable_file() {
850        use std::os::unix::fs::PermissionsExt;
851        let tmp = tempfile::tempdir().unwrap();
852        let path = tmp.path().join("secrets.json");
853        fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
854        let mut perms = fs::metadata(&path).unwrap().permissions();
855        perms.set_mode(0o644);
856        fs::set_permissions(&path, perms).unwrap();
857
858        let store = FileKeyringStore::new(path);
859        let err = store.get("deepseek").unwrap_err();
860        assert!(
861            matches!(err, SecretsError::InsecurePermissions { .. }),
862            "unexpected error: {err}"
863        );
864    }
865
866    // Regression for #281: `set` and `delete` used to call
867    // `load_unlocked().unwrap_or_default()`, which silently wiped every
868    // existing secret whenever the read failed (insecure permissions,
869    // corrupt JSON, or any other I/O error).
870
871    #[cfg(unix)]
872    #[test]
873    fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
874        use std::os::unix::fs::PermissionsExt;
875        let tmp = tempfile::tempdir().unwrap();
876        let path = tmp.path().join("secrets.json");
877        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
878        fs::write(&path, original).unwrap();
879        let mut perms = fs::metadata(&path).unwrap().permissions();
880        perms.set_mode(0o644);
881        fs::set_permissions(&path, perms).unwrap();
882
883        let store = FileKeyringStore::new(path.clone());
884        let err = store.set("openrouter", "or-new").unwrap_err();
885        assert!(
886            matches!(err, SecretsError::InsecurePermissions { .. }),
887            "set must surface the read error rather than overwriting; got: {err}"
888        );
889
890        let on_disk = fs::read_to_string(&path).unwrap();
891        assert_eq!(
892            on_disk, original,
893            "set must not modify the file when load_unlocked errored"
894        );
895    }
896
897    #[cfg(unix)]
898    #[test]
899    fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
900        use std::os::unix::fs::PermissionsExt;
901        let tmp = tempfile::tempdir().unwrap();
902        let path = tmp.path().join("secrets.json");
903        let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
904        fs::write(&path, original).unwrap();
905        let mut perms = fs::metadata(&path).unwrap().permissions();
906        perms.set_mode(0o644);
907        fs::set_permissions(&path, perms).unwrap();
908
909        let store = FileKeyringStore::new(path.clone());
910        let err = store.delete("nvidia").unwrap_err();
911        assert!(
912            matches!(err, SecretsError::InsecurePermissions { .. }),
913            "delete must surface the read error rather than wiping the file; got: {err}"
914        );
915        let on_disk = fs::read_to_string(&path).unwrap();
916        assert_eq!(on_disk, original);
917    }
918
919    #[test]
920    fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
921        let tmp = tempfile::tempdir().unwrap();
922        let path = tmp.path().join("secrets.json");
923        // Corrupt JSON. Permissions ok where unix; on Windows the perm-check
924        // doesn't run so we exercise the json-error path directly.
925        fs::write(&path, "{ this is not valid json").unwrap();
926        #[cfg(unix)]
927        {
928            use std::os::unix::fs::PermissionsExt;
929            let mut perms = fs::metadata(&path).unwrap().permissions();
930            perms.set_mode(0o600);
931            fs::set_permissions(&path, perms).unwrap();
932        }
933
934        let store = FileKeyringStore::new(path.clone());
935        let err = store.set("deepseek", "sk-new").unwrap_err();
936        assert!(
937            matches!(err, SecretsError::Json(_)),
938            "set must surface the parse error rather than wiping the file; got: {err}"
939        );
940        let on_disk = fs::read_to_string(&path).unwrap();
941        assert_eq!(on_disk, "{ this is not valid json");
942    }
943
944    #[test]
945    fn file_store_set_still_creates_file_when_missing() {
946        // Regression guard: the #281 fix removed `unwrap_or_default()` from
947        // the load call. Make sure the original first-write-creates-the-file
948        // ergonomic still works — `load_unlocked` returns `Ok(default)` for
949        // a missing file, so the `?` should pass through cleanly.
950        let tmp = tempfile::tempdir().unwrap();
951        let path = tmp.path().join("nested").join("secrets.json");
952        let store = FileKeyringStore::new(path.clone());
953
954        store.set("deepseek", "sk-fresh").unwrap();
955        assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
956    }
957
958    #[test]
959    fn file_store_default_path_uses_home() {
960        // We don't override HOME here (other tests do); we just check the
961        // shape of the path is `<home>/.deepseek/secrets/secrets.json`.
962        let path = FileKeyringStore::default_path().unwrap();
963        assert!(
964            path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
965            "unexpected default path: {}",
966            path.display()
967        );
968    }
969}