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(
128 target_os = "macos",
129 target_os = "windows",
130 all(target_os = "linux", not(target_env = "ohos"))
131 ))]
132 {
133 let entry = keyring::Entry::new(&self.service, "__probe__")
138 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
139 #[cfg(any(target_os = "macos", target_os = "windows"))]
140 {
141 let _ = entry;
142 Ok(())
143 }
144 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
145 match entry.get_password() {
146 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
147 Err(keyring::Error::PlatformFailure(err)) => {
148 Err(SecretsError::Keyring(format!("platform failure: {err}")))
149 }
150 Err(keyring::Error::NoStorageAccess(err)) => {
151 Err(SecretsError::Keyring(format!("no storage access: {err}")))
152 }
153 Err(other) => Err(SecretsError::Keyring(other.to_string())),
154 }
155 }
156 #[cfg(not(any(
157 target_os = "macos",
158 target_os = "windows",
159 all(target_os = "linux", not(target_env = "ohos"))
160 )))]
161 {
162 let _ = &self.service;
163 Err(SecretsError::Keyring(unsupported_keyring_message()))
164 }
165 }
166}
167
168impl KeyringStore for DefaultKeyringStore {
169 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
170 #[cfg(any(
171 target_os = "macos",
172 target_os = "windows",
173 all(target_os = "linux", not(target_env = "ohos"))
174 ))]
175 {
176 let entry = keyring::Entry::new(&self.service, key)
177 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
178 match entry.get_password() {
179 Ok(value) => Ok(Some(value)),
180 Err(keyring::Error::NoEntry) => Ok(None),
181 Err(err) => Err(SecretsError::Keyring(err.to_string())),
182 }
183 }
184 #[cfg(not(any(
185 target_os = "macos",
186 target_os = "windows",
187 all(target_os = "linux", not(target_env = "ohos"))
188 )))]
189 {
190 let _ = key;
191 Err(SecretsError::Keyring(unsupported_keyring_message()))
192 }
193 }
194
195 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
196 #[cfg(any(
197 target_os = "macos",
198 target_os = "windows",
199 all(target_os = "linux", not(target_env = "ohos"))
200 ))]
201 {
202 let entry = keyring::Entry::new(&self.service, key)
203 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
204 entry
205 .set_password(value)
206 .map_err(|err| SecretsError::Keyring(err.to_string()))
207 }
208 #[cfg(not(any(
209 target_os = "macos",
210 target_os = "windows",
211 all(target_os = "linux", not(target_env = "ohos"))
212 )))]
213 {
214 let _ = (key, value);
215 Err(SecretsError::Keyring(unsupported_keyring_message()))
216 }
217 }
218
219 fn delete(&self, key: &str) -> Result<(), SecretsError> {
220 #[cfg(any(
221 target_os = "macos",
222 target_os = "windows",
223 all(target_os = "linux", not(target_env = "ohos"))
224 ))]
225 {
226 let entry = keyring::Entry::new(&self.service, key)
227 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
228 match entry.delete_credential() {
229 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
230 Err(err) => Err(SecretsError::Keyring(err.to_string())),
231 }
232 }
233 #[cfg(not(any(
234 target_os = "macos",
235 target_os = "windows",
236 all(target_os = "linux", not(target_env = "ohos"))
237 )))]
238 {
239 let _ = key;
240 Err(SecretsError::Keyring(unsupported_keyring_message()))
241 }
242 }
243
244 fn backend_name(&self) -> &'static str {
245 "system keyring"
246 }
247}
248
249#[cfg(not(any(
250 target_os = "macos",
251 target_os = "windows",
252 all(target_os = "linux", not(target_env = "ohos"))
253)))]
254fn unsupported_keyring_message() -> String {
255 "system keyring backend is unsupported on this platform".to_string()
256}
257
258#[derive(Debug, Default)]
265pub struct InMemoryKeyringStore {
266 entries: Mutex<HashMap<String, String>>,
268}
269
270impl InMemoryKeyringStore {
271 #[must_use]
273 pub fn new() -> Self {
274 Self::default()
275 }
276}
277
278impl KeyringStore for InMemoryKeyringStore {
279 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
280 let guard = self.entries.lock().map_err(|e| {
281 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
282 })?;
283 Ok(guard.get(key).cloned())
284 }
285
286 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
287 let mut guard = self.entries.lock().map_err(|e| {
288 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
289 })?;
290 guard.insert(key.to_string(), value.to_string());
291 Ok(())
292 }
293
294 fn delete(&self, key: &str) -> Result<(), SecretsError> {
295 let mut guard = self.entries.lock().map_err(|e| {
296 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
297 })?;
298 guard.remove(key);
299 Ok(())
300 }
301
302 fn backend_name(&self) -> &'static str {
303 "in-memory (test)"
304 }
305}
306
307#[derive(Debug, Clone)]
320pub struct FileKeyringStore {
321 path: PathBuf,
323}
324
325#[derive(Debug, Default, Serialize, Deserialize)]
326struct FileSecretsBlob {
327 #[serde(default)]
328 entries: HashMap<String, String>,
329}
330
331impl FileKeyringStore {
332 #[must_use]
334 pub fn new(path: impl Into<PathBuf>) -> Self {
335 Self { path: path.into() }
336 }
337
338 pub fn default_path() -> Result<PathBuf, SecretsError> {
344 let primary = default_codewhale_secrets_path()?;
345 let legacy = legacy_deepseek_secrets_path()?;
346 if let Err(err) = Self::migrate_legacy_file_if_needed(&primary, &legacy) {
347 tracing::warn!(
348 "could not migrate legacy secret store from {} to {}: {err}",
349 legacy.display(),
350 primary.display()
351 );
352 }
353 Ok(primary)
354 }
355
356 fn migrate_legacy_file_if_needed(primary: &Path, legacy: &Path) -> Result<(), SecretsError> {
357 if !legacy.exists() {
358 return Ok(());
359 }
360
361 let legacy_store = Self::new(legacy.to_path_buf());
362 let legacy_blob = legacy_store.load_unlocked()?;
363 if legacy_blob.entries.is_empty() {
364 return Ok(());
365 }
366
367 let primary_store = Self::new(primary.to_path_buf());
368 let mut primary_blob = primary_store.load_unlocked()?;
369 let mut changed = false;
370 for (key, value) in legacy_blob.entries {
371 if let std::collections::hash_map::Entry::Vacant(entry) =
372 primary_blob.entries.entry(key)
373 {
374 entry.insert(value);
375 changed = true;
376 }
377 }
378 if changed {
379 primary_store.store_unlocked(&primary_blob)?;
380 }
381 Ok(())
382 }
383
384 fn home_dir() -> Result<PathBuf, SecretsError> {
385 for var in ["HOME", "USERPROFILE"] {
386 if let Ok(value) = std::env::var(var) {
387 let trimmed = value.trim();
388 if !trimmed.is_empty() {
389 return Ok(PathBuf::from(trimmed));
390 }
391 }
392 }
393
394 dirs::home_dir().ok_or_else(|| {
395 SecretsError::Io(std::io::Error::new(
396 std::io::ErrorKind::NotFound,
397 "could not resolve home directory for FileKeyringStore",
398 ))
399 })
400 }
401
402 #[must_use]
404 pub fn path(&self) -> &Path {
405 &self.path
406 }
407
408 fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
409 if !self.path.exists() {
410 return Ok(FileSecretsBlob::default());
411 }
412 #[cfg(unix)]
416 {
417 use std::os::unix::fs::PermissionsExt;
418 let meta = fs::metadata(&self.path)?;
419 let mode = meta.permissions().mode() & 0o777;
420 if mode & 0o077 != 0 {
421 return Err(SecretsError::InsecurePermissions {
422 path: self.path.clone(),
423 mode,
424 });
425 }
426 }
427 let raw = fs::read_to_string(&self.path)?;
428 if raw.trim().is_empty() {
429 return Ok(FileSecretsBlob::default());
430 }
431 let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
432 Ok(blob)
433 }
434
435 fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
436 if let Some(parent) = self.path.parent() {
437 fs::create_dir_all(parent)?;
438 #[cfg(unix)]
439 {
440 use std::os::unix::fs::PermissionsExt;
441 let mut perms = fs::metadata(parent)?.permissions();
442 perms.set_mode(0o700);
443 let _ = fs::set_permissions(parent, perms);
444 }
445 }
446 let body = serde_json::to_string_pretty(blob)?;
447 fs::write(&self.path, body)?;
448 #[cfg(unix)]
449 {
450 use std::os::unix::fs::PermissionsExt;
451 if let Ok(meta) = fs::metadata(&self.path) {
458 let mut perms = meta.permissions();
459 perms.set_mode(0o600);
460 let _ = fs::set_permissions(&self.path, perms);
461 }
462 }
463 Ok(())
464 }
465}
466
467impl KeyringStore for FileKeyringStore {
468 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
469 let blob = self.load_unlocked()?;
470 Ok(blob.entries.get(key).cloned())
471 }
472
473 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
474 let mut blob = self.load_unlocked()?;
480 blob.entries.insert(key.to_string(), value.to_string());
481 self.store_unlocked(&blob)
482 }
483
484 fn delete(&self, key: &str) -> Result<(), SecretsError> {
485 let mut blob = self.load_unlocked()?;
488 blob.entries.remove(key);
489 self.store_unlocked(&blob)
490 }
491
492 fn backend_name(&self) -> &'static str {
493 FILE_BACKEND_LABEL
494 }
495}
496
497fn default_codewhale_secrets_path() -> Result<PathBuf, SecretsError> {
498 if let Ok(value) = std::env::var("CODEWHALE_HOME") {
499 let trimmed = value.trim();
500 if !trimmed.is_empty() {
501 return Ok(PathBuf::from(trimmed).join("secrets").join("secrets.json"));
502 }
503 }
504 Ok(FileKeyringStore::home_dir()?
505 .join(".codewhale")
506 .join("secrets")
507 .join("secrets.json"))
508}
509
510fn legacy_deepseek_secrets_path() -> Result<PathBuf, SecretsError> {
511 Ok(FileKeyringStore::home_dir()?
512 .join(".deepseek")
513 .join("secrets")
514 .join("secrets.json"))
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
518enum SecretBackendSelection {
519 File,
520 System,
521 Unknown,
522}
523
524fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
525 match value.map(str::trim).filter(|value| !value.is_empty()) {
526 None => SecretBackendSelection::File,
527 Some(value) => match value.to_ascii_lowercase().as_str() {
528 "file" | "local" | "json" => SecretBackendSelection::File,
529 "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
530 _ => SecretBackendSelection::Unknown,
531 },
532 }
533}
534
535fn configured_secret_backend() -> Option<String> {
536 std::env::var(SECRET_BACKEND_ENV)
537 .ok()
538 .filter(|value| !value.trim().is_empty())
539 .or_else(|| std::env::var(LEGACY_SECRET_BACKEND_ENV).ok())
540}
541
542#[derive(Clone)]
559pub struct Secrets {
560 pub store: Arc<dyn KeyringStore>,
562 service: String,
567}
568
569#[derive(Debug, Clone, Copy, PartialEq, Eq)]
575pub enum SecretSource {
576 Keyring,
578 Env,
580}
581
582impl std::fmt::Debug for Secrets {
583 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
584 f.debug_struct("Secrets")
585 .field("backend", &self.store.backend_name())
586 .field("service", &self.service)
587 .finish()
588 }
589}
590
591impl Secrets {
592 #[must_use]
595 pub fn new(store: Arc<dyn KeyringStore>) -> Self {
596 Self {
597 store,
598 service: DEFAULT_SERVICE.to_string(),
599 }
600 }
601
602 pub fn auto_detect() -> Self {
613 match secret_backend_selection(configured_secret_backend().as_deref()) {
614 SecretBackendSelection::File => Self::file_backed_default(),
615 SecretBackendSelection::Unknown => {
616 tracing::warn!(
617 "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
618 );
619 Self::file_backed_default()
620 }
621 SecretBackendSelection::System => {
622 let default_store = DefaultKeyringStore::default();
623 match default_store.probe() {
624 Ok(()) => Self::new(Arc::new(default_store)),
625 Err(err) => {
626 tracing::warn!(
627 "OS keyring unavailable ({err}); falling back to file-backed secret store"
628 );
629 Self::file_backed_default()
630 }
631 }
632 }
633 }
634 }
635
636 fn file_backed_default() -> Self {
637 let path = FileKeyringStore::default_path()
638 .unwrap_or_else(|_| PathBuf::from(".codewhale-secrets.json"));
639 Self::new(Arc::new(FileKeyringStore::new(path)))
640 }
641
642 #[must_use]
644 pub fn file_backed() -> Self {
645 Self::file_backed_default()
646 }
647
648 #[must_use]
651 pub fn system_keyring() -> Self {
652 let default_store = DefaultKeyringStore::default();
653 match default_store.probe() {
654 Ok(()) => Self::new(Arc::new(default_store)),
655 Err(err) => {
656 tracing::warn!(
657 "OS keyring unavailable ({err}); falling back to file-backed secret store"
658 );
659 Self::file_backed_default()
660 }
661 }
662 }
663
664 #[must_use]
666 pub fn backend_name(&self) -> &'static str {
667 self.store.backend_name()
668 }
669
670 #[must_use]
675 pub fn resolve(&self, name: &str) -> Option<String> {
676 self.resolve_with_source(name).map(|(value, _)| value)
677 }
678
679 #[must_use]
681 pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
682 if let Ok(Some(v)) = self.store.get(name)
683 && !v.trim().is_empty()
684 {
685 return Some((v, SecretSource::Keyring));
686 }
687 env_for(name).map(|value| (value, SecretSource::Env))
688 }
689
690 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
692 self.store.set(name, value)
693 }
694
695 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
697 self.store.delete(name)
698 }
699
700 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
702 self.store.get(name)
703 }
704}
705
706#[must_use]
734pub fn env_for(name: &str) -> Option<String> {
735 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
736 "deepseek" => &["DEEPSEEK_API_KEY"],
737 "openrouter" => &["OPENROUTER_API_KEY"],
738 "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
739 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
740 }
741 "novita" => &["NOVITA_API_KEY"],
742 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
746 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
747 }
748 "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
749 "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
750 | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
751 "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
752 "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
753 "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
754 "vllm" | "v-llm" => &["VLLM_API_KEY"],
755 "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
756 "openai" => &["OPENAI_API_KEY"],
757 "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
758 "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
759 | "volcengineark" => &[
760 "VOLCENGINE_API_KEY",
761 "VOLCENGINE_ARK_API_KEY",
762 "ARK_API_KEY",
763 ],
764 "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
765 | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
766 "WANJIE_ARK_API_KEY",
767 "WANJIE_API_KEY",
768 "WANJIE_MAAS_API_KEY",
769 ],
770 _ => return None,
771 };
772 for var in candidates {
773 if let Ok(value) = std::env::var(var)
774 && !value.trim().is_empty()
775 {
776 return Some(value);
777 }
778 }
779 None
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use std::sync::{Mutex, OnceLock};
786
787 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
790 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
791 LOCK.get_or_init(|| Mutex::new(()))
792 .lock()
793 .unwrap_or_else(|p| p.into_inner())
794 }
795
796 fn clear_known_envs() {
797 for var in [
798 "CODEWHALE_HOME",
799 "DEEPSEEK_API_KEY",
800 "OPENROUTER_API_KEY",
801 "NOVITA_API_KEY",
802 "NVIDIA_API_KEY",
803 "NVIDIA_NIM_API_KEY",
804 "FIREWORKS_API_KEY",
805 "SILICONFLOW_API_KEY",
806 "ARCEE_API_KEY",
807 "SGLANG_API_KEY",
808 "VLLM_API_KEY",
809 "OLLAMA_API_KEY",
810 "OPENAI_API_KEY",
811 "ATLASCLOUD_API_KEY",
812 "WANJIE_ARK_API_KEY",
813 "WANJIE_API_KEY",
814 "WANJIE_MAAS_API_KEY",
815 "XIAOMI_MIMO_API_KEY",
816 "XIAOMI_API_KEY",
817 "MIMO_API_KEY",
818 SECRET_BACKEND_ENV,
819 LEGACY_SECRET_BACKEND_ENV,
820 ] {
821 unsafe { std::env::remove_var(var) };
824 }
825 }
826
827 struct EnvVarGuard {
828 name: &'static str,
829 previous: Option<std::ffi::OsString>,
830 }
831
832 impl EnvVarGuard {
833 fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
834 let previous = std::env::var_os(name);
835 unsafe { std::env::set_var(name, value) };
836 Self { name, previous }
837 }
838 }
839
840 impl Drop for EnvVarGuard {
841 fn drop(&mut self) {
842 match self.previous.take() {
843 Some(value) => unsafe { std::env::set_var(self.name, value) },
844 None => unsafe { std::env::remove_var(self.name) },
845 }
846 }
847 }
848
849 #[test]
850 fn backend_selection_defaults_to_file() {
851 assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
852 assert_eq!(
853 secret_backend_selection(Some("")),
854 SecretBackendSelection::File
855 );
856 assert_eq!(
857 secret_backend_selection(Some(" file ")),
858 SecretBackendSelection::File
859 );
860 }
861
862 #[test]
863 fn backend_selection_accepts_explicit_system_keyring() {
864 assert_eq!(
865 secret_backend_selection(Some("system")),
866 SecretBackendSelection::System
867 );
868 assert_eq!(
869 secret_backend_selection(Some("keyring")),
870 SecretBackendSelection::System
871 );
872 assert_eq!(
873 secret_backend_selection(Some("os-keyring")),
874 SecretBackendSelection::System
875 );
876 }
877
878 #[test]
879 fn auto_detect_is_file_backed_by_default() {
880 let _lock = env_lock();
881 clear_known_envs();
882 let tmp = tempfile::tempdir().unwrap();
883 let _home = EnvVarGuard::set("HOME", tmp.path());
884 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
885
886 let secrets = Secrets::auto_detect();
887
888 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
889 }
890
891 #[test]
892 fn auto_detect_honors_explicit_file_backend() {
893 let _lock = env_lock();
894 clear_known_envs();
895 let tmp = tempfile::tempdir().unwrap();
896 let _home = EnvVarGuard::set("HOME", tmp.path());
897 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
898 unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
900
901 let secrets = Secrets::auto_detect();
902
903 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
904 unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
906 }
907
908 #[test]
909 fn auto_detect_honors_legacy_backend_env_alias() {
910 let _lock = env_lock();
911 clear_known_envs();
912 let tmp = tempfile::tempdir().unwrap();
913 let _home = EnvVarGuard::set("HOME", tmp.path());
914 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
915 unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
916
917 let secrets = Secrets::auto_detect();
918
919 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
920 clear_known_envs();
921 }
922
923 #[test]
924 fn file_default_path_uses_codewhale_home() {
925 let _lock = env_lock();
926 clear_known_envs();
927 let tmp = tempfile::tempdir().unwrap();
928 let _home = EnvVarGuard::set("HOME", tmp.path());
929 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
930
931 let path = FileKeyringStore::default_path().unwrap();
932
933 assert_eq!(
934 path,
935 tmp.path()
936 .join(".codewhale")
937 .join("secrets")
938 .join("secrets.json")
939 );
940 }
941
942 #[test]
943 fn file_default_path_honors_codewhale_home() {
944 let _lock = env_lock();
945 clear_known_envs();
946 let tmp = tempfile::tempdir().unwrap();
947 let custom = tmp.path().join("custom-codewhale");
948 let _home = EnvVarGuard::set("HOME", tmp.path());
949 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
950 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
951
952 let path = FileKeyringStore::default_path().unwrap();
953
954 assert_eq!(path, custom.join("secrets").join("secrets.json"));
955 }
956
957 #[test]
958 fn file_default_path_migrates_legacy_entries_to_codewhale() {
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 FileKeyringStore::new(legacy.clone())
970 .set("xiaomi-mimo", "legacy-mimo")
971 .unwrap();
972
973 let primary = FileKeyringStore::default_path().unwrap();
974 let primary_store = FileKeyringStore::new(primary.clone());
975
976 assert_eq!(
977 primary,
978 tmp.path()
979 .join(".codewhale")
980 .join("secrets")
981 .join("secrets.json")
982 );
983 assert_eq!(
984 primary_store.get("xiaomi-mimo").unwrap().as_deref(),
985 Some("legacy-mimo")
986 );
987 assert!(
988 legacy.exists(),
989 "migration copies; it does not delete legacy data"
990 );
991 }
992
993 #[test]
994 fn file_default_path_migration_preserves_primary_values() {
995 let _lock = env_lock();
996 clear_known_envs();
997 let tmp = tempfile::tempdir().unwrap();
998 let _home = EnvVarGuard::set("HOME", tmp.path());
999 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1000 let legacy = tmp
1001 .path()
1002 .join(".deepseek")
1003 .join("secrets")
1004 .join("secrets.json");
1005 let primary = tmp
1006 .path()
1007 .join(".codewhale")
1008 .join("secrets")
1009 .join("secrets.json");
1010 FileKeyringStore::new(legacy)
1011 .set("openrouter", "legacy-openrouter")
1012 .unwrap();
1013 let primary_store = FileKeyringStore::new(primary.clone());
1014 primary_store
1015 .set("openrouter", "primary-openrouter")
1016 .unwrap();
1017
1018 let resolved = FileKeyringStore::default_path().unwrap();
1019
1020 assert_eq!(resolved, primary);
1021 assert_eq!(
1022 primary_store.get("openrouter").unwrap().as_deref(),
1023 Some("primary-openrouter")
1024 );
1025 }
1026
1027 #[test]
1028 fn in_memory_store_round_trips() {
1029 let store = InMemoryKeyringStore::new();
1030 assert_eq!(store.get("deepseek").unwrap(), None);
1031 store.set("deepseek", "sk-test").unwrap();
1032 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
1033 store.set("deepseek", "sk-replaced").unwrap();
1034 assert_eq!(
1035 store.get("deepseek").unwrap(),
1036 Some("sk-replaced".to_string())
1037 );
1038 store.delete("deepseek").unwrap();
1039 assert_eq!(store.get("deepseek").unwrap(), None);
1040 store.delete("missing").unwrap();
1042 }
1043
1044 #[test]
1045 fn resolve_prefers_keyring_over_env() {
1046 let _lock = env_lock();
1047 clear_known_envs();
1048 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1050
1051 let store = Arc::new(InMemoryKeyringStore::new());
1052 store.set("deepseek", "ring-key").unwrap();
1053 let secrets = Secrets::new(store);
1054
1055 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1056 assert_eq!(
1057 secrets.resolve_with_source("deepseek"),
1058 Some(("ring-key".to_string(), SecretSource::Keyring))
1059 );
1060 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1062 }
1063
1064 #[test]
1065 fn resolve_falls_back_to_env_when_keyring_empty() {
1066 let _lock = env_lock();
1067 clear_known_envs();
1068 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1070
1071 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1072 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1073 assert_eq!(
1074 secrets.resolve_with_source("deepseek"),
1075 Some(("env-fallback".to_string(), SecretSource::Env))
1076 );
1077 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1079 }
1080
1081 #[test]
1082 fn resolve_returns_none_when_both_layers_empty() {
1083 let _lock = env_lock();
1084 clear_known_envs();
1085 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1086 assert_eq!(secrets.resolve("deepseek"), None);
1087 }
1088
1089 #[test]
1090 fn resolve_treats_blank_keyring_value_as_unset() {
1091 let _lock = env_lock();
1092 clear_known_envs();
1093 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1095
1096 let store = Arc::new(InMemoryKeyringStore::new());
1097 store.set("deepseek", " ").unwrap();
1098 let secrets = Secrets::new(store);
1099 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1100 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1102 }
1103
1104 #[test]
1105 fn nvidia_env_aliases_resolve() {
1106 let _lock = env_lock();
1107 clear_known_envs();
1108 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1110 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1111 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1112 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1113 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1115 }
1116
1117 #[test]
1118 fn atlascloud_env_aliases_resolve() {
1119 let _guard = env_lock();
1120 clear_known_envs();
1121 unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1122
1123 assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1124 assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1125 assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1126
1127 clear_known_envs();
1128 }
1129
1130 #[test]
1131 fn wanjie_ark_env_aliases_resolve() {
1132 let _guard = env_lock();
1133 clear_known_envs();
1134 unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1135
1136 assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1137 assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1138 assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1139
1140 clear_known_envs();
1141 }
1142
1143 #[test]
1144 fn xiaomi_mimo_env_aliases_resolve() {
1145 let _guard = env_lock();
1146 clear_known_envs();
1147 unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1148
1149 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1150 assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1151 assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1152 assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1153
1154 clear_known_envs();
1155
1156 unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1157 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1158 clear_known_envs();
1159 }
1160
1161 #[test]
1162 fn fireworks_env_aliases_resolve() {
1163 let _lock = env_lock();
1164 clear_known_envs();
1165 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1167
1168 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1169 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1170 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1172 }
1173
1174 #[test]
1175 fn siliconflow_env_aliases_resolve() {
1176 let _lock = env_lock();
1177 clear_known_envs();
1178 unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
1180
1181 assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
1182 assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
1183 assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
1184 assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
1185 assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
1186 unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
1188 }
1189
1190 #[test]
1191 fn arcee_env_aliases_resolve() {
1192 let _lock = env_lock();
1193 clear_known_envs();
1194 unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
1196
1197 assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
1198 assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
1199 assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
1200 unsafe { std::env::remove_var("ARCEE_API_KEY") };
1202 }
1203
1204 #[test]
1205 fn moonshot_kimi_env_aliases_resolve() {
1206 let _lock = env_lock();
1207 clear_known_envs();
1208 unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
1210
1211 assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
1212 assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
1213 assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
1214 assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
1215 unsafe { std::env::remove_var("KIMI_API_KEY") };
1217 }
1218
1219 #[test]
1220 fn sglang_env_aliases_resolve() {
1221 let _lock = env_lock();
1222 clear_known_envs();
1223 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
1225
1226 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
1227 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
1228 unsafe { std::env::remove_var("SGLANG_API_KEY") };
1230 }
1231
1232 #[test]
1233 fn vllm_env_aliases_resolve() {
1234 let _lock = env_lock();
1235 clear_known_envs();
1236 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
1238
1239 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
1240 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
1241 unsafe { std::env::remove_var("VLLM_API_KEY") };
1243 }
1244
1245 #[test]
1246 fn ollama_env_aliases_resolve() {
1247 let _lock = env_lock();
1248 clear_known_envs();
1249 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
1251
1252 assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
1253 assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
1254 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
1256 }
1257
1258 #[cfg(unix)]
1259 #[test]
1260 fn file_store_round_trips_with_secure_perms() {
1261 use std::os::unix::fs::PermissionsExt;
1262
1263 let tmp = tempfile::tempdir().unwrap();
1264 let path = tmp.path().join("nested").join("secrets.json");
1265 let store = FileKeyringStore::new(path.clone());
1266 assert_eq!(store.get("deepseek").unwrap(), None);
1267 store.set("deepseek", "sk-disk").unwrap();
1268 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1269
1270 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1271 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
1272
1273 store.set("openrouter", "or-disk").unwrap();
1274 assert_eq!(
1275 store.get("openrouter").unwrap(),
1276 Some("or-disk".to_string())
1277 );
1278 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1280
1281 store.delete("deepseek").unwrap();
1282 assert_eq!(store.get("deepseek").unwrap(), None);
1283 }
1284
1285 #[cfg(unix)]
1286 #[test]
1287 fn file_store_rejects_world_readable_file() {
1288 use std::os::unix::fs::PermissionsExt;
1289 let tmp = tempfile::tempdir().unwrap();
1290 let path = tmp.path().join("secrets.json");
1291 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
1292 let mut perms = fs::metadata(&path).unwrap().permissions();
1293 perms.set_mode(0o644);
1294 fs::set_permissions(&path, perms).unwrap();
1295
1296 let store = FileKeyringStore::new(path);
1297 let err = store.get("deepseek").unwrap_err();
1298 assert!(
1299 matches!(err, SecretsError::InsecurePermissions { .. }),
1300 "unexpected error: {err}"
1301 );
1302 }
1303
1304 #[cfg(unix)]
1310 #[test]
1311 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
1312 use std::os::unix::fs::PermissionsExt;
1313 let tmp = tempfile::tempdir().unwrap();
1314 let path = tmp.path().join("secrets.json");
1315 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1316 fs::write(&path, original).unwrap();
1317 let mut perms = fs::metadata(&path).unwrap().permissions();
1318 perms.set_mode(0o644);
1319 fs::set_permissions(&path, perms).unwrap();
1320
1321 let store = FileKeyringStore::new(path.clone());
1322 let err = store.set("openrouter", "or-new").unwrap_err();
1323 assert!(
1324 matches!(err, SecretsError::InsecurePermissions { .. }),
1325 "set must surface the read error rather than overwriting; got: {err}"
1326 );
1327
1328 let on_disk = fs::read_to_string(&path).unwrap();
1329 assert_eq!(
1330 on_disk, original,
1331 "set must not modify the file when load_unlocked errored"
1332 );
1333 }
1334
1335 #[cfg(unix)]
1336 #[test]
1337 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
1338 use std::os::unix::fs::PermissionsExt;
1339 let tmp = tempfile::tempdir().unwrap();
1340 let path = tmp.path().join("secrets.json");
1341 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1342 fs::write(&path, original).unwrap();
1343 let mut perms = fs::metadata(&path).unwrap().permissions();
1344 perms.set_mode(0o644);
1345 fs::set_permissions(&path, perms).unwrap();
1346
1347 let store = FileKeyringStore::new(path.clone());
1348 let err = store.delete("nvidia").unwrap_err();
1349 assert!(
1350 matches!(err, SecretsError::InsecurePermissions { .. }),
1351 "delete must surface the read error rather than wiping the file; got: {err}"
1352 );
1353 let on_disk = fs::read_to_string(&path).unwrap();
1354 assert_eq!(on_disk, original);
1355 }
1356
1357 #[test]
1358 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
1359 let tmp = tempfile::tempdir().unwrap();
1360 let path = tmp.path().join("secrets.json");
1361 fs::write(&path, "{ this is not valid json").unwrap();
1364 #[cfg(unix)]
1365 {
1366 use std::os::unix::fs::PermissionsExt;
1367 let mut perms = fs::metadata(&path).unwrap().permissions();
1368 perms.set_mode(0o600);
1369 fs::set_permissions(&path, perms).unwrap();
1370 }
1371
1372 let store = FileKeyringStore::new(path.clone());
1373 let err = store.set("deepseek", "sk-new").unwrap_err();
1374 assert!(
1375 matches!(err, SecretsError::Json(_)),
1376 "set must surface the parse error rather than wiping the file; got: {err}"
1377 );
1378 let on_disk = fs::read_to_string(&path).unwrap();
1379 assert_eq!(on_disk, "{ this is not valid json");
1380 }
1381
1382 #[test]
1383 fn file_store_set_still_creates_file_when_missing() {
1384 let tmp = tempfile::tempdir().unwrap();
1389 let path = tmp.path().join("nested").join("secrets.json");
1390 let store = FileKeyringStore::new(path.clone());
1391
1392 store.set("deepseek", "sk-fresh").unwrap();
1393 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
1394 }
1395
1396 #[test]
1397 fn file_store_default_path_uses_home() {
1398 let _lock = env_lock();
1399 clear_known_envs();
1400 let tmp = tempfile::tempdir().unwrap();
1401 let _home = EnvVarGuard::set("HOME", tmp.path());
1402 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1403
1404 let path = FileKeyringStore::default_path().unwrap();
1405 assert_eq!(
1406 path,
1407 tmp.path()
1408 .join(".codewhale")
1409 .join("secrets")
1410 .join("secrets.json")
1411 );
1412 }
1413}