1#![deny(missing_docs)]
13
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22pub const DEFAULT_SERVICE: &str = "deepseek";
25pub const SECRET_BACKEND_ENV: &str = "DEEPSEEK_SECRET_BACKEND";
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)]
71pub struct DefaultKeyringStore {
72 service: String,
74}
75
76impl Default for DefaultKeyringStore {
77 fn default() -> Self {
78 Self::new(DEFAULT_SERVICE)
79 }
80}
81
82impl DefaultKeyringStore {
83 #[must_use]
85 pub fn new(service: impl Into<String>) -> Self {
86 Self {
87 service: service.into(),
88 }
89 }
90
91 pub fn probe(&self) -> Result<(), SecretsError> {
94 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
95 {
96 let entry = keyring::Entry::new(&self.service, "__probe__")
101 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
102 #[cfg(any(target_os = "macos", target_os = "windows"))]
103 {
104 let _ = entry;
105 Ok(())
106 }
107 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
108 match entry.get_password() {
109 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
110 Err(keyring::Error::PlatformFailure(err)) => {
111 Err(SecretsError::Keyring(format!("platform failure: {err}")))
112 }
113 Err(keyring::Error::NoStorageAccess(err)) => {
114 Err(SecretsError::Keyring(format!("no storage access: {err}")))
115 }
116 Err(other) => Err(SecretsError::Keyring(other.to_string())),
117 }
118 }
119 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
120 {
121 let _ = &self.service;
122 Err(SecretsError::Keyring(unsupported_keyring_message()))
123 }
124 }
125}
126
127impl KeyringStore for DefaultKeyringStore {
128 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
129 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
130 {
131 let entry = keyring::Entry::new(&self.service, key)
132 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
133 match entry.get_password() {
134 Ok(value) => Ok(Some(value)),
135 Err(keyring::Error::NoEntry) => Ok(None),
136 Err(err) => Err(SecretsError::Keyring(err.to_string())),
137 }
138 }
139 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
140 {
141 let _ = key;
142 Err(SecretsError::Keyring(unsupported_keyring_message()))
143 }
144 }
145
146 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
147 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
148 {
149 let entry = keyring::Entry::new(&self.service, key)
150 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
151 entry
152 .set_password(value)
153 .map_err(|err| SecretsError::Keyring(err.to_string()))
154 }
155 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
156 {
157 let _ = (key, value);
158 Err(SecretsError::Keyring(unsupported_keyring_message()))
159 }
160 }
161
162 fn delete(&self, key: &str) -> Result<(), SecretsError> {
163 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
164 {
165 let entry = keyring::Entry::new(&self.service, key)
166 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
167 match entry.delete_credential() {
168 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
169 Err(err) => Err(SecretsError::Keyring(err.to_string())),
170 }
171 }
172 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
173 {
174 let _ = key;
175 Err(SecretsError::Keyring(unsupported_keyring_message()))
176 }
177 }
178
179 fn backend_name(&self) -> &'static str {
180 "system keyring"
181 }
182}
183
184#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
185fn unsupported_keyring_message() -> String {
186 "system keyring backend is unsupported on this platform".to_string()
187}
188
189#[derive(Debug, Default)]
191pub struct InMemoryKeyringStore {
192 entries: Mutex<HashMap<String, String>>,
193}
194
195impl InMemoryKeyringStore {
196 #[must_use]
198 pub fn new() -> Self {
199 Self::default()
200 }
201}
202
203impl KeyringStore for InMemoryKeyringStore {
204 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
205 let guard = self.entries.lock().map_err(|e| {
206 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
207 })?;
208 Ok(guard.get(key).cloned())
209 }
210
211 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
212 let mut guard = self.entries.lock().map_err(|e| {
213 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
214 })?;
215 guard.insert(key.to_string(), value.to_string());
216 Ok(())
217 }
218
219 fn delete(&self, key: &str) -> Result<(), SecretsError> {
220 let mut guard = self.entries.lock().map_err(|e| {
221 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
222 })?;
223 guard.remove(key);
224 Ok(())
225 }
226
227 fn backend_name(&self) -> &'static str {
228 "in-memory (test)"
229 }
230}
231
232#[derive(Debug, Clone)]
236pub struct FileKeyringStore {
237 path: PathBuf,
239}
240
241#[derive(Debug, Default, Serialize, Deserialize)]
242struct FileSecretsBlob {
243 #[serde(default)]
244 entries: HashMap<String, String>,
245}
246
247impl FileKeyringStore {
248 #[must_use]
250 pub fn new(path: impl Into<PathBuf>) -> Self {
251 Self { path: path.into() }
252 }
253
254 pub fn default_path() -> Result<PathBuf, SecretsError> {
257 let home = dirs::home_dir().ok_or_else(|| {
258 SecretsError::Io(std::io::Error::new(
259 std::io::ErrorKind::NotFound,
260 "could not resolve home directory for FileKeyringStore",
261 ))
262 })?;
263 Ok(home.join(".deepseek").join("secrets").join("secrets.json"))
264 }
265
266 #[must_use]
268 pub fn path(&self) -> &Path {
269 &self.path
270 }
271
272 fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
273 if !self.path.exists() {
274 return Ok(FileSecretsBlob::default());
275 }
276 #[cfg(unix)]
280 {
281 use std::os::unix::fs::PermissionsExt;
282 let meta = fs::metadata(&self.path)?;
283 let mode = meta.permissions().mode() & 0o777;
284 if mode & 0o077 != 0 {
285 return Err(SecretsError::InsecurePermissions {
286 path: self.path.clone(),
287 mode,
288 });
289 }
290 }
291 let raw = fs::read_to_string(&self.path)?;
292 if raw.trim().is_empty() {
293 return Ok(FileSecretsBlob::default());
294 }
295 let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
296 Ok(blob)
297 }
298
299 fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
300 if let Some(parent) = self.path.parent() {
301 fs::create_dir_all(parent)?;
302 #[cfg(unix)]
303 {
304 use std::os::unix::fs::PermissionsExt;
305 let mut perms = fs::metadata(parent)?.permissions();
306 perms.set_mode(0o700);
307 let _ = fs::set_permissions(parent, perms);
308 }
309 }
310 let body = serde_json::to_string_pretty(blob)?;
311 fs::write(&self.path, body)?;
312 #[cfg(unix)]
313 {
314 use std::os::unix::fs::PermissionsExt;
315 if let Ok(meta) = fs::metadata(&self.path) {
322 let mut perms = meta.permissions();
323 perms.set_mode(0o600);
324 let _ = fs::set_permissions(&self.path, perms);
325 }
326 }
327 Ok(())
328 }
329}
330
331impl KeyringStore for FileKeyringStore {
332 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
333 let blob = self.load_unlocked()?;
334 Ok(blob.entries.get(key).cloned())
335 }
336
337 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
338 let mut blob = self.load_unlocked()?;
344 blob.entries.insert(key.to_string(), value.to_string());
345 self.store_unlocked(&blob)
346 }
347
348 fn delete(&self, key: &str) -> Result<(), SecretsError> {
349 let mut blob = self.load_unlocked()?;
352 blob.entries.remove(key);
353 self.store_unlocked(&blob)
354 }
355
356 fn backend_name(&self) -> &'static str {
357 "file-based (~/.deepseek/secrets/)"
358 }
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362enum SecretBackendSelection {
363 File,
364 System,
365 Unknown,
366}
367
368fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
369 match value.map(str::trim).filter(|value| !value.is_empty()) {
370 None => SecretBackendSelection::File,
371 Some(value) => match value.to_ascii_lowercase().as_str() {
372 "file" | "local" | "json" => SecretBackendSelection::File,
373 "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
374 _ => SecretBackendSelection::Unknown,
375 },
376 }
377}
378
379#[derive(Clone)]
386pub struct Secrets {
387 pub store: Arc<dyn KeyringStore>,
389 service: String,
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub enum SecretSource {
398 Keyring,
400 Env,
402}
403
404impl std::fmt::Debug for Secrets {
405 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406 f.debug_struct("Secrets")
407 .field("backend", &self.store.backend_name())
408 .field("service", &self.service)
409 .finish()
410 }
411}
412
413impl Secrets {
414 #[must_use]
416 pub fn new(store: Arc<dyn KeyringStore>) -> Self {
417 Self {
418 store,
419 service: DEFAULT_SERVICE.to_string(),
420 }
421 }
422
423 pub fn auto_detect() -> Self {
428 match secret_backend_selection(std::env::var(SECRET_BACKEND_ENV).ok().as_deref()) {
429 SecretBackendSelection::File => Self::file_backed_default(),
430 SecretBackendSelection::Unknown => {
431 tracing::warn!(
432 "{SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
433 );
434 Self::file_backed_default()
435 }
436 SecretBackendSelection::System => {
437 let default_store = DefaultKeyringStore::default();
438 match default_store.probe() {
439 Ok(()) => Self::new(Arc::new(default_store)),
440 Err(err) => {
441 tracing::warn!(
442 "OS keyring unavailable ({err}); falling back to file-backed secret store"
443 );
444 Self::file_backed_default()
445 }
446 }
447 }
448 }
449 }
450
451 fn file_backed_default() -> Self {
452 let path = FileKeyringStore::default_path()
453 .unwrap_or_else(|_| PathBuf::from(".deepseek-secrets.json"));
454 Self::new(Arc::new(FileKeyringStore::new(path)))
455 }
456
457 #[must_use]
459 pub fn file_backed() -> Self {
460 Self::file_backed_default()
461 }
462
463 #[must_use]
466 pub fn system_keyring() -> Self {
467 let default_store = DefaultKeyringStore::default();
468 match default_store.probe() {
469 Ok(()) => Self::new(Arc::new(default_store)),
470 Err(err) => {
471 tracing::warn!(
472 "OS keyring unavailable ({err}); falling back to file-backed secret store"
473 );
474 Self::file_backed_default()
475 }
476 }
477 }
478
479 #[must_use]
481 pub fn backend_name(&self) -> &'static str {
482 self.store.backend_name()
483 }
484
485 #[must_use]
492 pub fn resolve(&self, name: &str) -> Option<String> {
493 self.resolve_with_source(name).map(|(value, _)| value)
494 }
495
496 #[must_use]
498 pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
499 if let Ok(Some(v)) = self.store.get(name)
500 && !v.trim().is_empty()
501 {
502 return Some((v, SecretSource::Keyring));
503 }
504 env_for(name).map(|value| (value, SecretSource::Env))
505 }
506
507 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
509 self.store.set(name, value)
510 }
511
512 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
514 self.store.delete(name)
515 }
516
517 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
519 self.store.get(name)
520 }
521}
522
523#[must_use]
526pub fn env_for(name: &str) -> Option<String> {
527 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
528 "deepseek" => &["DEEPSEEK_API_KEY"],
529 "openrouter" => &["OPENROUTER_API_KEY"],
530 "novita" => &["NOVITA_API_KEY"],
531 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
535 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
536 }
537 "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
538 "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
539 "vllm" | "v-llm" => &["VLLM_API_KEY"],
540 "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
541 "openai" => &["OPENAI_API_KEY"],
542 "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
543 _ => return None,
544 };
545 for var in candidates {
546 if let Ok(value) = std::env::var(var)
547 && !value.trim().is_empty()
548 {
549 return Some(value);
550 }
551 }
552 None
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use std::sync::{Mutex, OnceLock};
559
560 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
563 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
564 LOCK.get_or_init(|| Mutex::new(()))
565 .lock()
566 .unwrap_or_else(|p| p.into_inner())
567 }
568
569 fn clear_known_envs() {
570 for var in [
571 "DEEPSEEK_API_KEY",
572 "OPENROUTER_API_KEY",
573 "NOVITA_API_KEY",
574 "NVIDIA_API_KEY",
575 "NVIDIA_NIM_API_KEY",
576 "FIREWORKS_API_KEY",
577 "SGLANG_API_KEY",
578 "VLLM_API_KEY",
579 "OLLAMA_API_KEY",
580 "OPENAI_API_KEY",
581 "ATLASCLOUD_API_KEY",
582 SECRET_BACKEND_ENV,
583 ] {
584 unsafe { std::env::remove_var(var) };
587 }
588 }
589
590 #[test]
591 fn backend_selection_defaults_to_file() {
592 assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
593 assert_eq!(
594 secret_backend_selection(Some("")),
595 SecretBackendSelection::File
596 );
597 assert_eq!(
598 secret_backend_selection(Some(" file ")),
599 SecretBackendSelection::File
600 );
601 }
602
603 #[test]
604 fn backend_selection_accepts_explicit_system_keyring() {
605 assert_eq!(
606 secret_backend_selection(Some("system")),
607 SecretBackendSelection::System
608 );
609 assert_eq!(
610 secret_backend_selection(Some("keyring")),
611 SecretBackendSelection::System
612 );
613 assert_eq!(
614 secret_backend_selection(Some("os-keyring")),
615 SecretBackendSelection::System
616 );
617 }
618
619 #[test]
620 fn auto_detect_is_file_backed_by_default() {
621 let _lock = env_lock();
622 clear_known_envs();
623
624 let secrets = Secrets::auto_detect();
625
626 assert_eq!(secrets.backend_name(), "file-based (~/.deepseek/secrets/)");
627 }
628
629 #[test]
630 fn auto_detect_honors_explicit_file_backend() {
631 let _lock = env_lock();
632 clear_known_envs();
633 unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
635
636 let secrets = Secrets::auto_detect();
637
638 assert_eq!(secrets.backend_name(), "file-based (~/.deepseek/secrets/)");
639 unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
641 }
642
643 #[test]
644 fn in_memory_store_round_trips() {
645 let store = InMemoryKeyringStore::new();
646 assert_eq!(store.get("deepseek").unwrap(), None);
647 store.set("deepseek", "sk-test").unwrap();
648 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
649 store.set("deepseek", "sk-replaced").unwrap();
650 assert_eq!(
651 store.get("deepseek").unwrap(),
652 Some("sk-replaced".to_string())
653 );
654 store.delete("deepseek").unwrap();
655 assert_eq!(store.get("deepseek").unwrap(), None);
656 store.delete("missing").unwrap();
658 }
659
660 #[test]
661 fn resolve_prefers_keyring_over_env() {
662 let _lock = env_lock();
663 clear_known_envs();
664 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
666
667 let store = Arc::new(InMemoryKeyringStore::new());
668 store.set("deepseek", "ring-key").unwrap();
669 let secrets = Secrets::new(store);
670
671 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
672 assert_eq!(
673 secrets.resolve_with_source("deepseek"),
674 Some(("ring-key".to_string(), SecretSource::Keyring))
675 );
676 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
678 }
679
680 #[test]
681 fn resolve_falls_back_to_env_when_keyring_empty() {
682 let _lock = env_lock();
683 clear_known_envs();
684 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
686
687 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
688 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
689 assert_eq!(
690 secrets.resolve_with_source("deepseek"),
691 Some(("env-fallback".to_string(), SecretSource::Env))
692 );
693 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
695 }
696
697 #[test]
698 fn resolve_returns_none_when_both_layers_empty() {
699 let _lock = env_lock();
700 clear_known_envs();
701 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
702 assert_eq!(secrets.resolve("deepseek"), None);
703 }
704
705 #[test]
706 fn resolve_treats_blank_keyring_value_as_unset() {
707 let _lock = env_lock();
708 clear_known_envs();
709 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
711
712 let store = Arc::new(InMemoryKeyringStore::new());
713 store.set("deepseek", " ").unwrap();
714 let secrets = Secrets::new(store);
715 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
716 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
718 }
719
720 #[test]
721 fn nvidia_env_aliases_resolve() {
722 let _lock = env_lock();
723 clear_known_envs();
724 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
726 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
727 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
728 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
729 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
731 }
732
733 #[test]
734 fn atlascloud_env_aliases_resolve() {
735 let _guard = env_lock();
736 clear_known_envs();
737 unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
738
739 assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
740 assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
741 assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
742
743 clear_known_envs();
744 }
745
746 #[test]
747 fn fireworks_env_aliases_resolve() {
748 let _lock = env_lock();
749 clear_known_envs();
750 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
752
753 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
754 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
755 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
757 }
758
759 #[test]
760 fn sglang_env_aliases_resolve() {
761 let _lock = env_lock();
762 clear_known_envs();
763 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
765
766 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
767 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
768 unsafe { std::env::remove_var("SGLANG_API_KEY") };
770 }
771
772 #[test]
773 fn vllm_env_aliases_resolve() {
774 let _lock = env_lock();
775 clear_known_envs();
776 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
778
779 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
780 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
781 unsafe { std::env::remove_var("VLLM_API_KEY") };
783 }
784
785 #[test]
786 fn ollama_env_aliases_resolve() {
787 let _lock = env_lock();
788 clear_known_envs();
789 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
791
792 assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
793 assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
794 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
796 }
797
798 #[cfg(unix)]
799 #[test]
800 fn file_store_round_trips_with_secure_perms() {
801 use std::os::unix::fs::PermissionsExt;
802
803 let tmp = tempfile::tempdir().unwrap();
804 let path = tmp.path().join("nested").join("secrets.json");
805 let store = FileKeyringStore::new(path.clone());
806 assert_eq!(store.get("deepseek").unwrap(), None);
807 store.set("deepseek", "sk-disk").unwrap();
808 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
809
810 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
811 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
812
813 store.set("openrouter", "or-disk").unwrap();
814 assert_eq!(
815 store.get("openrouter").unwrap(),
816 Some("or-disk".to_string())
817 );
818 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
820
821 store.delete("deepseek").unwrap();
822 assert_eq!(store.get("deepseek").unwrap(), None);
823 }
824
825 #[cfg(unix)]
826 #[test]
827 fn file_store_rejects_world_readable_file() {
828 use std::os::unix::fs::PermissionsExt;
829 let tmp = tempfile::tempdir().unwrap();
830 let path = tmp.path().join("secrets.json");
831 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
832 let mut perms = fs::metadata(&path).unwrap().permissions();
833 perms.set_mode(0o644);
834 fs::set_permissions(&path, perms).unwrap();
835
836 let store = FileKeyringStore::new(path);
837 let err = store.get("deepseek").unwrap_err();
838 assert!(
839 matches!(err, SecretsError::InsecurePermissions { .. }),
840 "unexpected error: {err}"
841 );
842 }
843
844 #[cfg(unix)]
850 #[test]
851 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
852 use std::os::unix::fs::PermissionsExt;
853 let tmp = tempfile::tempdir().unwrap();
854 let path = tmp.path().join("secrets.json");
855 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
856 fs::write(&path, original).unwrap();
857 let mut perms = fs::metadata(&path).unwrap().permissions();
858 perms.set_mode(0o644);
859 fs::set_permissions(&path, perms).unwrap();
860
861 let store = FileKeyringStore::new(path.clone());
862 let err = store.set("openrouter", "or-new").unwrap_err();
863 assert!(
864 matches!(err, SecretsError::InsecurePermissions { .. }),
865 "set must surface the read error rather than overwriting; got: {err}"
866 );
867
868 let on_disk = fs::read_to_string(&path).unwrap();
869 assert_eq!(
870 on_disk, original,
871 "set must not modify the file when load_unlocked errored"
872 );
873 }
874
875 #[cfg(unix)]
876 #[test]
877 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
878 use std::os::unix::fs::PermissionsExt;
879 let tmp = tempfile::tempdir().unwrap();
880 let path = tmp.path().join("secrets.json");
881 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
882 fs::write(&path, original).unwrap();
883 let mut perms = fs::metadata(&path).unwrap().permissions();
884 perms.set_mode(0o644);
885 fs::set_permissions(&path, perms).unwrap();
886
887 let store = FileKeyringStore::new(path.clone());
888 let err = store.delete("nvidia").unwrap_err();
889 assert!(
890 matches!(err, SecretsError::InsecurePermissions { .. }),
891 "delete must surface the read error rather than wiping the file; got: {err}"
892 );
893 let on_disk = fs::read_to_string(&path).unwrap();
894 assert_eq!(on_disk, original);
895 }
896
897 #[test]
898 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
899 let tmp = tempfile::tempdir().unwrap();
900 let path = tmp.path().join("secrets.json");
901 fs::write(&path, "{ this is not valid json").unwrap();
904 #[cfg(unix)]
905 {
906 use std::os::unix::fs::PermissionsExt;
907 let mut perms = fs::metadata(&path).unwrap().permissions();
908 perms.set_mode(0o600);
909 fs::set_permissions(&path, perms).unwrap();
910 }
911
912 let store = FileKeyringStore::new(path.clone());
913 let err = store.set("deepseek", "sk-new").unwrap_err();
914 assert!(
915 matches!(err, SecretsError::Json(_)),
916 "set must surface the parse error rather than wiping the file; got: {err}"
917 );
918 let on_disk = fs::read_to_string(&path).unwrap();
919 assert_eq!(on_disk, "{ this is not valid json");
920 }
921
922 #[test]
923 fn file_store_set_still_creates_file_when_missing() {
924 let tmp = tempfile::tempdir().unwrap();
929 let path = tmp.path().join("nested").join("secrets.json");
930 let store = FileKeyringStore::new(path.clone());
931
932 store.set("deepseek", "sk-fresh").unwrap();
933 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
934 }
935
936 #[test]
937 fn file_store_default_path_uses_home() {
938 let path = FileKeyringStore::default_path().unwrap();
941 assert!(
942 path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
943 "unexpected default path: {}",
944 path.display()
945 );
946 }
947}