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