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