返回 DeepSeek-TUI-2026
lib.rs
根目录 / crates / secrets / src / lib.rs
1 //! Secret storage for DeepSeek API keys.
2 //!
3 //! Provides a small abstraction (`KeyringStore`) plus a default
4 //! implementation backed by the OS keyring (`DefaultKeyringStore`),
5 //! a file-based fallback for headless Linux (`FileKeyringStore`), and
6 //! an in-memory store for tests (`InMemoryKeyringStore`).
7 //!
8 //! Higher-level lookup goes through [`Secrets::resolve`], which checks
9 //! the keyring first and falls back to environment variables. The
10 //! caller (typically the config crate) then falls back to plaintext
11 //! TOML if both are empty — that final layer lives outside this crate
12 //! so the precedence is explicit at the call site.
13 //!
14 //! Hard rule: **keyring → env → config-file**. Never swap.
15 #![deny(missing_docs)]
16
17 use std::collections::HashMap;
18 use std::fs;
19 use std::path::{Path, PathBuf};
20 use std::sync::{Arc, Mutex};
21
22 use serde::{Deserialize, Serialize};
23 use thiserror::Error;
24
25 /// Default OS keychain service name. macOS users can verify entries with
26 /// `security find-generic-password -s deepseek -a <provider>`.
27 pub const DEFAULT_SERVICE: &str = "deepseek";
28
29 /// Errors that may arise from a [`KeyringStore`] backend.
30 #[derive(Debug, Error)]
31 pub enum SecretsError {
32 /// Underlying OS keyring backend reported an error.
33 #[error("keyring backend error: {0}")]
34 Keyring(String),
35 /// File-backed fallback I/O error.
36 #[error("file-backed secret store I/O error: {0}")]
37 Io(#[from] std::io::Error),
38 /// File-backed fallback JSON (de)serialisation error.
39 #[error("file-backed secret store JSON error: {0}")]
40 Json(#[from] serde_json::Error),
41 /// Caught when a stored secret on disk has unsafe permissions.
42 #[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
43 InsecurePermissions {
44 /// Absolute path to the secrets file.
45 path: PathBuf,
46 /// Observed unix permission mode.
47 mode: u32,
48 },
49 }
50
51 /// Abstract secret store; concrete implementations may use the OS
52 /// keyring, a JSON file under `~/.deepseek/secrets/`, or an in-memory
53 /// map (tests).
54 pub trait KeyringStore: Send + Sync {
55 /// Read a secret. Returns `Ok(None)` if no entry exists.
56 fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
57 /// Write a secret, replacing any existing value.
58 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
59 /// Remove a secret. Should not error if the entry is absent.
60 fn delete(&self, key: &str) -> Result<(), SecretsError>;
61 /// Short, human-readable name of the backend (used by `doctor`).
62 fn backend_name(&self) -> &'static str;
63 }
64
65 /// OS keyring backend (macOS Keychain, Windows Credential Manager,
66 /// Linux Secret Service / kwallet).
67 #[derive(Debug, Clone)]
68 pub struct DefaultKeyringStore {
69 /// Keyring service name (defaults to [`DEFAULT_SERVICE`]).
70 service: String,
71 }
72
73 impl Default for DefaultKeyringStore {
74 fn default() -> Self {
75 Self::new(DEFAULT_SERVICE)
76 }
77 }
78
79 impl DefaultKeyringStore {
80 /// Build a new store with the given service name.
81 #[must_use]
82 pub fn new(service: impl Into<String>) -> Self {
83 Self {
84 service: service.into(),
85 }
86 }
87
88 /// Probe the OS keyring without writing anything. Returns `Ok(())` if
89 /// a backend is reachable, otherwise an error describing why not.
90 pub fn probe(&self) -> Result<(), SecretsError> {
91 // `Entry::new` is enough to validate the native macOS/Windows
92 // backend path. Avoid a dummy read there because it can trigger
93 // a second user-visible Keychain/Credential Manager access before
94 // the real provider key lookup.
95 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
116 impl 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 /// In-memory keyring (tests only).
150 #[derive(Debug, Default)]
151 pub struct InMemoryKeyringStore {
152 entries: Mutex<HashMap<String, String>>,
153 }
154
155 impl InMemoryKeyringStore {
156 /// Create an empty store.
157 #[must_use]
158 pub fn new() -> Self {
159 Self::default()
160 }
161 }
162
163 impl 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 /// JSON-on-disk fallback for headless environments without a Secret
187 /// Service / dbus. Stored at `<home>/.deepseek/secrets/secrets.json`
188 /// with mode `0600`.
189 #[derive(Debug, Clone)]
190 pub struct FileKeyringStore {
191 /// Absolute path to the JSON file.
192 path: PathBuf,
193 }
194
195 #[derive(Debug, Default, Serialize, Deserialize)]
196 struct FileSecretsBlob {
197 #[serde(default)]
198 entries: HashMap<String, String>,
199 }
200
201 impl FileKeyringStore {
202 /// Build a store backed by the given JSON file path.
203 #[must_use]
204 pub fn new(path: impl Into<PathBuf>) -> Self {
205 Self { path: path.into() }
206 }
207
208 /// Default path: `<home>/.deepseek/secrets/secrets.json`. Honours
209 /// `HOME` (Unix) and `USERPROFILE` (Windows) via the `dirs` crate.
210 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 /// Path used for storage.
221 #[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 // Reject files with unsafe permissions on unix. On Windows the
231 // ACL model is too different to enforce here; the caller is
232 // responsible for placing the file in a per-user directory.
233 #[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 // Best-effort 0o600 — matches the parent-dir chmod above which
270 // is also `let _ = ...`. Filesystems that don't support Unix
271 // chmod (Docker bind-mounts of NTFS, network shares — #897)
272 // would otherwise fail the whole save here even though the
273 // blob already wrote successfully. The host's native ACLs
274 // are doing access control in those environments.
275 if let Ok(meta) = fs::metadata(&self.path) {
276 let mut perms = meta.permissions();
277 perms.set_mode(0o600);
278 let _ = fs::set_permissions(&self.path, perms);
279 }
280 }
281 Ok(())
282 }
283 }
284
285 impl KeyringStore for FileKeyringStore {
286 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
287 let blob = self.load_unlocked()?;
288 Ok(blob.entries.get(key).cloned())
289 }
290
291 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
292 // load_unlocked already returns Ok(default) for a missing file, so the
293 // first-write-creates-the-file path is preserved. Any other Err
294 // (insecure permissions, corrupt JSON, transient I/O) MUST surface to
295 // the caller — propagating it via `unwrap_or_default()` silently
296 // wipes every previously stored secret on the next `store_unlocked`.
297 let mut blob = self.load_unlocked()?;
298 blob.entries.insert(key.to_string(), value.to_string());
299 self.store_unlocked(&blob)
300 }
301
302 fn delete(&self, key: &str) -> Result<(), SecretsError> {
303 // Same invariant as `set`: never fall back to an empty blob on read
304 // error, or `delete <one-key>` becomes `delete <every-key>`.
305 let mut blob = self.load_unlocked()?;
306 blob.entries.remove(key);
307 self.store_unlocked(&blob)
308 }
309
310 fn backend_name(&self) -> &'static str {
311 "file-based (~/.deepseek/secrets/)"
312 }
313 }
314
315 /// High-level façade combining a [`KeyringStore`] with environment
316 /// variable fallbacks.
317 ///
318 /// Lookup precedence: **keyring → env → none**. Callers that also have
319 /// a TOML config layer must wire that themselves at the very end of
320 /// the chain.
321 #[derive(Clone)]
322 pub struct Secrets {
323 /// Underlying secret store.
324 pub store: Arc<dyn KeyringStore>,
325 /// Owner identifier within the keyring (typically "deepseek"); the
326 /// `key` parameter passed to `resolve` is mapped to a slot in the
327 /// store as-is, while envs are looked up by canonical name.
328 service: String,
329 }
330
331 /// Source layer that provided a resolved secret.
332 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
333 pub enum SecretSource {
334 /// The configured keyring backend returned the secret.
335 Keyring,
336 /// A process environment variable returned the secret.
337 Env,
338 }
339
340 impl std::fmt::Debug for Secrets {
341 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342 f.debug_struct("Secrets")
343 .field("backend", &self.store.backend_name())
344 .field("service", &self.service)
345 .finish()
346 }
347 }
348
349 impl Secrets {
350 /// Build a new façade around a store.
351 #[must_use]
352 pub fn new(store: Arc<dyn KeyringStore>) -> Self {
353 Self {
354 store,
355 service: DEFAULT_SERVICE.to_string(),
356 }
357 }
358
359 /// Construct the platform-appropriate default backend. On platforms
360 /// where an OS keyring backend is reachable this returns
361 /// [`DefaultKeyringStore`]; otherwise it falls back to
362 /// [`FileKeyringStore`] under `~/.deepseek/secrets/`.
363 pub fn auto_detect() -> Self {
364 let default_store = DefaultKeyringStore::default();
365 match default_store.probe() {
366 Ok(()) => Self::new(Arc::new(default_store)),
367 Err(err) => {
368 tracing::warn!(
369 "OS keyring unavailable ({err}); falling back to file-backed secret store"
370 );
371 let path = FileKeyringStore::default_path()
372 .unwrap_or_else(|_| PathBuf::from(".deepseek-secrets.json"));
373 Self::new(Arc::new(FileKeyringStore::new(path)))
374 }
375 }
376 }
377
378 /// Backend label, suitable for `doctor` output.
379 #[must_use]
380 pub fn backend_name(&self) -> &'static str {
381 self.store.backend_name()
382 }
383
384 /// Resolve a secret with `keyring → env → none` precedence.
385 ///
386 /// `name` is the canonical provider name (`"deepseek"`,
387 /// `"openrouter"`, `"novita"`, `"nvidia"`/`"nvidia-nim"`, `"openai"`).
388 /// Empty strings on either layer are treated as "not set".
389 #[must_use]
390 pub fn resolve(&self, name: &str) -> Option<String> {
391 self.resolve_with_source(name).map(|(value, _)| value)
392 }
393
394 /// Resolve a secret and report which layer supplied it.
395 #[must_use]
396 pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
397 if let Ok(Some(v)) = self.store.get(name)
398 && !v.trim().is_empty()
399 {
400 return Some((v, SecretSource::Keyring));
401 }
402 env_for(name).map(|value| (value, SecretSource::Env))
403 }
404
405 /// Convenience: write a secret through the underlying store.
406 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
407 self.store.set(name, value)
408 }
409
410 /// Convenience: delete a secret through the underlying store.
411 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
412 self.store.delete(name)
413 }
414
415 /// Convenience: read a secret directly (no env fallback).
416 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
417 self.store.get(name)
418 }
419 }
420
421 /// Map a canonical provider name to its environment variable, returning
422 /// the value if non-empty.
423 #[must_use]
424 pub fn env_for(name: &str) -> Option<String> {
425 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
426 "deepseek" => &["DEEPSEEK_API_KEY"],
427 "openrouter" => &["OPENROUTER_API_KEY"],
428 "novita" => &["NOVITA_API_KEY"],
429 // NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
430 // catalog endpoint accepts the same DeepSeek-issued key when no
431 // dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
432 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
433 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
434 }
435 "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
436 "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
437 "vllm" | "v-llm" => &["VLLM_API_KEY"],
438 "openai" => &["OPENAI_API_KEY"],
439 _ => return None,
440 };
441 for var in candidates {
442 if let Ok(value) = std::env::var(var)
443 && !value.trim().is_empty()
444 {
445 return Some(value);
446 }
447 }
448 None
449 }
450
451 #[cfg(test)]
452 mod tests {
453 use super::*;
454 use std::sync::{Mutex, OnceLock};
455
456 /// Serialise env-mutating tests: tests in this module poke
457 /// `DEEPSEEK_API_KEY` etc., which is process-global.
458 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
459 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
460 LOCK.get_or_init(|| Mutex::new(()))
461 .lock()
462 .unwrap_or_else(|p| p.into_inner())
463 }
464
465 fn clear_known_envs() {
466 for var in [
467 "DEEPSEEK_API_KEY",
468 "OPENROUTER_API_KEY",
469 "NOVITA_API_KEY",
470 "NVIDIA_API_KEY",
471 "NVIDIA_NIM_API_KEY",
472 "FIREWORKS_API_KEY",
473 "SGLANG_API_KEY",
474 "VLLM_API_KEY",
475 "OPENAI_API_KEY",
476 ] {
477 // Safety: tests serialise on env_lock(); the broader
478 // workspace has the same pattern in `crates/config`.
479 unsafe { std::env::remove_var(var) };
480 }
481 }
482
483 #[test]
484 fn in_memory_store_round_trips() {
485 let store = InMemoryKeyringStore::new();
486 assert_eq!(store.get("deepseek").unwrap(), None);
487 store.set("deepseek", "sk-test").unwrap();
488 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
489 store.set("deepseek", "sk-replaced").unwrap();
490 assert_eq!(
491 store.get("deepseek").unwrap(),
492 Some("sk-replaced".to_string())
493 );
494 store.delete("deepseek").unwrap();
495 assert_eq!(store.get("deepseek").unwrap(), None);
496 // Deleting an absent key is a no-op.
497 store.delete("missing").unwrap();
498 }
499
500 #[test]
501 fn resolve_prefers_keyring_over_env() {
502 let _lock = env_lock();
503 clear_known_envs();
504 // Safety: env mutation guarded by env_lock().
505 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
506
507 let store = Arc::new(InMemoryKeyringStore::new());
508 store.set("deepseek", "ring-key").unwrap();
509 let secrets = Secrets::new(store);
510
511 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
512 assert_eq!(
513 secrets.resolve_with_source("deepseek"),
514 Some(("ring-key".to_string(), SecretSource::Keyring))
515 );
516 // Safety: env mutation guarded by env_lock().
517 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
518 }
519
520 #[test]
521 fn resolve_falls_back_to_env_when_keyring_empty() {
522 let _lock = env_lock();
523 clear_known_envs();
524 // Safety: env mutation guarded by env_lock().
525 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
526
527 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
528 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
529 assert_eq!(
530 secrets.resolve_with_source("deepseek"),
531 Some(("env-fallback".to_string(), SecretSource::Env))
532 );
533 // Safety: env mutation guarded by env_lock().
534 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
535 }
536
537 #[test]
538 fn resolve_returns_none_when_both_layers_empty() {
539 let _lock = env_lock();
540 clear_known_envs();
541 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
542 assert_eq!(secrets.resolve("deepseek"), None);
543 }
544
545 #[test]
546 fn resolve_treats_blank_keyring_value_as_unset() {
547 let _lock = env_lock();
548 clear_known_envs();
549 // Safety: env mutation guarded by env_lock().
550 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
551
552 let store = Arc::new(InMemoryKeyringStore::new());
553 store.set("deepseek", " ").unwrap();
554 let secrets = Secrets::new(store);
555 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
556 // Safety: env mutation guarded by env_lock().
557 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
558 }
559
560 #[test]
561 fn nvidia_env_aliases_resolve() {
562 let _lock = env_lock();
563 clear_known_envs();
564 // Safety: env mutation guarded by env_lock().
565 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
566 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
567 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
568 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
569 // Safety: env mutation guarded by env_lock().
570 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
571 }
572
573 #[test]
574 fn fireworks_env_aliases_resolve() {
575 let _lock = env_lock();
576 clear_known_envs();
577 // Safety: env mutation guarded by env_lock().
578 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
579
580 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
581 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
582 // Safety: env mutation guarded by env_lock().
583 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
584 }
585
586 #[test]
587 fn sglang_env_aliases_resolve() {
588 let _lock = env_lock();
589 clear_known_envs();
590 // Safety: env mutation guarded by env_lock().
591 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
592
593 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
594 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
595 // Safety: env mutation guarded by env_lock().
596 unsafe { std::env::remove_var("SGLANG_API_KEY") };
597 }
598
599 #[test]
600 fn vllm_env_aliases_resolve() {
601 let _lock = env_lock();
602 clear_known_envs();
603 // Safety: env mutation guarded by env_lock().
604 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
605
606 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
607 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
608 // Safety: env mutation guarded by env_lock().
609 unsafe { std::env::remove_var("VLLM_API_KEY") };
610 }
611
612 #[cfg(unix)]
613 #[test]
614 fn file_store_round_trips_with_secure_perms() {
615 use std::os::unix::fs::PermissionsExt;
616
617 let tmp = tempfile::tempdir().unwrap();
618 let path = tmp.path().join("nested").join("secrets.json");
619 let store = FileKeyringStore::new(path.clone());
620 assert_eq!(store.get("deepseek").unwrap(), None);
621 store.set("deepseek", "sk-disk").unwrap();
622 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
623
624 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
625 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
626
627 store.set("openrouter", "or-disk").unwrap();
628 assert_eq!(
629 store.get("openrouter").unwrap(),
630 Some("or-disk".to_string())
631 );
632 // First entry must still be intact.
633 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
634
635 store.delete("deepseek").unwrap();
636 assert_eq!(store.get("deepseek").unwrap(), None);
637 }
638
639 #[cfg(unix)]
640 #[test]
641 fn file_store_rejects_world_readable_file() {
642 use std::os::unix::fs::PermissionsExt;
643 let tmp = tempfile::tempdir().unwrap();
644 let path = tmp.path().join("secrets.json");
645 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
646 let mut perms = fs::metadata(&path).unwrap().permissions();
647 perms.set_mode(0o644);
648 fs::set_permissions(&path, perms).unwrap();
649
650 let store = FileKeyringStore::new(path);
651 let err = store.get("deepseek").unwrap_err();
652 assert!(
653 matches!(err, SecretsError::InsecurePermissions { .. }),
654 "unexpected error: {err}"
655 );
656 }
657
658 // Regression for #281: `set` and `delete` used to call
659 // `load_unlocked().unwrap_or_default()`, which silently wiped every
660 // existing secret whenever the read failed (insecure permissions,
661 // corrupt JSON, or any other I/O error).
662
663 #[cfg(unix)]
664 #[test]
665 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
666 use std::os::unix::fs::PermissionsExt;
667 let tmp = tempfile::tempdir().unwrap();
668 let path = tmp.path().join("secrets.json");
669 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
670 fs::write(&path, original).unwrap();
671 let mut perms = fs::metadata(&path).unwrap().permissions();
672 perms.set_mode(0o644);
673 fs::set_permissions(&path, perms).unwrap();
674
675 let store = FileKeyringStore::new(path.clone());
676 let err = store.set("openrouter", "or-new").unwrap_err();
677 assert!(
678 matches!(err, SecretsError::InsecurePermissions { .. }),
679 "set must surface the read error rather than overwriting; got: {err}"
680 );
681
682 let on_disk = fs::read_to_string(&path).unwrap();
683 assert_eq!(
684 on_disk, original,
685 "set must not modify the file when load_unlocked errored"
686 );
687 }
688
689 #[cfg(unix)]
690 #[test]
691 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
692 use std::os::unix::fs::PermissionsExt;
693 let tmp = tempfile::tempdir().unwrap();
694 let path = tmp.path().join("secrets.json");
695 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
696 fs::write(&path, original).unwrap();
697 let mut perms = fs::metadata(&path).unwrap().permissions();
698 perms.set_mode(0o644);
699 fs::set_permissions(&path, perms).unwrap();
700
701 let store = FileKeyringStore::new(path.clone());
702 let err = store.delete("nvidia").unwrap_err();
703 assert!(
704 matches!(err, SecretsError::InsecurePermissions { .. }),
705 "delete must surface the read error rather than wiping the file; got: {err}"
706 );
707 let on_disk = fs::read_to_string(&path).unwrap();
708 assert_eq!(on_disk, original);
709 }
710
711 #[test]
712 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
713 let tmp = tempfile::tempdir().unwrap();
714 let path = tmp.path().join("secrets.json");
715 // Corrupt JSON. Permissions ok where unix; on Windows the perm-check
716 // doesn't run so we exercise the json-error path directly.
717 fs::write(&path, "{ this is not valid json").unwrap();
718 #[cfg(unix)]
719 {
720 use std::os::unix::fs::PermissionsExt;
721 let mut perms = fs::metadata(&path).unwrap().permissions();
722 perms.set_mode(0o600);
723 fs::set_permissions(&path, perms).unwrap();
724 }
725
726 let store = FileKeyringStore::new(path.clone());
727 let err = store.set("deepseek", "sk-new").unwrap_err();
728 assert!(
729 matches!(err, SecretsError::Json(_)),
730 "set must surface the parse error rather than wiping the file; got: {err}"
731 );
732 let on_disk = fs::read_to_string(&path).unwrap();
733 assert_eq!(on_disk, "{ this is not valid json");
734 }
735
736 #[test]
737 fn file_store_set_still_creates_file_when_missing() {
738 // Regression guard: the #281 fix removed `unwrap_or_default()` from
739 // the load call. Make sure the original first-write-creates-the-file
740 // ergonomic still works — `load_unlocked` returns `Ok(default)` for
741 // a missing file, so the `?` should pass through cleanly.
742 let tmp = tempfile::tempdir().unwrap();
743 let path = tmp.path().join("nested").join("secrets.json");
744 let store = FileKeyringStore::new(path.clone());
745
746 store.set("deepseek", "sk-fresh").unwrap();
747 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
748 }
749
750 #[test]
751 fn file_store_default_path_uses_home() {
752 // We don't override HOME here (other tests do); we just check the
753 // shape of the path is `<home>/.deepseek/secrets/secrets.json`.
754 let path = FileKeyringStore::default_path().unwrap();
755 assert!(
756 path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
757 "unexpected default path: {}",
758 path.display()
759 );
760 }
761 }
762
762 lines RUST