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]
698pub fn env_for(name: &str) -> Option<String> {
699 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
700 "deepseek" => &["DEEPSEEK_API_KEY"],
701 "openrouter" => &["OPENROUTER_API_KEY"],
702 "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
703 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
704 }
705 "novita" => &["NOVITA_API_KEY"],
706 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
710 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
711 }
712 "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
713 "siliconflow" | "silicon-flow" | "silicon_flow" => &["SILICONFLOW_API_KEY"],
714 "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
715 "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
716 "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
717 "vllm" | "v-llm" => &["VLLM_API_KEY"],
718 "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
719 "openai" => &["OPENAI_API_KEY"],
720 "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
721 "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
722 | "volcengineark" => &[
723 "VOLCENGINE_API_KEY",
724 "VOLCENGINE_ARK_API_KEY",
725 "ARK_API_KEY",
726 ],
727 "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
728 | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
729 "WANJIE_ARK_API_KEY",
730 "WANJIE_API_KEY",
731 "WANJIE_MAAS_API_KEY",
732 ],
733 _ => return None,
734 };
735 for var in candidates {
736 if let Ok(value) = std::env::var(var)
737 && !value.trim().is_empty()
738 {
739 return Some(value);
740 }
741 }
742 None
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use std::sync::{Mutex, OnceLock};
749
750 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
753 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
754 LOCK.get_or_init(|| Mutex::new(()))
755 .lock()
756 .unwrap_or_else(|p| p.into_inner())
757 }
758
759 fn clear_known_envs() {
760 for var in [
761 "CODEWHALE_HOME",
762 "DEEPSEEK_API_KEY",
763 "OPENROUTER_API_KEY",
764 "NOVITA_API_KEY",
765 "NVIDIA_API_KEY",
766 "NVIDIA_NIM_API_KEY",
767 "FIREWORKS_API_KEY",
768 "SILICONFLOW_API_KEY",
769 "ARCEE_API_KEY",
770 "SGLANG_API_KEY",
771 "VLLM_API_KEY",
772 "OLLAMA_API_KEY",
773 "OPENAI_API_KEY",
774 "ATLASCLOUD_API_KEY",
775 "WANJIE_ARK_API_KEY",
776 "WANJIE_API_KEY",
777 "WANJIE_MAAS_API_KEY",
778 "XIAOMI_MIMO_API_KEY",
779 "XIAOMI_API_KEY",
780 "MIMO_API_KEY",
781 SECRET_BACKEND_ENV,
782 LEGACY_SECRET_BACKEND_ENV,
783 ] {
784 unsafe { std::env::remove_var(var) };
787 }
788 }
789
790 struct EnvVarGuard {
791 name: &'static str,
792 previous: Option<std::ffi::OsString>,
793 }
794
795 impl EnvVarGuard {
796 fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
797 let previous = std::env::var_os(name);
798 unsafe { std::env::set_var(name, value) };
799 Self { name, previous }
800 }
801 }
802
803 impl Drop for EnvVarGuard {
804 fn drop(&mut self) {
805 match self.previous.take() {
806 Some(value) => unsafe { std::env::set_var(self.name, value) },
807 None => unsafe { std::env::remove_var(self.name) },
808 }
809 }
810 }
811
812 #[test]
813 fn backend_selection_defaults_to_file() {
814 assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
815 assert_eq!(
816 secret_backend_selection(Some("")),
817 SecretBackendSelection::File
818 );
819 assert_eq!(
820 secret_backend_selection(Some(" file ")),
821 SecretBackendSelection::File
822 );
823 }
824
825 #[test]
826 fn backend_selection_accepts_explicit_system_keyring() {
827 assert_eq!(
828 secret_backend_selection(Some("system")),
829 SecretBackendSelection::System
830 );
831 assert_eq!(
832 secret_backend_selection(Some("keyring")),
833 SecretBackendSelection::System
834 );
835 assert_eq!(
836 secret_backend_selection(Some("os-keyring")),
837 SecretBackendSelection::System
838 );
839 }
840
841 #[test]
842 fn auto_detect_is_file_backed_by_default() {
843 let _lock = env_lock();
844 clear_known_envs();
845 let tmp = tempfile::tempdir().unwrap();
846 let _home = EnvVarGuard::set("HOME", tmp.path());
847 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
848
849 let secrets = Secrets::auto_detect();
850
851 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
852 }
853
854 #[test]
855 fn auto_detect_honors_explicit_file_backend() {
856 let _lock = env_lock();
857 clear_known_envs();
858 let tmp = tempfile::tempdir().unwrap();
859 let _home = EnvVarGuard::set("HOME", tmp.path());
860 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
861 unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
863
864 let secrets = Secrets::auto_detect();
865
866 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
867 unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
869 }
870
871 #[test]
872 fn auto_detect_honors_legacy_backend_env_alias() {
873 let _lock = env_lock();
874 clear_known_envs();
875 let tmp = tempfile::tempdir().unwrap();
876 let _home = EnvVarGuard::set("HOME", tmp.path());
877 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
878 unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
879
880 let secrets = Secrets::auto_detect();
881
882 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
883 clear_known_envs();
884 }
885
886 #[test]
887 fn file_default_path_uses_codewhale_home() {
888 let _lock = env_lock();
889 clear_known_envs();
890 let tmp = tempfile::tempdir().unwrap();
891 let _home = EnvVarGuard::set("HOME", tmp.path());
892 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
893
894 let path = FileKeyringStore::default_path().unwrap();
895
896 assert_eq!(
897 path,
898 tmp.path()
899 .join(".codewhale")
900 .join("secrets")
901 .join("secrets.json")
902 );
903 }
904
905 #[test]
906 fn file_default_path_honors_codewhale_home() {
907 let _lock = env_lock();
908 clear_known_envs();
909 let tmp = tempfile::tempdir().unwrap();
910 let custom = tmp.path().join("custom-codewhale");
911 let _home = EnvVarGuard::set("HOME", tmp.path());
912 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
913 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
914
915 let path = FileKeyringStore::default_path().unwrap();
916
917 assert_eq!(path, custom.join("secrets").join("secrets.json"));
918 }
919
920 #[test]
921 fn file_default_path_migrates_legacy_entries_to_codewhale() {
922 let _lock = env_lock();
923 clear_known_envs();
924 let tmp = tempfile::tempdir().unwrap();
925 let _home = EnvVarGuard::set("HOME", tmp.path());
926 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
927 let legacy = tmp
928 .path()
929 .join(".deepseek")
930 .join("secrets")
931 .join("secrets.json");
932 FileKeyringStore::new(legacy.clone())
933 .set("xiaomi-mimo", "legacy-mimo")
934 .unwrap();
935
936 let primary = FileKeyringStore::default_path().unwrap();
937 let primary_store = FileKeyringStore::new(primary.clone());
938
939 assert_eq!(
940 primary,
941 tmp.path()
942 .join(".codewhale")
943 .join("secrets")
944 .join("secrets.json")
945 );
946 assert_eq!(
947 primary_store.get("xiaomi-mimo").unwrap().as_deref(),
948 Some("legacy-mimo")
949 );
950 assert!(
951 legacy.exists(),
952 "migration copies; it does not delete legacy data"
953 );
954 }
955
956 #[test]
957 fn file_default_path_migration_preserves_primary_values() {
958 let _lock = env_lock();
959 clear_known_envs();
960 let tmp = tempfile::tempdir().unwrap();
961 let _home = EnvVarGuard::set("HOME", tmp.path());
962 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
963 let legacy = tmp
964 .path()
965 .join(".deepseek")
966 .join("secrets")
967 .join("secrets.json");
968 let primary = tmp
969 .path()
970 .join(".codewhale")
971 .join("secrets")
972 .join("secrets.json");
973 FileKeyringStore::new(legacy)
974 .set("openrouter", "legacy-openrouter")
975 .unwrap();
976 let primary_store = FileKeyringStore::new(primary.clone());
977 primary_store
978 .set("openrouter", "primary-openrouter")
979 .unwrap();
980
981 let resolved = FileKeyringStore::default_path().unwrap();
982
983 assert_eq!(resolved, primary);
984 assert_eq!(
985 primary_store.get("openrouter").unwrap().as_deref(),
986 Some("primary-openrouter")
987 );
988 }
989
990 #[test]
991 fn in_memory_store_round_trips() {
992 let store = InMemoryKeyringStore::new();
993 assert_eq!(store.get("deepseek").unwrap(), None);
994 store.set("deepseek", "sk-test").unwrap();
995 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
996 store.set("deepseek", "sk-replaced").unwrap();
997 assert_eq!(
998 store.get("deepseek").unwrap(),
999 Some("sk-replaced".to_string())
1000 );
1001 store.delete("deepseek").unwrap();
1002 assert_eq!(store.get("deepseek").unwrap(), None);
1003 store.delete("missing").unwrap();
1005 }
1006
1007 #[test]
1008 fn resolve_prefers_keyring_over_env() {
1009 let _lock = env_lock();
1010 clear_known_envs();
1011 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1013
1014 let store = Arc::new(InMemoryKeyringStore::new());
1015 store.set("deepseek", "ring-key").unwrap();
1016 let secrets = Secrets::new(store);
1017
1018 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1019 assert_eq!(
1020 secrets.resolve_with_source("deepseek"),
1021 Some(("ring-key".to_string(), SecretSource::Keyring))
1022 );
1023 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1025 }
1026
1027 #[test]
1028 fn resolve_falls_back_to_env_when_keyring_empty() {
1029 let _lock = env_lock();
1030 clear_known_envs();
1031 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1033
1034 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1035 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1036 assert_eq!(
1037 secrets.resolve_with_source("deepseek"),
1038 Some(("env-fallback".to_string(), SecretSource::Env))
1039 );
1040 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1042 }
1043
1044 #[test]
1045 fn resolve_returns_none_when_both_layers_empty() {
1046 let _lock = env_lock();
1047 clear_known_envs();
1048 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1049 assert_eq!(secrets.resolve("deepseek"), None);
1050 }
1051
1052 #[test]
1053 fn resolve_treats_blank_keyring_value_as_unset() {
1054 let _lock = env_lock();
1055 clear_known_envs();
1056 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1058
1059 let store = Arc::new(InMemoryKeyringStore::new());
1060 store.set("deepseek", " ").unwrap();
1061 let secrets = Secrets::new(store);
1062 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1063 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1065 }
1066
1067 #[test]
1068 fn nvidia_env_aliases_resolve() {
1069 let _lock = env_lock();
1070 clear_known_envs();
1071 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1073 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1074 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1075 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1076 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1078 }
1079
1080 #[test]
1081 fn atlascloud_env_aliases_resolve() {
1082 let _guard = env_lock();
1083 clear_known_envs();
1084 unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1085
1086 assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1087 assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1088 assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1089
1090 clear_known_envs();
1091 }
1092
1093 #[test]
1094 fn wanjie_ark_env_aliases_resolve() {
1095 let _guard = env_lock();
1096 clear_known_envs();
1097 unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1098
1099 assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1100 assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1101 assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1102
1103 clear_known_envs();
1104 }
1105
1106 #[test]
1107 fn xiaomi_mimo_env_aliases_resolve() {
1108 let _guard = env_lock();
1109 clear_known_envs();
1110 unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1111
1112 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1113 assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1114 assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1115 assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1116
1117 clear_known_envs();
1118
1119 unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1120 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1121 clear_known_envs();
1122 }
1123
1124 #[test]
1125 fn fireworks_env_aliases_resolve() {
1126 let _lock = env_lock();
1127 clear_known_envs();
1128 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1130
1131 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1132 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1133 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1135 }
1136
1137 #[test]
1138 fn siliconflow_env_aliases_resolve() {
1139 let _lock = env_lock();
1140 clear_known_envs();
1141 unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
1143
1144 assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
1145 assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
1146 assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
1147 unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
1149 }
1150
1151 #[test]
1152 fn arcee_env_aliases_resolve() {
1153 let _lock = env_lock();
1154 clear_known_envs();
1155 unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
1157
1158 assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
1159 assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
1160 assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
1161 unsafe { std::env::remove_var("ARCEE_API_KEY") };
1163 }
1164
1165 #[test]
1166 fn moonshot_kimi_env_aliases_resolve() {
1167 let _lock = env_lock();
1168 clear_known_envs();
1169 unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
1171
1172 assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
1173 assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
1174 assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
1175 assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
1176 unsafe { std::env::remove_var("KIMI_API_KEY") };
1178 }
1179
1180 #[test]
1181 fn sglang_env_aliases_resolve() {
1182 let _lock = env_lock();
1183 clear_known_envs();
1184 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
1186
1187 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
1188 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
1189 unsafe { std::env::remove_var("SGLANG_API_KEY") };
1191 }
1192
1193 #[test]
1194 fn vllm_env_aliases_resolve() {
1195 let _lock = env_lock();
1196 clear_known_envs();
1197 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
1199
1200 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
1201 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
1202 unsafe { std::env::remove_var("VLLM_API_KEY") };
1204 }
1205
1206 #[test]
1207 fn ollama_env_aliases_resolve() {
1208 let _lock = env_lock();
1209 clear_known_envs();
1210 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
1212
1213 assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
1214 assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
1215 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
1217 }
1218
1219 #[cfg(unix)]
1220 #[test]
1221 fn file_store_round_trips_with_secure_perms() {
1222 use std::os::unix::fs::PermissionsExt;
1223
1224 let tmp = tempfile::tempdir().unwrap();
1225 let path = tmp.path().join("nested").join("secrets.json");
1226 let store = FileKeyringStore::new(path.clone());
1227 assert_eq!(store.get("deepseek").unwrap(), None);
1228 store.set("deepseek", "sk-disk").unwrap();
1229 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1230
1231 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1232 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
1233
1234 store.set("openrouter", "or-disk").unwrap();
1235 assert_eq!(
1236 store.get("openrouter").unwrap(),
1237 Some("or-disk".to_string())
1238 );
1239 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1241
1242 store.delete("deepseek").unwrap();
1243 assert_eq!(store.get("deepseek").unwrap(), None);
1244 }
1245
1246 #[cfg(unix)]
1247 #[test]
1248 fn file_store_rejects_world_readable_file() {
1249 use std::os::unix::fs::PermissionsExt;
1250 let tmp = tempfile::tempdir().unwrap();
1251 let path = tmp.path().join("secrets.json");
1252 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
1253 let mut perms = fs::metadata(&path).unwrap().permissions();
1254 perms.set_mode(0o644);
1255 fs::set_permissions(&path, perms).unwrap();
1256
1257 let store = FileKeyringStore::new(path);
1258 let err = store.get("deepseek").unwrap_err();
1259 assert!(
1260 matches!(err, SecretsError::InsecurePermissions { .. }),
1261 "unexpected error: {err}"
1262 );
1263 }
1264
1265 #[cfg(unix)]
1271 #[test]
1272 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
1273 use std::os::unix::fs::PermissionsExt;
1274 let tmp = tempfile::tempdir().unwrap();
1275 let path = tmp.path().join("secrets.json");
1276 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1277 fs::write(&path, original).unwrap();
1278 let mut perms = fs::metadata(&path).unwrap().permissions();
1279 perms.set_mode(0o644);
1280 fs::set_permissions(&path, perms).unwrap();
1281
1282 let store = FileKeyringStore::new(path.clone());
1283 let err = store.set("openrouter", "or-new").unwrap_err();
1284 assert!(
1285 matches!(err, SecretsError::InsecurePermissions { .. }),
1286 "set must surface the read error rather than overwriting; got: {err}"
1287 );
1288
1289 let on_disk = fs::read_to_string(&path).unwrap();
1290 assert_eq!(
1291 on_disk, original,
1292 "set must not modify the file when load_unlocked errored"
1293 );
1294 }
1295
1296 #[cfg(unix)]
1297 #[test]
1298 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
1299 use std::os::unix::fs::PermissionsExt;
1300 let tmp = tempfile::tempdir().unwrap();
1301 let path = tmp.path().join("secrets.json");
1302 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1303 fs::write(&path, original).unwrap();
1304 let mut perms = fs::metadata(&path).unwrap().permissions();
1305 perms.set_mode(0o644);
1306 fs::set_permissions(&path, perms).unwrap();
1307
1308 let store = FileKeyringStore::new(path.clone());
1309 let err = store.delete("nvidia").unwrap_err();
1310 assert!(
1311 matches!(err, SecretsError::InsecurePermissions { .. }),
1312 "delete must surface the read error rather than wiping the file; got: {err}"
1313 );
1314 let on_disk = fs::read_to_string(&path).unwrap();
1315 assert_eq!(on_disk, original);
1316 }
1317
1318 #[test]
1319 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
1320 let tmp = tempfile::tempdir().unwrap();
1321 let path = tmp.path().join("secrets.json");
1322 fs::write(&path, "{ this is not valid json").unwrap();
1325 #[cfg(unix)]
1326 {
1327 use std::os::unix::fs::PermissionsExt;
1328 let mut perms = fs::metadata(&path).unwrap().permissions();
1329 perms.set_mode(0o600);
1330 fs::set_permissions(&path, perms).unwrap();
1331 }
1332
1333 let store = FileKeyringStore::new(path.clone());
1334 let err = store.set("deepseek", "sk-new").unwrap_err();
1335 assert!(
1336 matches!(err, SecretsError::Json(_)),
1337 "set must surface the parse error rather than wiping the file; got: {err}"
1338 );
1339 let on_disk = fs::read_to_string(&path).unwrap();
1340 assert_eq!(on_disk, "{ this is not valid json");
1341 }
1342
1343 #[test]
1344 fn file_store_set_still_creates_file_when_missing() {
1345 let tmp = tempfile::tempdir().unwrap();
1350 let path = tmp.path().join("nested").join("secrets.json");
1351 let store = FileKeyringStore::new(path.clone());
1352
1353 store.set("deepseek", "sk-fresh").unwrap();
1354 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
1355 }
1356
1357 #[test]
1358 fn file_store_default_path_uses_home() {
1359 let _lock = env_lock();
1360 clear_known_envs();
1361 let tmp = tempfile::tempdir().unwrap();
1362 let _home = EnvVarGuard::set("HOME", tmp.path());
1363 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1364
1365 let path = FileKeyringStore::default_path().unwrap();
1366 assert_eq!(
1367 path,
1368 tmp.path()
1369 .join(".codewhale")
1370 .join("secrets")
1371 .join("secrets.json")
1372 );
1373 }
1374}