1#![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
25pub const DEFAULT_SERVICE: &str = "deepseek";
28
29#[derive(Debug, Error)]
31pub enum SecretsError {
32 #[error("keyring backend error: {0}")]
34 Keyring(String),
35 #[error("file-backed secret store I/O error: {0}")]
37 Io(#[from] std::io::Error),
38 #[error("file-backed secret store JSON error: {0}")]
40 Json(#[from] serde_json::Error),
41 #[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
43 InsecurePermissions {
44 path: PathBuf,
46 mode: u32,
48 },
49}
50
51pub trait KeyringStore: Send + Sync {
55 fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
57 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
59 fn delete(&self, key: &str) -> Result<(), SecretsError>;
61 fn backend_name(&self) -> &'static str;
63}
64
65#[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 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#[derive(Debug, Default)]
151pub struct InMemoryKeyringStore {
152 entries: Mutex<HashMap<String, String>>,
153}
154
155impl InMemoryKeyringStore {
156 #[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#[derive(Debug, Clone)]
190pub struct FileKeyringStore {
191 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 #[must_use]
204 pub fn new(path: impl Into<PathBuf>) -> Self {
205 Self { path: path.into() }
206 }
207
208 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 #[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 #[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 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 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#[derive(Clone)]
314pub struct Secrets {
315 pub store: Arc<dyn KeyringStore>,
317 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 #[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 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 #[must_use]
363 pub fn backend_name(&self) -> &'static str {
364 self.store.backend_name()
365 }
366
367 #[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 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
384 self.store.set(name, value)
385 }
386
387 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
389 self.store.delete(name)
390 }
391
392 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
394 self.store.get(name)
395 }
396}
397
398#[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" | "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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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}