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__")
94 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
95 match entry.get_password() {
96 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
97 Err(keyring::Error::PlatformFailure(err)) => {
98 Err(SecretsError::Keyring(format!("platform failure: {err}")))
99 }
100 Err(keyring::Error::NoStorageAccess(err)) => {
101 Err(SecretsError::Keyring(format!("no storage access: {err}")))
102 }
103 Err(other) => Err(SecretsError::Keyring(other.to_string())),
104 }
105 }
106}
107
108impl KeyringStore for DefaultKeyringStore {
109 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
110 let entry = keyring::Entry::new(&self.service, key)
111 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
112 match entry.get_password() {
113 Ok(value) => Ok(Some(value)),
114 Err(keyring::Error::NoEntry) => Ok(None),
115 Err(err) => Err(SecretsError::Keyring(err.to_string())),
116 }
117 }
118
119 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
120 let entry = keyring::Entry::new(&self.service, key)
121 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
122 entry
123 .set_password(value)
124 .map_err(|err| SecretsError::Keyring(err.to_string()))
125 }
126
127 fn delete(&self, key: &str) -> Result<(), SecretsError> {
128 let entry = keyring::Entry::new(&self.service, key)
129 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
130 match entry.delete_credential() {
131 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
132 Err(err) => Err(SecretsError::Keyring(err.to_string())),
133 }
134 }
135
136 fn backend_name(&self) -> &'static str {
137 "system keyring"
138 }
139}
140
141#[derive(Debug, Default)]
143pub struct InMemoryKeyringStore {
144 entries: Mutex<HashMap<String, String>>,
145}
146
147impl InMemoryKeyringStore {
148 #[must_use]
150 pub fn new() -> Self {
151 Self::default()
152 }
153}
154
155impl KeyringStore for InMemoryKeyringStore {
156 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
157 Ok(self.entries.lock().unwrap().get(key).cloned())
158 }
159
160 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
161 self.entries
162 .lock()
163 .unwrap()
164 .insert(key.to_string(), value.to_string());
165 Ok(())
166 }
167
168 fn delete(&self, key: &str) -> Result<(), SecretsError> {
169 self.entries.lock().unwrap().remove(key);
170 Ok(())
171 }
172
173 fn backend_name(&self) -> &'static str {
174 "in-memory (test)"
175 }
176}
177
178#[derive(Debug, Clone)]
182pub struct FileKeyringStore {
183 path: PathBuf,
185}
186
187#[derive(Debug, Default, Serialize, Deserialize)]
188struct FileSecretsBlob {
189 #[serde(default)]
190 entries: HashMap<String, String>,
191}
192
193impl FileKeyringStore {
194 #[must_use]
196 pub fn new(path: impl Into<PathBuf>) -> Self {
197 Self { path: path.into() }
198 }
199
200 pub fn default_path() -> Result<PathBuf, SecretsError> {
203 let home = dirs::home_dir().ok_or_else(|| {
204 SecretsError::Io(std::io::Error::new(
205 std::io::ErrorKind::NotFound,
206 "could not resolve home directory for FileKeyringStore",
207 ))
208 })?;
209 Ok(home.join(".deepseek").join("secrets").join("secrets.json"))
210 }
211
212 #[must_use]
214 pub fn path(&self) -> &Path {
215 &self.path
216 }
217
218 fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
219 if !self.path.exists() {
220 return Ok(FileSecretsBlob::default());
221 }
222 #[cfg(unix)]
226 {
227 use std::os::unix::fs::PermissionsExt;
228 let meta = fs::metadata(&self.path)?;
229 let mode = meta.permissions().mode() & 0o777;
230 if mode & 0o077 != 0 {
231 return Err(SecretsError::InsecurePermissions {
232 path: self.path.clone(),
233 mode,
234 });
235 }
236 }
237 let raw = fs::read_to_string(&self.path)?;
238 if raw.trim().is_empty() {
239 return Ok(FileSecretsBlob::default());
240 }
241 let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
242 Ok(blob)
243 }
244
245 fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
246 if let Some(parent) = self.path.parent() {
247 fs::create_dir_all(parent)?;
248 #[cfg(unix)]
249 {
250 use std::os::unix::fs::PermissionsExt;
251 let mut perms = fs::metadata(parent)?.permissions();
252 perms.set_mode(0o700);
253 let _ = fs::set_permissions(parent, perms);
254 }
255 }
256 let body = serde_json::to_string_pretty(blob)?;
257 fs::write(&self.path, body)?;
258 #[cfg(unix)]
259 {
260 use std::os::unix::fs::PermissionsExt;
261 let mut perms = fs::metadata(&self.path)?.permissions();
262 perms.set_mode(0o600);
263 fs::set_permissions(&self.path, perms)?;
264 }
265 Ok(())
266 }
267}
268
269impl KeyringStore for FileKeyringStore {
270 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
271 let blob = self.load_unlocked()?;
272 Ok(blob.entries.get(key).cloned())
273 }
274
275 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
276 let mut blob = self.load_unlocked().unwrap_or_default();
277 blob.entries.insert(key.to_string(), value.to_string());
278 self.store_unlocked(&blob)
279 }
280
281 fn delete(&self, key: &str) -> Result<(), SecretsError> {
282 let mut blob = self.load_unlocked().unwrap_or_default();
283 blob.entries.remove(key);
284 self.store_unlocked(&blob)
285 }
286
287 fn backend_name(&self) -> &'static str {
288 "file-based (~/.deepseek/secrets/)"
289 }
290}
291
292#[derive(Clone)]
299pub struct Secrets {
300 pub store: Arc<dyn KeyringStore>,
302 service: String,
306}
307
308impl std::fmt::Debug for Secrets {
309 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310 f.debug_struct("Secrets")
311 .field("backend", &self.store.backend_name())
312 .field("service", &self.service)
313 .finish()
314 }
315}
316
317impl Secrets {
318 #[must_use]
320 pub fn new(store: Arc<dyn KeyringStore>) -> Self {
321 Self {
322 store,
323 service: DEFAULT_SERVICE.to_string(),
324 }
325 }
326
327 pub fn auto_detect() -> Self {
332 let default_store = DefaultKeyringStore::default();
333 match default_store.probe() {
334 Ok(()) => Self::new(Arc::new(default_store)),
335 Err(err) => {
336 tracing::warn!(
337 "OS keyring unavailable ({err}); falling back to file-backed secret store"
338 );
339 let path = FileKeyringStore::default_path()
340 .unwrap_or_else(|_| PathBuf::from(".deepseek-secrets.json"));
341 Self::new(Arc::new(FileKeyringStore::new(path)))
342 }
343 }
344 }
345
346 #[must_use]
348 pub fn backend_name(&self) -> &'static str {
349 self.store.backend_name()
350 }
351
352 #[must_use]
358 pub fn resolve(&self, name: &str) -> Option<String> {
359 if let Ok(Some(v)) = self.store.get(name)
360 && !v.trim().is_empty()
361 {
362 return Some(v);
363 }
364 env_for(name)
365 }
366
367 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
369 self.store.set(name, value)
370 }
371
372 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
374 self.store.delete(name)
375 }
376
377 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
379 self.store.get(name)
380 }
381}
382
383#[must_use]
386pub fn env_for(name: &str) -> Option<String> {
387 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
388 "deepseek" => &["DEEPSEEK_API_KEY"],
389 "openrouter" => &["OPENROUTER_API_KEY"],
390 "novita" => &["NOVITA_API_KEY"],
391 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
395 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
396 }
397 "openai" => &["OPENAI_API_KEY"],
398 _ => return None,
399 };
400 for var in candidates {
401 if let Ok(value) = std::env::var(var)
402 && !value.trim().is_empty()
403 {
404 return Some(value);
405 }
406 }
407 None
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use std::sync::{Mutex, OnceLock};
414
415 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
418 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
419 LOCK.get_or_init(|| Mutex::new(()))
420 .lock()
421 .unwrap_or_else(|p| p.into_inner())
422 }
423
424 fn clear_known_envs() {
425 for var in [
426 "DEEPSEEK_API_KEY",
427 "OPENROUTER_API_KEY",
428 "NOVITA_API_KEY",
429 "NVIDIA_API_KEY",
430 "NVIDIA_NIM_API_KEY",
431 "OPENAI_API_KEY",
432 ] {
433 unsafe { std::env::remove_var(var) };
436 }
437 }
438
439 #[test]
440 fn in_memory_store_round_trips() {
441 let store = InMemoryKeyringStore::new();
442 assert_eq!(store.get("deepseek").unwrap(), None);
443 store.set("deepseek", "sk-test").unwrap();
444 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
445 store.set("deepseek", "sk-replaced").unwrap();
446 assert_eq!(
447 store.get("deepseek").unwrap(),
448 Some("sk-replaced".to_string())
449 );
450 store.delete("deepseek").unwrap();
451 assert_eq!(store.get("deepseek").unwrap(), None);
452 store.delete("missing").unwrap();
454 }
455
456 #[test]
457 fn resolve_prefers_keyring_over_env() {
458 let _lock = env_lock();
459 clear_known_envs();
460 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
462
463 let store = Arc::new(InMemoryKeyringStore::new());
464 store.set("deepseek", "ring-key").unwrap();
465 let secrets = Secrets::new(store);
466
467 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
468 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
470 }
471
472 #[test]
473 fn resolve_falls_back_to_env_when_keyring_empty() {
474 let _lock = env_lock();
475 clear_known_envs();
476 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
478
479 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
480 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
481 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
483 }
484
485 #[test]
486 fn resolve_returns_none_when_both_layers_empty() {
487 let _lock = env_lock();
488 clear_known_envs();
489 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
490 assert_eq!(secrets.resolve("deepseek"), None);
491 }
492
493 #[test]
494 fn resolve_treats_blank_keyring_value_as_unset() {
495 let _lock = env_lock();
496 clear_known_envs();
497 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
499
500 let store = Arc::new(InMemoryKeyringStore::new());
501 store.set("deepseek", " ").unwrap();
502 let secrets = Secrets::new(store);
503 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
504 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
506 }
507
508 #[test]
509 fn nvidia_env_aliases_resolve() {
510 let _lock = env_lock();
511 clear_known_envs();
512 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
514 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
515 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
516 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
517 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
519 }
520
521 #[cfg(unix)]
522 #[test]
523 fn file_store_round_trips_with_secure_perms() {
524 use std::os::unix::fs::PermissionsExt;
525
526 let tmp = tempfile::tempdir().unwrap();
527 let path = tmp.path().join("nested").join("secrets.json");
528 let store = FileKeyringStore::new(path.clone());
529 assert_eq!(store.get("deepseek").unwrap(), None);
530 store.set("deepseek", "sk-disk").unwrap();
531 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
532
533 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
534 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
535
536 store.set("openrouter", "or-disk").unwrap();
537 assert_eq!(
538 store.get("openrouter").unwrap(),
539 Some("or-disk".to_string())
540 );
541 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
543
544 store.delete("deepseek").unwrap();
545 assert_eq!(store.get("deepseek").unwrap(), None);
546 }
547
548 #[cfg(unix)]
549 #[test]
550 fn file_store_rejects_world_readable_file() {
551 use std::os::unix::fs::PermissionsExt;
552 let tmp = tempfile::tempdir().unwrap();
553 let path = tmp.path().join("secrets.json");
554 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
555 let mut perms = fs::metadata(&path).unwrap().permissions();
556 perms.set_mode(0o644);
557 fs::set_permissions(&path, perms).unwrap();
558
559 let store = FileKeyringStore::new(path);
560 let err = store.get("deepseek").unwrap_err();
561 assert!(
562 matches!(err, SecretsError::InsecurePermissions { .. }),
563 "unexpected error: {err}"
564 );
565 }
566
567 #[test]
568 fn file_store_default_path_uses_home() {
569 let path = FileKeyringStore::default_path().unwrap();
572 assert!(
573 path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
574 "unexpected default path: {}",
575 path.display()
576 );
577 }
578}