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 "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
544 | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
545 "WANJIE_ARK_API_KEY",
546 "WANJIE_API_KEY",
547 "WANJIE_MAAS_API_KEY",
548 ],
549 _ => return None,
550 };
551 for var in candidates {
552 if let Ok(value) = std::env::var(var)
553 && !value.trim().is_empty()
554 {
555 return Some(value);
556 }
557 }
558 None
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use std::sync::{Mutex, OnceLock};
565
566 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
569 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
570 LOCK.get_or_init(|| Mutex::new(()))
571 .lock()
572 .unwrap_or_else(|p| p.into_inner())
573 }
574
575 fn clear_known_envs() {
576 for var in [
577 "DEEPSEEK_API_KEY",
578 "OPENROUTER_API_KEY",
579 "NOVITA_API_KEY",
580 "NVIDIA_API_KEY",
581 "NVIDIA_NIM_API_KEY",
582 "FIREWORKS_API_KEY",
583 "SGLANG_API_KEY",
584 "VLLM_API_KEY",
585 "OLLAMA_API_KEY",
586 "OPENAI_API_KEY",
587 "ATLASCLOUD_API_KEY",
588 "WANJIE_ARK_API_KEY",
589 "WANJIE_API_KEY",
590 "WANJIE_MAAS_API_KEY",
591 SECRET_BACKEND_ENV,
592 ] {
593 unsafe { std::env::remove_var(var) };
596 }
597 }
598
599 #[test]
600 fn backend_selection_defaults_to_file() {
601 assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
602 assert_eq!(
603 secret_backend_selection(Some("")),
604 SecretBackendSelection::File
605 );
606 assert_eq!(
607 secret_backend_selection(Some(" file ")),
608 SecretBackendSelection::File
609 );
610 }
611
612 #[test]
613 fn backend_selection_accepts_explicit_system_keyring() {
614 assert_eq!(
615 secret_backend_selection(Some("system")),
616 SecretBackendSelection::System
617 );
618 assert_eq!(
619 secret_backend_selection(Some("keyring")),
620 SecretBackendSelection::System
621 );
622 assert_eq!(
623 secret_backend_selection(Some("os-keyring")),
624 SecretBackendSelection::System
625 );
626 }
627
628 #[test]
629 fn auto_detect_is_file_backed_by_default() {
630 let _lock = env_lock();
631 clear_known_envs();
632
633 let secrets = Secrets::auto_detect();
634
635 assert_eq!(secrets.backend_name(), "file-based (~/.deepseek/secrets/)");
636 }
637
638 #[test]
639 fn auto_detect_honors_explicit_file_backend() {
640 let _lock = env_lock();
641 clear_known_envs();
642 unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
644
645 let secrets = Secrets::auto_detect();
646
647 assert_eq!(secrets.backend_name(), "file-based (~/.deepseek/secrets/)");
648 unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
650 }
651
652 #[test]
653 fn in_memory_store_round_trips() {
654 let store = InMemoryKeyringStore::new();
655 assert_eq!(store.get("deepseek").unwrap(), None);
656 store.set("deepseek", "sk-test").unwrap();
657 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
658 store.set("deepseek", "sk-replaced").unwrap();
659 assert_eq!(
660 store.get("deepseek").unwrap(),
661 Some("sk-replaced".to_string())
662 );
663 store.delete("deepseek").unwrap();
664 assert_eq!(store.get("deepseek").unwrap(), None);
665 store.delete("missing").unwrap();
667 }
668
669 #[test]
670 fn resolve_prefers_keyring_over_env() {
671 let _lock = env_lock();
672 clear_known_envs();
673 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
675
676 let store = Arc::new(InMemoryKeyringStore::new());
677 store.set("deepseek", "ring-key").unwrap();
678 let secrets = Secrets::new(store);
679
680 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
681 assert_eq!(
682 secrets.resolve_with_source("deepseek"),
683 Some(("ring-key".to_string(), SecretSource::Keyring))
684 );
685 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
687 }
688
689 #[test]
690 fn resolve_falls_back_to_env_when_keyring_empty() {
691 let _lock = env_lock();
692 clear_known_envs();
693 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
695
696 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
697 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
698 assert_eq!(
699 secrets.resolve_with_source("deepseek"),
700 Some(("env-fallback".to_string(), SecretSource::Env))
701 );
702 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
704 }
705
706 #[test]
707 fn resolve_returns_none_when_both_layers_empty() {
708 let _lock = env_lock();
709 clear_known_envs();
710 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
711 assert_eq!(secrets.resolve("deepseek"), None);
712 }
713
714 #[test]
715 fn resolve_treats_blank_keyring_value_as_unset() {
716 let _lock = env_lock();
717 clear_known_envs();
718 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
720
721 let store = Arc::new(InMemoryKeyringStore::new());
722 store.set("deepseek", " ").unwrap();
723 let secrets = Secrets::new(store);
724 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
725 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
727 }
728
729 #[test]
730 fn nvidia_env_aliases_resolve() {
731 let _lock = env_lock();
732 clear_known_envs();
733 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
735 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
736 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
737 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
738 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
740 }
741
742 #[test]
743 fn atlascloud_env_aliases_resolve() {
744 let _guard = env_lock();
745 clear_known_envs();
746 unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
747
748 assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
749 assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
750 assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
751
752 clear_known_envs();
753 }
754
755 #[test]
756 fn wanjie_ark_env_aliases_resolve() {
757 let _guard = env_lock();
758 clear_known_envs();
759 unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
760
761 assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
762 assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
763 assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
764
765 clear_known_envs();
766 }
767
768 #[test]
769 fn fireworks_env_aliases_resolve() {
770 let _lock = env_lock();
771 clear_known_envs();
772 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
774
775 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
776 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
777 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
779 }
780
781 #[test]
782 fn sglang_env_aliases_resolve() {
783 let _lock = env_lock();
784 clear_known_envs();
785 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
787
788 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
789 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
790 unsafe { std::env::remove_var("SGLANG_API_KEY") };
792 }
793
794 #[test]
795 fn vllm_env_aliases_resolve() {
796 let _lock = env_lock();
797 clear_known_envs();
798 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
800
801 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
802 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
803 unsafe { std::env::remove_var("VLLM_API_KEY") };
805 }
806
807 #[test]
808 fn ollama_env_aliases_resolve() {
809 let _lock = env_lock();
810 clear_known_envs();
811 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
813
814 assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
815 assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
816 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
818 }
819
820 #[cfg(unix)]
821 #[test]
822 fn file_store_round_trips_with_secure_perms() {
823 use std::os::unix::fs::PermissionsExt;
824
825 let tmp = tempfile::tempdir().unwrap();
826 let path = tmp.path().join("nested").join("secrets.json");
827 let store = FileKeyringStore::new(path.clone());
828 assert_eq!(store.get("deepseek").unwrap(), None);
829 store.set("deepseek", "sk-disk").unwrap();
830 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
831
832 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
833 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
834
835 store.set("openrouter", "or-disk").unwrap();
836 assert_eq!(
837 store.get("openrouter").unwrap(),
838 Some("or-disk".to_string())
839 );
840 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
842
843 store.delete("deepseek").unwrap();
844 assert_eq!(store.get("deepseek").unwrap(), None);
845 }
846
847 #[cfg(unix)]
848 #[test]
849 fn file_store_rejects_world_readable_file() {
850 use std::os::unix::fs::PermissionsExt;
851 let tmp = tempfile::tempdir().unwrap();
852 let path = tmp.path().join("secrets.json");
853 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
854 let mut perms = fs::metadata(&path).unwrap().permissions();
855 perms.set_mode(0o644);
856 fs::set_permissions(&path, perms).unwrap();
857
858 let store = FileKeyringStore::new(path);
859 let err = store.get("deepseek").unwrap_err();
860 assert!(
861 matches!(err, SecretsError::InsecurePermissions { .. }),
862 "unexpected error: {err}"
863 );
864 }
865
866 #[cfg(unix)]
872 #[test]
873 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
874 use std::os::unix::fs::PermissionsExt;
875 let tmp = tempfile::tempdir().unwrap();
876 let path = tmp.path().join("secrets.json");
877 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
878 fs::write(&path, original).unwrap();
879 let mut perms = fs::metadata(&path).unwrap().permissions();
880 perms.set_mode(0o644);
881 fs::set_permissions(&path, perms).unwrap();
882
883 let store = FileKeyringStore::new(path.clone());
884 let err = store.set("openrouter", "or-new").unwrap_err();
885 assert!(
886 matches!(err, SecretsError::InsecurePermissions { .. }),
887 "set must surface the read error rather than overwriting; got: {err}"
888 );
889
890 let on_disk = fs::read_to_string(&path).unwrap();
891 assert_eq!(
892 on_disk, original,
893 "set must not modify the file when load_unlocked errored"
894 );
895 }
896
897 #[cfg(unix)]
898 #[test]
899 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
900 use std::os::unix::fs::PermissionsExt;
901 let tmp = tempfile::tempdir().unwrap();
902 let path = tmp.path().join("secrets.json");
903 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
904 fs::write(&path, original).unwrap();
905 let mut perms = fs::metadata(&path).unwrap().permissions();
906 perms.set_mode(0o644);
907 fs::set_permissions(&path, perms).unwrap();
908
909 let store = FileKeyringStore::new(path.clone());
910 let err = store.delete("nvidia").unwrap_err();
911 assert!(
912 matches!(err, SecretsError::InsecurePermissions { .. }),
913 "delete must surface the read error rather than wiping the file; got: {err}"
914 );
915 let on_disk = fs::read_to_string(&path).unwrap();
916 assert_eq!(on_disk, original);
917 }
918
919 #[test]
920 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
921 let tmp = tempfile::tempdir().unwrap();
922 let path = tmp.path().join("secrets.json");
923 fs::write(&path, "{ this is not valid json").unwrap();
926 #[cfg(unix)]
927 {
928 use std::os::unix::fs::PermissionsExt;
929 let mut perms = fs::metadata(&path).unwrap().permissions();
930 perms.set_mode(0o600);
931 fs::set_permissions(&path, perms).unwrap();
932 }
933
934 let store = FileKeyringStore::new(path.clone());
935 let err = store.set("deepseek", "sk-new").unwrap_err();
936 assert!(
937 matches!(err, SecretsError::Json(_)),
938 "set must surface the parse error rather than wiping the file; got: {err}"
939 );
940 let on_disk = fs::read_to_string(&path).unwrap();
941 assert_eq!(on_disk, "{ this is not valid json");
942 }
943
944 #[test]
945 fn file_store_set_still_creates_file_when_missing() {
946 let tmp = tempfile::tempdir().unwrap();
951 let path = tmp.path().join("nested").join("secrets.json");
952 let store = FileKeyringStore::new(path.clone());
953
954 store.set("deepseek", "sk-fresh").unwrap();
955 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
956 }
957
958 #[test]
959 fn file_store_default_path_uses_home() {
960 let path = FileKeyringStore::default_path().unwrap();
963 assert!(
964 path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
965 "unexpected default path: {}",
966 path.display()
967 );
968 }
969}