1#![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
23pub const DEFAULT_SERVICE: &str = "deepseek";
26
27#[derive(Debug, Error)]
29pub enum SecretsError {
30 #[error("keyring backend error: {0}")]
32 Keyring(String),
33 #[error("file-backed secret store I/O error: {0}")]
35 Io(#[from] std::io::Error),
36 #[error("file-backed secret store JSON error: {0}")]
38 Json(#[from] serde_json::Error),
39 #[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
41 InsecurePermissions {
42 path: PathBuf,
44 mode: u32,
46 },
47}
48
49pub trait KeyringStore: Send + Sync {
53 fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
55 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
57 fn delete(&self, key: &str) -> Result<(), SecretsError>;
59 fn backend_name(&self) -> &'static str;
61}
62
63#[derive(Debug, Clone)]
68pub struct DefaultKeyringStore {
69 service: String,
71}
72
73impl Default for DefaultKeyringStore {
74 fn default() -> Self {
75 Self::new(DEFAULT_SERVICE)
76 }
77}
78
79impl DefaultKeyringStore {
80 #[must_use]
82 pub fn new(service: impl Into<String>) -> Self {
83 Self {
84 service: service.into(),
85 }
86 }
87
88 pub fn probe(&self) -> Result<(), SecretsError> {
91 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
92 {
93 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#[derive(Debug, Default)]
188pub struct InMemoryKeyringStore {
189 entries: Mutex<HashMap<String, String>>,
190}
191
192impl InMemoryKeyringStore {
193 #[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#[derive(Debug, Clone)]
227pub struct FileKeyringStore {
228 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 #[must_use]
241 pub fn new(path: impl Into<PathBuf>) -> Self {
242 Self { path: path.into() }
243 }
244
245 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 #[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 #[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 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 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 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#[derive(Clone)]
359pub struct Secrets {
360 pub store: Arc<dyn KeyringStore>,
362 service: String,
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370pub enum SecretSource {
371 Keyring,
373 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 #[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 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 #[must_use]
417 pub fn backend_name(&self) -> &'static str {
418 self.store.backend_name()
419 }
420
421 #[must_use]
427 pub fn resolve(&self, name: &str) -> Option<String> {
428 self.resolve_with_source(name).map(|(value, _)| value)
429 }
430
431 #[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 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
444 self.store.set(name, value)
445 }
446
447 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
449 self.store.delete(name)
450 }
451
452 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
454 self.store.get(name)
455 }
456}
457
458#[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" | "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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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}