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";
26pub const SECRET_BACKEND_ENV: &str = "CODEWHALE_SECRET_BACKEND";
29pub const LEGACY_SECRET_BACKEND_ENV: &str = "DEEPSEEK_SECRET_BACKEND";
31const FILE_BACKEND_LABEL: &str = "file-based (~/.codewhale/secrets/)";
32
33#[derive(Debug, Error)]
35pub enum SecretsError {
36 #[error("keyring backend error: {0}")]
38 Keyring(String),
39 #[error("file-backed secret store I/O error: {0}")]
41 Io(#[from] std::io::Error),
42 #[error("file-backed secret store JSON error: {0}")]
44 Json(#[from] serde_json::Error),
45 #[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
47 InsecurePermissions {
48 path: PathBuf,
50 mode: u32,
52 },
53}
54
55pub trait KeyringStore: Send + Sync {
64 fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
69
70 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
75
76 fn delete(&self, key: &str) -> Result<(), SecretsError>;
81
82 fn backend_name(&self) -> &'static str;
88}
89
90#[derive(Debug, Clone)]
103pub struct DefaultKeyringStore {
104 service: String,
107}
108
109impl Default for DefaultKeyringStore {
110 fn default() -> Self {
111 Self::new(DEFAULT_SERVICE)
112 }
113}
114
115impl DefaultKeyringStore {
116 #[must_use]
118 pub fn new(service: impl Into<String>) -> Self {
119 Self {
120 service: service.into(),
121 }
122 }
123
124 pub fn probe(&self) -> Result<(), SecretsError> {
127 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
128 {
129 let entry = keyring::Entry::new(&self.service, "__probe__")
134 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
135 #[cfg(any(target_os = "macos", target_os = "windows"))]
136 {
137 let _ = entry;
138 Ok(())
139 }
140 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
141 match entry.get_password() {
142 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
143 Err(keyring::Error::PlatformFailure(err)) => {
144 Err(SecretsError::Keyring(format!("platform failure: {err}")))
145 }
146 Err(keyring::Error::NoStorageAccess(err)) => {
147 Err(SecretsError::Keyring(format!("no storage access: {err}")))
148 }
149 Err(other) => Err(SecretsError::Keyring(other.to_string())),
150 }
151 }
152 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
153 {
154 let _ = &self.service;
155 Err(SecretsError::Keyring(unsupported_keyring_message()))
156 }
157 }
158}
159
160impl KeyringStore for DefaultKeyringStore {
161 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
162 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
163 {
164 let entry = keyring::Entry::new(&self.service, key)
165 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
166 match entry.get_password() {
167 Ok(value) => Ok(Some(value)),
168 Err(keyring::Error::NoEntry) => Ok(None),
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 set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
180 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
181 {
182 let entry = keyring::Entry::new(&self.service, key)
183 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
184 entry
185 .set_password(value)
186 .map_err(|err| SecretsError::Keyring(err.to_string()))
187 }
188 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
189 {
190 let _ = (key, value);
191 Err(SecretsError::Keyring(unsupported_keyring_message()))
192 }
193 }
194
195 fn delete(&self, key: &str) -> Result<(), SecretsError> {
196 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
197 {
198 let entry = keyring::Entry::new(&self.service, key)
199 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
200 match entry.delete_credential() {
201 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
202 Err(err) => Err(SecretsError::Keyring(err.to_string())),
203 }
204 }
205 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
206 {
207 let _ = key;
208 Err(SecretsError::Keyring(unsupported_keyring_message()))
209 }
210 }
211
212 fn backend_name(&self) -> &'static str {
213 "system keyring"
214 }
215}
216
217#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
218fn unsupported_keyring_message() -> String {
219 "system keyring backend is unsupported on this platform".to_string()
220}
221
222#[derive(Debug, Default)]
229pub struct InMemoryKeyringStore {
230 entries: Mutex<HashMap<String, String>>,
232}
233
234impl InMemoryKeyringStore {
235 #[must_use]
237 pub fn new() -> Self {
238 Self::default()
239 }
240}
241
242impl KeyringStore for InMemoryKeyringStore {
243 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
244 let guard = self.entries.lock().map_err(|e| {
245 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
246 })?;
247 Ok(guard.get(key).cloned())
248 }
249
250 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
251 let mut guard = self.entries.lock().map_err(|e| {
252 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
253 })?;
254 guard.insert(key.to_string(), value.to_string());
255 Ok(())
256 }
257
258 fn delete(&self, key: &str) -> Result<(), SecretsError> {
259 let mut guard = self.entries.lock().map_err(|e| {
260 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
261 })?;
262 guard.remove(key);
263 Ok(())
264 }
265
266 fn backend_name(&self) -> &'static str {
267 "in-memory (test)"
268 }
269}
270
271#[derive(Debug, Clone)]
284pub struct FileKeyringStore {
285 path: PathBuf,
287}
288
289#[derive(Debug, Default, Serialize, Deserialize)]
290struct FileSecretsBlob {
291 #[serde(default)]
292 entries: HashMap<String, String>,
293}
294
295impl FileKeyringStore {
296 #[must_use]
298 pub fn new(path: impl Into<PathBuf>) -> Self {
299 Self { path: path.into() }
300 }
301
302 pub fn default_path() -> Result<PathBuf, SecretsError> {
308 let primary = default_codewhale_secrets_path()?;
309 let legacy = legacy_deepseek_secrets_path()?;
310 if let Err(err) = Self::migrate_legacy_file_if_needed(&primary, &legacy) {
311 tracing::warn!(
312 "could not migrate legacy secret store from {} to {}: {err}",
313 legacy.display(),
314 primary.display()
315 );
316 }
317 Ok(primary)
318 }
319
320 fn migrate_legacy_file_if_needed(primary: &Path, legacy: &Path) -> Result<(), SecretsError> {
321 if !legacy.exists() {
322 return Ok(());
323 }
324
325 let legacy_store = Self::new(legacy.to_path_buf());
326 let legacy_blob = legacy_store.load_unlocked()?;
327 if legacy_blob.entries.is_empty() {
328 return Ok(());
329 }
330
331 let primary_store = Self::new(primary.to_path_buf());
332 let mut primary_blob = primary_store.load_unlocked()?;
333 let mut changed = false;
334 for (key, value) in legacy_blob.entries {
335 if let std::collections::hash_map::Entry::Vacant(entry) =
336 primary_blob.entries.entry(key)
337 {
338 entry.insert(value);
339 changed = true;
340 }
341 }
342 if changed {
343 primary_store.store_unlocked(&primary_blob)?;
344 }
345 Ok(())
346 }
347
348 fn home_dir() -> Result<PathBuf, SecretsError> {
349 for var in ["HOME", "USERPROFILE"] {
350 if let Ok(value) = std::env::var(var) {
351 let trimmed = value.trim();
352 if !trimmed.is_empty() {
353 return Ok(PathBuf::from(trimmed));
354 }
355 }
356 }
357
358 dirs::home_dir().ok_or_else(|| {
359 SecretsError::Io(std::io::Error::new(
360 std::io::ErrorKind::NotFound,
361 "could not resolve home directory for FileKeyringStore",
362 ))
363 })
364 }
365
366 #[must_use]
368 pub fn path(&self) -> &Path {
369 &self.path
370 }
371
372 fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
373 if !self.path.exists() {
374 return Ok(FileSecretsBlob::default());
375 }
376 #[cfg(unix)]
380 {
381 use std::os::unix::fs::PermissionsExt;
382 let meta = fs::metadata(&self.path)?;
383 let mode = meta.permissions().mode() & 0o777;
384 if mode & 0o077 != 0 {
385 return Err(SecretsError::InsecurePermissions {
386 path: self.path.clone(),
387 mode,
388 });
389 }
390 }
391 let raw = fs::read_to_string(&self.path)?;
392 if raw.trim().is_empty() {
393 return Ok(FileSecretsBlob::default());
394 }
395 let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
396 Ok(blob)
397 }
398
399 fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
400 if let Some(parent) = self.path.parent() {
401 fs::create_dir_all(parent)?;
402 #[cfg(unix)]
403 {
404 use std::os::unix::fs::PermissionsExt;
405 let mut perms = fs::metadata(parent)?.permissions();
406 perms.set_mode(0o700);
407 let _ = fs::set_permissions(parent, perms);
408 }
409 }
410 let body = serde_json::to_string_pretty(blob)?;
411 fs::write(&self.path, body)?;
412 #[cfg(unix)]
413 {
414 use std::os::unix::fs::PermissionsExt;
415 if let Ok(meta) = fs::metadata(&self.path) {
422 let mut perms = meta.permissions();
423 perms.set_mode(0o600);
424 let _ = fs::set_permissions(&self.path, perms);
425 }
426 }
427 Ok(())
428 }
429}
430
431impl KeyringStore for FileKeyringStore {
432 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
433 let blob = self.load_unlocked()?;
434 Ok(blob.entries.get(key).cloned())
435 }
436
437 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
438 let mut blob = self.load_unlocked()?;
444 blob.entries.insert(key.to_string(), value.to_string());
445 self.store_unlocked(&blob)
446 }
447
448 fn delete(&self, key: &str) -> Result<(), SecretsError> {
449 let mut blob = self.load_unlocked()?;
452 blob.entries.remove(key);
453 self.store_unlocked(&blob)
454 }
455
456 fn backend_name(&self) -> &'static str {
457 FILE_BACKEND_LABEL
458 }
459}
460
461fn default_codewhale_secrets_path() -> Result<PathBuf, SecretsError> {
462 if let Ok(value) = std::env::var("CODEWHALE_HOME") {
463 let trimmed = value.trim();
464 if !trimmed.is_empty() {
465 return Ok(PathBuf::from(trimmed).join("secrets").join("secrets.json"));
466 }
467 }
468 Ok(FileKeyringStore::home_dir()?
469 .join(".codewhale")
470 .join("secrets")
471 .join("secrets.json"))
472}
473
474fn legacy_deepseek_secrets_path() -> Result<PathBuf, SecretsError> {
475 Ok(FileKeyringStore::home_dir()?
476 .join(".deepseek")
477 .join("secrets")
478 .join("secrets.json"))
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482enum SecretBackendSelection {
483 File,
484 System,
485 Unknown,
486}
487
488fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
489 match value.map(str::trim).filter(|value| !value.is_empty()) {
490 None => SecretBackendSelection::File,
491 Some(value) => match value.to_ascii_lowercase().as_str() {
492 "file" | "local" | "json" => SecretBackendSelection::File,
493 "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
494 _ => SecretBackendSelection::Unknown,
495 },
496 }
497}
498
499fn configured_secret_backend() -> Option<String> {
500 std::env::var(SECRET_BACKEND_ENV)
501 .ok()
502 .filter(|value| !value.trim().is_empty())
503 .or_else(|| std::env::var(LEGACY_SECRET_BACKEND_ENV).ok())
504}
505
506#[derive(Clone)]
523pub struct Secrets {
524 pub store: Arc<dyn KeyringStore>,
526 service: String,
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub enum SecretSource {
540 Keyring,
542 Env,
544}
545
546impl std::fmt::Debug for Secrets {
547 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548 f.debug_struct("Secrets")
549 .field("backend", &self.store.backend_name())
550 .field("service", &self.service)
551 .finish()
552 }
553}
554
555impl Secrets {
556 #[must_use]
559 pub fn new(store: Arc<dyn KeyringStore>) -> Self {
560 Self {
561 store,
562 service: DEFAULT_SERVICE.to_string(),
563 }
564 }
565
566 pub fn auto_detect() -> Self {
577 match secret_backend_selection(configured_secret_backend().as_deref()) {
578 SecretBackendSelection::File => Self::file_backed_default(),
579 SecretBackendSelection::Unknown => {
580 tracing::warn!(
581 "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
582 );
583 Self::file_backed_default()
584 }
585 SecretBackendSelection::System => {
586 let default_store = DefaultKeyringStore::default();
587 match default_store.probe() {
588 Ok(()) => Self::new(Arc::new(default_store)),
589 Err(err) => {
590 tracing::warn!(
591 "OS keyring unavailable ({err}); falling back to file-backed secret store"
592 );
593 Self::file_backed_default()
594 }
595 }
596 }
597 }
598 }
599
600 fn file_backed_default() -> Self {
601 let path = FileKeyringStore::default_path()
602 .unwrap_or_else(|_| PathBuf::from(".codewhale-secrets.json"));
603 Self::new(Arc::new(FileKeyringStore::new(path)))
604 }
605
606 #[must_use]
608 pub fn file_backed() -> Self {
609 Self::file_backed_default()
610 }
611
612 #[must_use]
615 pub fn system_keyring() -> Self {
616 let default_store = DefaultKeyringStore::default();
617 match default_store.probe() {
618 Ok(()) => Self::new(Arc::new(default_store)),
619 Err(err) => {
620 tracing::warn!(
621 "OS keyring unavailable ({err}); falling back to file-backed secret store"
622 );
623 Self::file_backed_default()
624 }
625 }
626 }
627
628 #[must_use]
630 pub fn backend_name(&self) -> &'static str {
631 self.store.backend_name()
632 }
633
634 #[must_use]
639 pub fn resolve(&self, name: &str) -> Option<String> {
640 self.resolve_with_source(name).map(|(value, _)| value)
641 }
642
643 #[must_use]
645 pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
646 if let Ok(Some(v)) = self.store.get(name)
647 && !v.trim().is_empty()
648 {
649 return Some((v, SecretSource::Keyring));
650 }
651 env_for(name).map(|value| (value, SecretSource::Env))
652 }
653
654 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
656 self.store.set(name, value)
657 }
658
659 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
661 self.store.delete(name)
662 }
663
664 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
666 self.store.get(name)
667 }
668}
669
670#[must_use]
697pub fn env_for(name: &str) -> Option<String> {
698 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
699 "deepseek" => &["DEEPSEEK_API_KEY"],
700 "openrouter" => &["OPENROUTER_API_KEY"],
701 "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
702 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
703 }
704 "novita" => &["NOVITA_API_KEY"],
705 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
709 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
710 }
711 "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
712 "siliconflow" | "silicon-flow" | "silicon_flow" => &["SILICONFLOW_API_KEY"],
713 "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
714 "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
715 "vllm" | "v-llm" => &["VLLM_API_KEY"],
716 "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
717 "openai" => &["OPENAI_API_KEY"],
718 "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
719 "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
720 | "volcengineark" => &[
721 "VOLCENGINE_API_KEY",
722 "VOLCENGINE_ARK_API_KEY",
723 "ARK_API_KEY",
724 ],
725 "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
726 | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
727 "WANJIE_ARK_API_KEY",
728 "WANJIE_API_KEY",
729 "WANJIE_MAAS_API_KEY",
730 ],
731 _ => return None,
732 };
733 for var in candidates {
734 if let Ok(value) = std::env::var(var)
735 && !value.trim().is_empty()
736 {
737 return Some(value);
738 }
739 }
740 None
741}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
746 use std::sync::{Mutex, OnceLock};
747
748 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
751 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
752 LOCK.get_or_init(|| Mutex::new(()))
753 .lock()
754 .unwrap_or_else(|p| p.into_inner())
755 }
756
757 fn clear_known_envs() {
758 for var in [
759 "CODEWHALE_HOME",
760 "DEEPSEEK_API_KEY",
761 "OPENROUTER_API_KEY",
762 "NOVITA_API_KEY",
763 "NVIDIA_API_KEY",
764 "NVIDIA_NIM_API_KEY",
765 "FIREWORKS_API_KEY",
766 "SILICONFLOW_API_KEY",
767 "SGLANG_API_KEY",
768 "VLLM_API_KEY",
769 "OLLAMA_API_KEY",
770 "OPENAI_API_KEY",
771 "ATLASCLOUD_API_KEY",
772 "WANJIE_ARK_API_KEY",
773 "WANJIE_API_KEY",
774 "WANJIE_MAAS_API_KEY",
775 "XIAOMI_MIMO_API_KEY",
776 "XIAOMI_API_KEY",
777 "MIMO_API_KEY",
778 SECRET_BACKEND_ENV,
779 LEGACY_SECRET_BACKEND_ENV,
780 ] {
781 unsafe { std::env::remove_var(var) };
784 }
785 }
786
787 struct EnvVarGuard {
788 name: &'static str,
789 previous: Option<std::ffi::OsString>,
790 }
791
792 impl EnvVarGuard {
793 fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
794 let previous = std::env::var_os(name);
795 unsafe { std::env::set_var(name, value) };
796 Self { name, previous }
797 }
798 }
799
800 impl Drop for EnvVarGuard {
801 fn drop(&mut self) {
802 match self.previous.take() {
803 Some(value) => unsafe { std::env::set_var(self.name, value) },
804 None => unsafe { std::env::remove_var(self.name) },
805 }
806 }
807 }
808
809 #[test]
810 fn backend_selection_defaults_to_file() {
811 assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
812 assert_eq!(
813 secret_backend_selection(Some("")),
814 SecretBackendSelection::File
815 );
816 assert_eq!(
817 secret_backend_selection(Some(" file ")),
818 SecretBackendSelection::File
819 );
820 }
821
822 #[test]
823 fn backend_selection_accepts_explicit_system_keyring() {
824 assert_eq!(
825 secret_backend_selection(Some("system")),
826 SecretBackendSelection::System
827 );
828 assert_eq!(
829 secret_backend_selection(Some("keyring")),
830 SecretBackendSelection::System
831 );
832 assert_eq!(
833 secret_backend_selection(Some("os-keyring")),
834 SecretBackendSelection::System
835 );
836 }
837
838 #[test]
839 fn auto_detect_is_file_backed_by_default() {
840 let _lock = env_lock();
841 clear_known_envs();
842 let tmp = tempfile::tempdir().unwrap();
843 let _home = EnvVarGuard::set("HOME", tmp.path());
844 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
845
846 let secrets = Secrets::auto_detect();
847
848 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
849 }
850
851 #[test]
852 fn auto_detect_honors_explicit_file_backend() {
853 let _lock = env_lock();
854 clear_known_envs();
855 let tmp = tempfile::tempdir().unwrap();
856 let _home = EnvVarGuard::set("HOME", tmp.path());
857 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
858 unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
860
861 let secrets = Secrets::auto_detect();
862
863 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
864 unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
866 }
867
868 #[test]
869 fn auto_detect_honors_legacy_backend_env_alias() {
870 let _lock = env_lock();
871 clear_known_envs();
872 let tmp = tempfile::tempdir().unwrap();
873 let _home = EnvVarGuard::set("HOME", tmp.path());
874 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
875 unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
876
877 let secrets = Secrets::auto_detect();
878
879 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
880 clear_known_envs();
881 }
882
883 #[test]
884 fn file_default_path_uses_codewhale_home() {
885 let _lock = env_lock();
886 clear_known_envs();
887 let tmp = tempfile::tempdir().unwrap();
888 let _home = EnvVarGuard::set("HOME", tmp.path());
889 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
890
891 let path = FileKeyringStore::default_path().unwrap();
892
893 assert_eq!(
894 path,
895 tmp.path()
896 .join(".codewhale")
897 .join("secrets")
898 .join("secrets.json")
899 );
900 }
901
902 #[test]
903 fn file_default_path_honors_codewhale_home() {
904 let _lock = env_lock();
905 clear_known_envs();
906 let tmp = tempfile::tempdir().unwrap();
907 let custom = tmp.path().join("custom-codewhale");
908 let _home = EnvVarGuard::set("HOME", tmp.path());
909 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
910 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
911
912 let path = FileKeyringStore::default_path().unwrap();
913
914 assert_eq!(path, custom.join("secrets").join("secrets.json"));
915 }
916
917 #[test]
918 fn file_default_path_migrates_legacy_entries_to_codewhale() {
919 let _lock = env_lock();
920 clear_known_envs();
921 let tmp = tempfile::tempdir().unwrap();
922 let _home = EnvVarGuard::set("HOME", tmp.path());
923 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
924 let legacy = tmp
925 .path()
926 .join(".deepseek")
927 .join("secrets")
928 .join("secrets.json");
929 FileKeyringStore::new(legacy.clone())
930 .set("xiaomi-mimo", "legacy-mimo")
931 .unwrap();
932
933 let primary = FileKeyringStore::default_path().unwrap();
934 let primary_store = FileKeyringStore::new(primary.clone());
935
936 assert_eq!(
937 primary,
938 tmp.path()
939 .join(".codewhale")
940 .join("secrets")
941 .join("secrets.json")
942 );
943 assert_eq!(
944 primary_store.get("xiaomi-mimo").unwrap().as_deref(),
945 Some("legacy-mimo")
946 );
947 assert!(
948 legacy.exists(),
949 "migration copies; it does not delete legacy data"
950 );
951 }
952
953 #[test]
954 fn file_default_path_migration_preserves_primary_values() {
955 let _lock = env_lock();
956 clear_known_envs();
957 let tmp = tempfile::tempdir().unwrap();
958 let _home = EnvVarGuard::set("HOME", tmp.path());
959 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
960 let legacy = tmp
961 .path()
962 .join(".deepseek")
963 .join("secrets")
964 .join("secrets.json");
965 let primary = tmp
966 .path()
967 .join(".codewhale")
968 .join("secrets")
969 .join("secrets.json");
970 FileKeyringStore::new(legacy)
971 .set("openrouter", "legacy-openrouter")
972 .unwrap();
973 let primary_store = FileKeyringStore::new(primary.clone());
974 primary_store
975 .set("openrouter", "primary-openrouter")
976 .unwrap();
977
978 let resolved = FileKeyringStore::default_path().unwrap();
979
980 assert_eq!(resolved, primary);
981 assert_eq!(
982 primary_store.get("openrouter").unwrap().as_deref(),
983 Some("primary-openrouter")
984 );
985 }
986
987 #[test]
988 fn in_memory_store_round_trips() {
989 let store = InMemoryKeyringStore::new();
990 assert_eq!(store.get("deepseek").unwrap(), None);
991 store.set("deepseek", "sk-test").unwrap();
992 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
993 store.set("deepseek", "sk-replaced").unwrap();
994 assert_eq!(
995 store.get("deepseek").unwrap(),
996 Some("sk-replaced".to_string())
997 );
998 store.delete("deepseek").unwrap();
999 assert_eq!(store.get("deepseek").unwrap(), None);
1000 store.delete("missing").unwrap();
1002 }
1003
1004 #[test]
1005 fn resolve_prefers_keyring_over_env() {
1006 let _lock = env_lock();
1007 clear_known_envs();
1008 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1010
1011 let store = Arc::new(InMemoryKeyringStore::new());
1012 store.set("deepseek", "ring-key").unwrap();
1013 let secrets = Secrets::new(store);
1014
1015 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1016 assert_eq!(
1017 secrets.resolve_with_source("deepseek"),
1018 Some(("ring-key".to_string(), SecretSource::Keyring))
1019 );
1020 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1022 }
1023
1024 #[test]
1025 fn resolve_falls_back_to_env_when_keyring_empty() {
1026 let _lock = env_lock();
1027 clear_known_envs();
1028 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1030
1031 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1032 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1033 assert_eq!(
1034 secrets.resolve_with_source("deepseek"),
1035 Some(("env-fallback".to_string(), SecretSource::Env))
1036 );
1037 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1039 }
1040
1041 #[test]
1042 fn resolve_returns_none_when_both_layers_empty() {
1043 let _lock = env_lock();
1044 clear_known_envs();
1045 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1046 assert_eq!(secrets.resolve("deepseek"), None);
1047 }
1048
1049 #[test]
1050 fn resolve_treats_blank_keyring_value_as_unset() {
1051 let _lock = env_lock();
1052 clear_known_envs();
1053 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1055
1056 let store = Arc::new(InMemoryKeyringStore::new());
1057 store.set("deepseek", " ").unwrap();
1058 let secrets = Secrets::new(store);
1059 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1060 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1062 }
1063
1064 #[test]
1065 fn nvidia_env_aliases_resolve() {
1066 let _lock = env_lock();
1067 clear_known_envs();
1068 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1070 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1071 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1072 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1073 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1075 }
1076
1077 #[test]
1078 fn atlascloud_env_aliases_resolve() {
1079 let _guard = env_lock();
1080 clear_known_envs();
1081 unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1082
1083 assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1084 assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1085 assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1086
1087 clear_known_envs();
1088 }
1089
1090 #[test]
1091 fn wanjie_ark_env_aliases_resolve() {
1092 let _guard = env_lock();
1093 clear_known_envs();
1094 unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1095
1096 assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1097 assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1098 assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1099
1100 clear_known_envs();
1101 }
1102
1103 #[test]
1104 fn xiaomi_mimo_env_aliases_resolve() {
1105 let _guard = env_lock();
1106 clear_known_envs();
1107 unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1108
1109 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1110 assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1111 assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1112 assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1113
1114 clear_known_envs();
1115
1116 unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1117 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1118 clear_known_envs();
1119 }
1120
1121 #[test]
1122 fn fireworks_env_aliases_resolve() {
1123 let _lock = env_lock();
1124 clear_known_envs();
1125 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1127
1128 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1129 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1130 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1132 }
1133
1134 #[test]
1135 fn siliconflow_env_aliases_resolve() {
1136 let _lock = env_lock();
1137 clear_known_envs();
1138 unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
1140
1141 assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
1142 assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
1143 assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
1144 unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
1146 }
1147
1148 #[test]
1149 fn moonshot_kimi_env_aliases_resolve() {
1150 let _lock = env_lock();
1151 clear_known_envs();
1152 unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
1154
1155 assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
1156 assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
1157 assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
1158 assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
1159 unsafe { std::env::remove_var("KIMI_API_KEY") };
1161 }
1162
1163 #[test]
1164 fn sglang_env_aliases_resolve() {
1165 let _lock = env_lock();
1166 clear_known_envs();
1167 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
1169
1170 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
1171 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
1172 unsafe { std::env::remove_var("SGLANG_API_KEY") };
1174 }
1175
1176 #[test]
1177 fn vllm_env_aliases_resolve() {
1178 let _lock = env_lock();
1179 clear_known_envs();
1180 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
1182
1183 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
1184 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
1185 unsafe { std::env::remove_var("VLLM_API_KEY") };
1187 }
1188
1189 #[test]
1190 fn ollama_env_aliases_resolve() {
1191 let _lock = env_lock();
1192 clear_known_envs();
1193 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
1195
1196 assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
1197 assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
1198 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
1200 }
1201
1202 #[cfg(unix)]
1203 #[test]
1204 fn file_store_round_trips_with_secure_perms() {
1205 use std::os::unix::fs::PermissionsExt;
1206
1207 let tmp = tempfile::tempdir().unwrap();
1208 let path = tmp.path().join("nested").join("secrets.json");
1209 let store = FileKeyringStore::new(path.clone());
1210 assert_eq!(store.get("deepseek").unwrap(), None);
1211 store.set("deepseek", "sk-disk").unwrap();
1212 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1213
1214 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1215 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
1216
1217 store.set("openrouter", "or-disk").unwrap();
1218 assert_eq!(
1219 store.get("openrouter").unwrap(),
1220 Some("or-disk".to_string())
1221 );
1222 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1224
1225 store.delete("deepseek").unwrap();
1226 assert_eq!(store.get("deepseek").unwrap(), None);
1227 }
1228
1229 #[cfg(unix)]
1230 #[test]
1231 fn file_store_rejects_world_readable_file() {
1232 use std::os::unix::fs::PermissionsExt;
1233 let tmp = tempfile::tempdir().unwrap();
1234 let path = tmp.path().join("secrets.json");
1235 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
1236 let mut perms = fs::metadata(&path).unwrap().permissions();
1237 perms.set_mode(0o644);
1238 fs::set_permissions(&path, perms).unwrap();
1239
1240 let store = FileKeyringStore::new(path);
1241 let err = store.get("deepseek").unwrap_err();
1242 assert!(
1243 matches!(err, SecretsError::InsecurePermissions { .. }),
1244 "unexpected error: {err}"
1245 );
1246 }
1247
1248 #[cfg(unix)]
1254 #[test]
1255 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
1256 use std::os::unix::fs::PermissionsExt;
1257 let tmp = tempfile::tempdir().unwrap();
1258 let path = tmp.path().join("secrets.json");
1259 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1260 fs::write(&path, original).unwrap();
1261 let mut perms = fs::metadata(&path).unwrap().permissions();
1262 perms.set_mode(0o644);
1263 fs::set_permissions(&path, perms).unwrap();
1264
1265 let store = FileKeyringStore::new(path.clone());
1266 let err = store.set("openrouter", "or-new").unwrap_err();
1267 assert!(
1268 matches!(err, SecretsError::InsecurePermissions { .. }),
1269 "set must surface the read error rather than overwriting; got: {err}"
1270 );
1271
1272 let on_disk = fs::read_to_string(&path).unwrap();
1273 assert_eq!(
1274 on_disk, original,
1275 "set must not modify the file when load_unlocked errored"
1276 );
1277 }
1278
1279 #[cfg(unix)]
1280 #[test]
1281 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
1282 use std::os::unix::fs::PermissionsExt;
1283 let tmp = tempfile::tempdir().unwrap();
1284 let path = tmp.path().join("secrets.json");
1285 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1286 fs::write(&path, original).unwrap();
1287 let mut perms = fs::metadata(&path).unwrap().permissions();
1288 perms.set_mode(0o644);
1289 fs::set_permissions(&path, perms).unwrap();
1290
1291 let store = FileKeyringStore::new(path.clone());
1292 let err = store.delete("nvidia").unwrap_err();
1293 assert!(
1294 matches!(err, SecretsError::InsecurePermissions { .. }),
1295 "delete must surface the read error rather than wiping the file; got: {err}"
1296 );
1297 let on_disk = fs::read_to_string(&path).unwrap();
1298 assert_eq!(on_disk, original);
1299 }
1300
1301 #[test]
1302 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
1303 let tmp = tempfile::tempdir().unwrap();
1304 let path = tmp.path().join("secrets.json");
1305 fs::write(&path, "{ this is not valid json").unwrap();
1308 #[cfg(unix)]
1309 {
1310 use std::os::unix::fs::PermissionsExt;
1311 let mut perms = fs::metadata(&path).unwrap().permissions();
1312 perms.set_mode(0o600);
1313 fs::set_permissions(&path, perms).unwrap();
1314 }
1315
1316 let store = FileKeyringStore::new(path.clone());
1317 let err = store.set("deepseek", "sk-new").unwrap_err();
1318 assert!(
1319 matches!(err, SecretsError::Json(_)),
1320 "set must surface the parse error rather than wiping the file; got: {err}"
1321 );
1322 let on_disk = fs::read_to_string(&path).unwrap();
1323 assert_eq!(on_disk, "{ this is not valid json");
1324 }
1325
1326 #[test]
1327 fn file_store_set_still_creates_file_when_missing() {
1328 let tmp = tempfile::tempdir().unwrap();
1333 let path = tmp.path().join("nested").join("secrets.json");
1334 let store = FileKeyringStore::new(path.clone());
1335
1336 store.set("deepseek", "sk-fresh").unwrap();
1337 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
1338 }
1339
1340 #[test]
1341 fn file_store_default_path_uses_home() {
1342 let _lock = env_lock();
1343 clear_known_envs();
1344 let tmp = tempfile::tempdir().unwrap();
1345 let _home = EnvVarGuard::set("HOME", tmp.path());
1346 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1347
1348 let path = FileKeyringStore::default_path().unwrap();
1349 assert_eq!(
1350 path,
1351 tmp.path()
1352 .join(".codewhale")
1353 .join("secrets")
1354 .join("secrets.json")
1355 );
1356 }
1357}