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