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