| 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 |