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(
131 target_os = "linux",
132 not(target_env = "ohos"),
133 not(target_env = "musl")
134 )
135 ))]
136 {
137 let entry = keyring::Entry::new(&self.service, "__probe__")
142 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
143 #[cfg(any(target_os = "macos", target_os = "windows"))]
144 {
145 let _ = entry;
146 Ok(())
147 }
148 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
149 match entry.get_password() {
150 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
151 Err(keyring::Error::PlatformFailure(err)) => {
152 Err(SecretsError::Keyring(format!("platform failure: {err}")))
153 }
154 Err(keyring::Error::NoStorageAccess(err)) => {
155 Err(SecretsError::Keyring(format!("no storage access: {err}")))
156 }
157 Err(other) => Err(SecretsError::Keyring(other.to_string())),
158 }
159 }
160 #[cfg(not(any(
161 target_os = "macos",
162 target_os = "windows",
163 all(
164 target_os = "linux",
165 not(target_env = "ohos"),
166 not(target_env = "musl")
167 )
168 )))]
169 {
170 let _ = &self.service;
171 Err(SecretsError::Keyring(unsupported_keyring_message()))
172 }
173 }
174}
175
176impl KeyringStore for DefaultKeyringStore {
177 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
178 #[cfg(any(
179 target_os = "macos",
180 target_os = "windows",
181 all(
182 target_os = "linux",
183 not(target_env = "ohos"),
184 not(target_env = "musl")
185 )
186 ))]
187 {
188 let entry = keyring::Entry::new(&self.service, key)
189 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
190 match entry.get_password() {
191 Ok(value) => Ok(Some(value)),
192 Err(keyring::Error::NoEntry) => Ok(None),
193 Err(err) => Err(SecretsError::Keyring(err.to_string())),
194 }
195 }
196 #[cfg(not(any(
197 target_os = "macos",
198 target_os = "windows",
199 all(
200 target_os = "linux",
201 not(target_env = "ohos"),
202 not(target_env = "musl")
203 )
204 )))]
205 {
206 let _ = key;
207 Err(SecretsError::Keyring(unsupported_keyring_message()))
208 }
209 }
210
211 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
212 #[cfg(any(
213 target_os = "macos",
214 target_os = "windows",
215 all(
216 target_os = "linux",
217 not(target_env = "ohos"),
218 not(target_env = "musl")
219 )
220 ))]
221 {
222 let entry = keyring::Entry::new(&self.service, key)
223 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
224 entry
225 .set_password(value)
226 .map_err(|err| SecretsError::Keyring(err.to_string()))
227 }
228 #[cfg(not(any(
229 target_os = "macos",
230 target_os = "windows",
231 all(
232 target_os = "linux",
233 not(target_env = "ohos"),
234 not(target_env = "musl")
235 )
236 )))]
237 {
238 let _ = (key, value);
239 Err(SecretsError::Keyring(unsupported_keyring_message()))
240 }
241 }
242
243 fn delete(&self, key: &str) -> Result<(), SecretsError> {
244 #[cfg(any(
245 target_os = "macos",
246 target_os = "windows",
247 all(
248 target_os = "linux",
249 not(target_env = "ohos"),
250 not(target_env = "musl")
251 )
252 ))]
253 {
254 let entry = keyring::Entry::new(&self.service, key)
255 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
256 match entry.delete_credential() {
257 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
258 Err(err) => Err(SecretsError::Keyring(err.to_string())),
259 }
260 }
261 #[cfg(not(any(
262 target_os = "macos",
263 target_os = "windows",
264 all(
265 target_os = "linux",
266 not(target_env = "ohos"),
267 not(target_env = "musl")
268 )
269 )))]
270 {
271 let _ = key;
272 Err(SecretsError::Keyring(unsupported_keyring_message()))
273 }
274 }
275
276 fn backend_name(&self) -> &'static str {
277 "system keyring"
278 }
279}
280
281#[cfg(not(any(
282 target_os = "macos",
283 target_os = "windows",
284 all(
285 target_os = "linux",
286 not(target_env = "ohos"),
287 not(target_env = "musl")
288 )
289)))]
290fn unsupported_keyring_message() -> String {
291 "system keyring backend is unsupported on this platform".to_string()
292}
293
294#[derive(Debug, Default)]
301pub struct InMemoryKeyringStore {
302 entries: Mutex<HashMap<String, String>>,
304}
305
306impl InMemoryKeyringStore {
307 #[must_use]
309 pub fn new() -> Self {
310 Self::default()
311 }
312}
313
314impl KeyringStore for InMemoryKeyringStore {
315 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
316 let guard = self.entries.lock().map_err(|e| {
317 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
318 })?;
319 Ok(guard.get(key).cloned())
320 }
321
322 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
323 let mut guard = self.entries.lock().map_err(|e| {
324 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
325 })?;
326 guard.insert(key.to_string(), value.to_string());
327 Ok(())
328 }
329
330 fn delete(&self, key: &str) -> Result<(), SecretsError> {
331 let mut guard = self.entries.lock().map_err(|e| {
332 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
333 })?;
334 guard.remove(key);
335 Ok(())
336 }
337
338 fn backend_name(&self) -> &'static str {
339 "in-memory (test)"
340 }
341}
342
343#[derive(Debug, Clone)]
356pub struct FileKeyringStore {
357 path: PathBuf,
359}
360
361#[derive(Debug, Default, Serialize, Deserialize)]
362struct FileSecretsBlob {
363 #[serde(default)]
364 entries: HashMap<String, String>,
365}
366
367impl FileKeyringStore {
368 #[must_use]
370 pub fn new(path: impl Into<PathBuf>) -> Self {
371 Self { path: path.into() }
372 }
373
374 pub fn default_path() -> Result<PathBuf, SecretsError> {
380 let primary = default_codewhale_secrets_path()?;
381 let legacy = legacy_deepseek_secrets_path()?;
382 if let Err(err) = Self::migrate_legacy_file_if_needed(&primary, &legacy) {
383 tracing::warn!(
384 "could not migrate legacy secret store from {} to {}: {err}",
385 legacy.display(),
386 primary.display()
387 );
388 }
389 Ok(primary)
390 }
391
392 fn migrate_legacy_file_if_needed(primary: &Path, legacy: &Path) -> Result<(), SecretsError> {
393 if !legacy.exists() {
394 return Ok(());
395 }
396
397 let legacy_store = Self::new(legacy.to_path_buf());
398 let legacy_blob = legacy_store.load_unlocked()?;
399 if legacy_blob.entries.is_empty() {
400 return Ok(());
401 }
402
403 let primary_store = Self::new(primary.to_path_buf());
404 let mut primary_blob = primary_store.load_unlocked()?;
405 let mut changed = false;
406 for (key, value) in legacy_blob.entries {
407 if let std::collections::hash_map::Entry::Vacant(entry) =
408 primary_blob.entries.entry(key)
409 {
410 entry.insert(value);
411 changed = true;
412 }
413 }
414 if changed {
415 primary_store.store_unlocked(&primary_blob)?;
416 }
417 Ok(())
418 }
419
420 fn home_dir() -> Result<PathBuf, SecretsError> {
421 for var in ["HOME", "USERPROFILE"] {
422 if let Ok(value) = std::env::var(var) {
423 let trimmed = value.trim();
424 if !trimmed.is_empty() {
425 return Ok(PathBuf::from(trimmed));
426 }
427 }
428 }
429
430 dirs::home_dir().ok_or_else(|| {
431 SecretsError::Io(std::io::Error::new(
432 std::io::ErrorKind::NotFound,
433 "could not resolve home directory for FileKeyringStore",
434 ))
435 })
436 }
437
438 #[must_use]
440 pub fn path(&self) -> &Path {
441 &self.path
442 }
443
444 fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
445 if !self.path.exists() {
446 return Ok(FileSecretsBlob::default());
447 }
448 #[cfg(unix)]
452 {
453 use std::os::unix::fs::PermissionsExt;
454 let meta = fs::metadata(&self.path)?;
455 let mode = meta.permissions().mode() & 0o777;
456 if mode & 0o077 != 0 {
457 return Err(SecretsError::InsecurePermissions {
458 path: self.path.clone(),
459 mode,
460 });
461 }
462 }
463 let raw = fs::read_to_string(&self.path)?;
464 if raw.trim().is_empty() {
465 return Ok(FileSecretsBlob::default());
466 }
467 let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
468 Ok(blob)
469 }
470
471 fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
472 if let Some(parent) = self.path.parent() {
473 fs::create_dir_all(parent)?;
474 #[cfg(unix)]
475 {
476 use std::os::unix::fs::PermissionsExt;
477 let mut perms = fs::metadata(parent)?.permissions();
478 perms.set_mode(0o700);
479 let _ = fs::set_permissions(parent, perms);
480 }
481 }
482 let body = serde_json::to_string_pretty(blob)?;
483 write_private_file(&self.path, body.as_bytes())?;
484 #[cfg(unix)]
485 {
486 use std::os::unix::fs::PermissionsExt;
487 if let Ok(meta) = fs::metadata(&self.path) {
494 let mut perms = meta.permissions();
495 perms.set_mode(0o600);
496 let _ = fs::set_permissions(&self.path, perms);
497 }
498 }
499 Ok(())
500 }
501}
502
503#[cfg(unix)]
504fn write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
505 use std::fs::OpenOptions;
506 use std::io::Write;
507 use std::os::unix::fs::OpenOptionsExt;
508
509 let mut file = OpenOptions::new()
510 .create(true)
511 .truncate(true)
512 .write(true)
513 .mode(0o600)
514 .open(path)?;
515 file.write_all(body)?;
516 Ok(())
517}
518
519#[cfg(not(unix))]
520fn write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
521 fs::write(path, body)?;
522 Ok(())
523}
524
525impl KeyringStore for FileKeyringStore {
526 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
527 let blob = self.load_unlocked()?;
528 Ok(blob.entries.get(key).cloned())
529 }
530
531 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
532 let mut blob = self.load_unlocked()?;
538 blob.entries.insert(key.to_string(), value.to_string());
539 self.store_unlocked(&blob)
540 }
541
542 fn delete(&self, key: &str) -> Result<(), SecretsError> {
543 let mut blob = self.load_unlocked()?;
546 blob.entries.remove(key);
547 self.store_unlocked(&blob)
548 }
549
550 fn backend_name(&self) -> &'static str {
551 FILE_BACKEND_LABEL
552 }
553}
554
555fn default_codewhale_secrets_path() -> Result<PathBuf, SecretsError> {
556 if let Ok(value) = std::env::var("CODEWHALE_HOME") {
557 let trimmed = value.trim();
558 if !trimmed.is_empty() {
559 return Ok(PathBuf::from(trimmed).join("secrets").join("secrets.json"));
560 }
561 }
562 Ok(FileKeyringStore::home_dir()?
563 .join(".codewhale")
564 .join("secrets")
565 .join("secrets.json"))
566}
567
568fn legacy_deepseek_secrets_path() -> Result<PathBuf, SecretsError> {
569 Ok(FileKeyringStore::home_dir()?
570 .join(".deepseek")
571 .join("secrets")
572 .join("secrets.json"))
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576enum SecretBackendSelection {
577 File,
578 System,
579 Unknown,
580}
581
582fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
583 match value.map(str::trim).filter(|value| !value.is_empty()) {
584 None => SecretBackendSelection::File,
585 Some(value) => match value.to_ascii_lowercase().as_str() {
586 "file" | "local" | "json" => SecretBackendSelection::File,
587 "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
588 _ => SecretBackendSelection::Unknown,
589 },
590 }
591}
592
593fn configured_secret_backend() -> Option<String> {
594 std::env::var(SECRET_BACKEND_ENV)
595 .ok()
596 .filter(|value| !value.trim().is_empty())
597 .or_else(|| std::env::var(LEGACY_SECRET_BACKEND_ENV).ok())
598}
599
600#[derive(Clone)]
617pub struct Secrets {
618 pub store: Arc<dyn KeyringStore>,
620 service: String,
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
633pub enum SecretSource {
634 Keyring,
636 Env,
638}
639
640impl std::fmt::Debug for Secrets {
641 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
642 f.debug_struct("Secrets")
643 .field("backend", &self.store.backend_name())
644 .field("service", &self.service)
645 .finish()
646 }
647}
648
649impl Secrets {
650 #[must_use]
653 pub fn new(store: Arc<dyn KeyringStore>) -> Self {
654 Self {
655 store,
656 service: DEFAULT_SERVICE.to_string(),
657 }
658 }
659
660 pub fn auto_detect() -> Self {
671 match secret_backend_selection(configured_secret_backend().as_deref()) {
672 SecretBackendSelection::File => Self::file_backed_default(),
673 SecretBackendSelection::Unknown => {
674 tracing::warn!(
675 "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
676 );
677 Self::file_backed_default()
678 }
679 SecretBackendSelection::System => {
680 let default_store = DefaultKeyringStore::default();
681 match default_store.probe() {
682 Ok(()) => Self::new(Arc::new(default_store)),
683 Err(err) => {
684 tracing::warn!(
685 "OS keyring unavailable ({err}); falling back to file-backed secret store"
686 );
687 Self::file_backed_default()
688 }
689 }
690 }
691 }
692 }
693
694 fn file_backed_default() -> Self {
695 let path = FileKeyringStore::default_path()
696 .unwrap_or_else(|_| PathBuf::from(".codewhale-secrets.json"));
697 Self::new(Arc::new(FileKeyringStore::new(path)))
698 }
699
700 #[must_use]
702 pub fn file_backed() -> Self {
703 Self::file_backed_default()
704 }
705
706 #[must_use]
709 pub fn system_keyring() -> Self {
710 let default_store = DefaultKeyringStore::default();
711 match default_store.probe() {
712 Ok(()) => Self::new(Arc::new(default_store)),
713 Err(err) => {
714 tracing::warn!(
715 "OS keyring unavailable ({err}); falling back to file-backed secret store"
716 );
717 Self::file_backed_default()
718 }
719 }
720 }
721
722 #[must_use]
724 pub fn backend_name(&self) -> &'static str {
725 self.store.backend_name()
726 }
727
728 #[must_use]
733 pub fn resolve(&self, name: &str) -> Option<String> {
734 self.resolve_with_source(name).map(|(value, _)| value)
735 }
736
737 #[must_use]
739 pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
740 if let Ok(Some(v)) = self.store.get(name)
741 && !v.trim().is_empty()
742 {
743 return Some((v, SecretSource::Keyring));
744 }
745 env_for(name).map(|value| (value, SecretSource::Env))
746 }
747
748 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
750 self.store.set(name, value)
751 }
752
753 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
755 self.store.delete(name)
756 }
757
758 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
760 self.store.get(name)
761 }
762
763 #[must_use]
775 pub fn resolve_direct(&self, key: &str, source_hint: Option<&str>) -> Option<String> {
776 match source_hint {
777 Some("env") => {
778 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
780 }
781 Some("keyring") | Some("file") => {
782 self.store
784 .get(key)
785 .ok()
786 .flatten()
787 .filter(|v| !v.trim().is_empty())
788 }
789 Some(_) | None => {
790 if let Ok(Some(v)) = self.store.get(key)
792 && !v.trim().is_empty()
793 {
794 return Some(v);
795 }
796 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
797 }
798 }
799 }
800}
801
802#[must_use]
830pub fn env_for(name: &str) -> Option<String> {
831 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
832 "deepseek" => &["DEEPSEEK_API_KEY"],
833 "openrouter" => &["OPENROUTER_API_KEY"],
834 "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
835 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
836 }
837 "novita" => &["NOVITA_API_KEY"],
838 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
842 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
843 }
844 "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
845 "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
846 | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
847 "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
848 "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
849 "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
850 "vllm" | "v-llm" => &["VLLM_API_KEY"],
851 "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
852 "openai" => &["OPENAI_API_KEY"],
853 "anthropic" | "claude" => &["ANTHROPIC_API_KEY"],
854 "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
855 "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
856 | "volcengineark" => &[
857 "VOLCENGINE_API_KEY",
858 "VOLCENGINE_ARK_API_KEY",
859 "ARK_API_KEY",
860 ],
861 "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
862 | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
863 "WANJIE_ARK_API_KEY",
864 "WANJIE_API_KEY",
865 "WANJIE_MAAS_API_KEY",
866 ],
867 "sakana" | "sakana-ai" | "sakana_ai" | "fugu" => &["FUGU_API_KEY", "SAKANA_API_KEY"],
868 "longcat" | "long-cat" | "meituan-longcat" | "meituan" => &["LONGCAT_API_KEY"],
869 _ => return None,
870 };
871 for var in candidates {
872 if let Ok(value) = std::env::var(var)
873 && !value.trim().is_empty()
874 {
875 return Some(value);
876 }
877 }
878 None
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884 use std::sync::{Mutex, OnceLock};
885
886 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
889 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
890 LOCK.get_or_init(|| Mutex::new(()))
891 .lock()
892 .unwrap_or_else(|p| p.into_inner())
893 }
894
895 fn clear_known_envs() {
896 for var in [
897 "CODEWHALE_HOME",
898 "DEEPSEEK_API_KEY",
899 "OPENROUTER_API_KEY",
900 "NOVITA_API_KEY",
901 "NVIDIA_API_KEY",
902 "NVIDIA_NIM_API_KEY",
903 "FIREWORKS_API_KEY",
904 "SILICONFLOW_API_KEY",
905 "ARCEE_API_KEY",
906 "SGLANG_API_KEY",
907 "VLLM_API_KEY",
908 "OLLAMA_API_KEY",
909 "OPENAI_API_KEY",
910 "ATLASCLOUD_API_KEY",
911 "WANJIE_ARK_API_KEY",
912 "WANJIE_API_KEY",
913 "WANJIE_MAAS_API_KEY",
914 "XIAOMI_MIMO_API_KEY",
915 "XIAOMI_API_KEY",
916 "MIMO_API_KEY",
917 "FUGU_API_KEY",
918 "SAKANA_API_KEY",
919 "LONGCAT_API_KEY",
920 SECRET_BACKEND_ENV,
921 LEGACY_SECRET_BACKEND_ENV,
922 ] {
923 unsafe { std::env::remove_var(var) };
926 }
927 }
928
929 struct EnvVarGuard {
930 name: &'static str,
931 previous: Option<std::ffi::OsString>,
932 }
933
934 impl EnvVarGuard {
935 fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
936 let previous = std::env::var_os(name);
937 unsafe { std::env::set_var(name, value) };
938 Self { name, previous }
939 }
940 }
941
942 impl Drop for EnvVarGuard {
943 fn drop(&mut self) {
944 match self.previous.take() {
945 Some(value) => unsafe { std::env::set_var(self.name, value) },
946 None => unsafe { std::env::remove_var(self.name) },
947 }
948 }
949 }
950
951 #[test]
952 fn backend_selection_defaults_to_file() {
953 assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
954 assert_eq!(
955 secret_backend_selection(Some("")),
956 SecretBackendSelection::File
957 );
958 assert_eq!(
959 secret_backend_selection(Some(" file ")),
960 SecretBackendSelection::File
961 );
962 }
963
964 #[test]
965 fn backend_selection_accepts_explicit_system_keyring() {
966 assert_eq!(
967 secret_backend_selection(Some("system")),
968 SecretBackendSelection::System
969 );
970 assert_eq!(
971 secret_backend_selection(Some("keyring")),
972 SecretBackendSelection::System
973 );
974 assert_eq!(
975 secret_backend_selection(Some("os-keyring")),
976 SecretBackendSelection::System
977 );
978 }
979
980 #[test]
981 fn auto_detect_is_file_backed_by_default() {
982 let _lock = env_lock();
983 clear_known_envs();
984 let tmp = tempfile::tempdir().unwrap();
985 let _home = EnvVarGuard::set("HOME", tmp.path());
986 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
987
988 let secrets = Secrets::auto_detect();
989
990 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
991 }
992
993 #[test]
994 fn auto_detect_honors_explicit_file_backend() {
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 unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
1002
1003 let secrets = Secrets::auto_detect();
1004
1005 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1006 unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
1008 }
1009
1010 #[test]
1011 fn auto_detect_honors_legacy_backend_env_alias() {
1012 let _lock = env_lock();
1013 clear_known_envs();
1014 let tmp = tempfile::tempdir().unwrap();
1015 let _home = EnvVarGuard::set("HOME", tmp.path());
1016 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1017 unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
1018
1019 let secrets = Secrets::auto_detect();
1020
1021 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1022 clear_known_envs();
1023 }
1024
1025 #[test]
1026 fn file_default_path_uses_codewhale_home() {
1027 let _lock = env_lock();
1028 clear_known_envs();
1029 let tmp = tempfile::tempdir().unwrap();
1030 let _home = EnvVarGuard::set("HOME", tmp.path());
1031 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1032
1033 let path = FileKeyringStore::default_path().unwrap();
1034
1035 assert_eq!(
1036 path,
1037 tmp.path()
1038 .join(".codewhale")
1039 .join("secrets")
1040 .join("secrets.json")
1041 );
1042 }
1043
1044 #[test]
1045 fn file_default_path_honors_codewhale_home() {
1046 let _lock = env_lock();
1047 clear_known_envs();
1048 let tmp = tempfile::tempdir().unwrap();
1049 let custom = tmp.path().join("custom-codewhale");
1050 let _home = EnvVarGuard::set("HOME", tmp.path());
1051 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1052 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
1053
1054 let path = FileKeyringStore::default_path().unwrap();
1055
1056 assert_eq!(path, custom.join("secrets").join("secrets.json"));
1057 }
1058
1059 #[test]
1060 fn file_default_path_migrates_legacy_entries_to_codewhale() {
1061 let _lock = env_lock();
1062 clear_known_envs();
1063 let tmp = tempfile::tempdir().unwrap();
1064 let _home = EnvVarGuard::set("HOME", tmp.path());
1065 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1066 let legacy = tmp
1067 .path()
1068 .join(".deepseek")
1069 .join("secrets")
1070 .join("secrets.json");
1071 FileKeyringStore::new(legacy.clone())
1072 .set("xiaomi-mimo", "legacy-mimo")
1073 .unwrap();
1074
1075 let primary = FileKeyringStore::default_path().unwrap();
1076 let primary_store = FileKeyringStore::new(primary.clone());
1077
1078 assert_eq!(
1079 primary,
1080 tmp.path()
1081 .join(".codewhale")
1082 .join("secrets")
1083 .join("secrets.json")
1084 );
1085 assert_eq!(
1086 primary_store.get("xiaomi-mimo").unwrap().as_deref(),
1087 Some("legacy-mimo")
1088 );
1089 assert!(
1090 legacy.exists(),
1091 "migration copies; it does not delete legacy data"
1092 );
1093 }
1094
1095 #[test]
1096 fn file_default_path_migration_preserves_primary_values() {
1097 let _lock = env_lock();
1098 clear_known_envs();
1099 let tmp = tempfile::tempdir().unwrap();
1100 let _home = EnvVarGuard::set("HOME", tmp.path());
1101 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1102 let legacy = tmp
1103 .path()
1104 .join(".deepseek")
1105 .join("secrets")
1106 .join("secrets.json");
1107 let primary = tmp
1108 .path()
1109 .join(".codewhale")
1110 .join("secrets")
1111 .join("secrets.json");
1112 FileKeyringStore::new(legacy)
1113 .set("openrouter", "legacy-openrouter")
1114 .unwrap();
1115 let primary_store = FileKeyringStore::new(primary.clone());
1116 primary_store
1117 .set("openrouter", "primary-openrouter")
1118 .unwrap();
1119
1120 let resolved = FileKeyringStore::default_path().unwrap();
1121
1122 assert_eq!(resolved, primary);
1123 assert_eq!(
1124 primary_store.get("openrouter").unwrap().as_deref(),
1125 Some("primary-openrouter")
1126 );
1127 }
1128
1129 #[test]
1130 fn in_memory_store_round_trips() {
1131 let store = InMemoryKeyringStore::new();
1132 assert_eq!(store.get("deepseek").unwrap(), None);
1133 store.set("deepseek", "sk-test").unwrap();
1134 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
1135 store.set("deepseek", "sk-replaced").unwrap();
1136 assert_eq!(
1137 store.get("deepseek").unwrap(),
1138 Some("sk-replaced".to_string())
1139 );
1140 store.delete("deepseek").unwrap();
1141 assert_eq!(store.get("deepseek").unwrap(), None);
1142 store.delete("missing").unwrap();
1144 }
1145
1146 #[test]
1147 fn resolve_prefers_keyring_over_env() {
1148 let _lock = env_lock();
1149 clear_known_envs();
1150 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1152
1153 let store = Arc::new(InMemoryKeyringStore::new());
1154 store.set("deepseek", "ring-key").unwrap();
1155 let secrets = Secrets::new(store);
1156
1157 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1158 assert_eq!(
1159 secrets.resolve_with_source("deepseek"),
1160 Some(("ring-key".to_string(), SecretSource::Keyring))
1161 );
1162 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1164 }
1165
1166 #[test]
1167 fn resolve_falls_back_to_env_when_keyring_empty() {
1168 let _lock = env_lock();
1169 clear_known_envs();
1170 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1172
1173 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1174 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1175 assert_eq!(
1176 secrets.resolve_with_source("deepseek"),
1177 Some(("env-fallback".to_string(), SecretSource::Env))
1178 );
1179 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1181 }
1182
1183 #[test]
1184 fn resolve_returns_none_when_both_layers_empty() {
1185 let _lock = env_lock();
1186 clear_known_envs();
1187 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1188 assert_eq!(secrets.resolve("deepseek"), None);
1189 }
1190
1191 #[test]
1192 fn resolve_treats_blank_keyring_value_as_unset() {
1193 let _lock = env_lock();
1194 clear_known_envs();
1195 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1197
1198 let store = Arc::new(InMemoryKeyringStore::new());
1199 store.set("deepseek", " ").unwrap();
1200 let secrets = Secrets::new(store);
1201 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1202 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1204 }
1205
1206 #[test]
1207 fn nvidia_env_aliases_resolve() {
1208 let _lock = env_lock();
1209 clear_known_envs();
1210 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1212 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1213 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1214 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1215 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1217 }
1218
1219 #[test]
1220 fn atlascloud_env_aliases_resolve() {
1221 let _guard = env_lock();
1222 clear_known_envs();
1223 unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1224
1225 assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1226 assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1227 assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1228
1229 clear_known_envs();
1230 }
1231
1232 #[test]
1233 fn sakana_env_aliases_resolve() {
1234 let _guard = env_lock();
1235 clear_known_envs();
1236 unsafe { std::env::set_var("FUGU_API_KEY", "fugu-key") };
1237
1238 assert_eq!(env_for("sakana").as_deref(), Some("fugu-key"));
1239 assert_eq!(env_for("sakana-ai").as_deref(), Some("fugu-key"));
1240 assert_eq!(env_for("sakana_ai").as_deref(), Some("fugu-key"));
1241 assert_eq!(env_for("fugu").as_deref(), Some("fugu-key"));
1242
1243 clear_known_envs();
1244 unsafe { std::env::set_var("SAKANA_API_KEY", "sakana-key") };
1245 assert_eq!(env_for("sakana").as_deref(), Some("sakana-key"));
1246
1247 clear_known_envs();
1248 }
1249
1250 #[test]
1251 fn wanjie_ark_env_aliases_resolve() {
1252 let _guard = env_lock();
1253 clear_known_envs();
1254 unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1255
1256 assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1257 assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1258 assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1259
1260 clear_known_envs();
1261 }
1262
1263 #[test]
1264 fn xiaomi_mimo_env_aliases_resolve() {
1265 let _guard = env_lock();
1266 clear_known_envs();
1267 unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1268
1269 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1270 assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1271 assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1272 assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1273
1274 clear_known_envs();
1275
1276 unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1277 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1278 clear_known_envs();
1279 }
1280
1281 #[test]
1282 fn fireworks_env_aliases_resolve() {
1283 let _lock = env_lock();
1284 clear_known_envs();
1285 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1287
1288 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1289 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1290 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1292 }
1293
1294 #[test]
1295 fn siliconflow_env_aliases_resolve() {
1296 let _lock = env_lock();
1297 clear_known_envs();
1298 unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
1300
1301 assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
1302 assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
1303 assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
1304 assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
1305 assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
1306 unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
1308 }
1309
1310 #[test]
1311 fn arcee_env_aliases_resolve() {
1312 let _lock = env_lock();
1313 clear_known_envs();
1314 unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
1316
1317 assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
1318 assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
1319 assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
1320 unsafe { std::env::remove_var("ARCEE_API_KEY") };
1322 }
1323
1324 #[test]
1325 fn moonshot_kimi_env_aliases_resolve() {
1326 let _lock = env_lock();
1327 clear_known_envs();
1328 unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
1330
1331 assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
1332 assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
1333 assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
1334 assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
1335 unsafe { std::env::remove_var("KIMI_API_KEY") };
1337 }
1338
1339 #[test]
1340 fn sglang_env_aliases_resolve() {
1341 let _lock = env_lock();
1342 clear_known_envs();
1343 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
1345
1346 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
1347 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
1348 unsafe { std::env::remove_var("SGLANG_API_KEY") };
1350 }
1351
1352 #[test]
1353 fn vllm_env_aliases_resolve() {
1354 let _lock = env_lock();
1355 clear_known_envs();
1356 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
1358
1359 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
1360 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
1361 unsafe { std::env::remove_var("VLLM_API_KEY") };
1363 }
1364
1365 #[test]
1366 fn ollama_env_aliases_resolve() {
1367 let _lock = env_lock();
1368 clear_known_envs();
1369 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
1371
1372 assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
1373 assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
1374 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
1376 }
1377
1378 #[cfg(unix)]
1379 #[test]
1380 fn file_store_round_trips_with_secure_perms() {
1381 use std::os::unix::fs::PermissionsExt;
1382
1383 let tmp = tempfile::tempdir().unwrap();
1384 let path = tmp.path().join("nested").join("secrets.json");
1385 let store = FileKeyringStore::new(path.clone());
1386 assert_eq!(store.get("deepseek").unwrap(), None);
1387 store.set("deepseek", "sk-disk").unwrap();
1388 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1389
1390 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1391 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
1392
1393 store.set("openrouter", "or-disk").unwrap();
1394 assert_eq!(
1395 store.get("openrouter").unwrap(),
1396 Some("or-disk".to_string())
1397 );
1398 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1400
1401 store.delete("deepseek").unwrap();
1402 assert_eq!(store.get("deepseek").unwrap(), None);
1403 }
1404
1405 #[cfg(unix)]
1406 #[test]
1407 fn file_store_rejects_world_readable_file() {
1408 use std::os::unix::fs::PermissionsExt;
1409 let tmp = tempfile::tempdir().unwrap();
1410 let path = tmp.path().join("secrets.json");
1411 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
1412 let mut perms = fs::metadata(&path).unwrap().permissions();
1413 perms.set_mode(0o644);
1414 fs::set_permissions(&path, perms).unwrap();
1415
1416 let store = FileKeyringStore::new(path);
1417 let err = store.get("deepseek").unwrap_err();
1418 assert!(
1419 matches!(err, SecretsError::InsecurePermissions { .. }),
1420 "unexpected error: {err}"
1421 );
1422 }
1423
1424 #[cfg(unix)]
1430 #[test]
1431 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
1432 use std::os::unix::fs::PermissionsExt;
1433 let tmp = tempfile::tempdir().unwrap();
1434 let path = tmp.path().join("secrets.json");
1435 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1436 fs::write(&path, original).unwrap();
1437 let mut perms = fs::metadata(&path).unwrap().permissions();
1438 perms.set_mode(0o644);
1439 fs::set_permissions(&path, perms).unwrap();
1440
1441 let store = FileKeyringStore::new(path.clone());
1442 let err = store.set("openrouter", "or-new").unwrap_err();
1443 assert!(
1444 matches!(err, SecretsError::InsecurePermissions { .. }),
1445 "set must surface the read error rather than overwriting; got: {err}"
1446 );
1447
1448 let on_disk = fs::read_to_string(&path).unwrap();
1449 assert_eq!(
1450 on_disk, original,
1451 "set must not modify the file when load_unlocked errored"
1452 );
1453 }
1454
1455 #[cfg(unix)]
1456 #[test]
1457 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
1458 use std::os::unix::fs::PermissionsExt;
1459 let tmp = tempfile::tempdir().unwrap();
1460 let path = tmp.path().join("secrets.json");
1461 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1462 fs::write(&path, original).unwrap();
1463 let mut perms = fs::metadata(&path).unwrap().permissions();
1464 perms.set_mode(0o644);
1465 fs::set_permissions(&path, perms).unwrap();
1466
1467 let store = FileKeyringStore::new(path.clone());
1468 let err = store.delete("nvidia").unwrap_err();
1469 assert!(
1470 matches!(err, SecretsError::InsecurePermissions { .. }),
1471 "delete must surface the read error rather than wiping the file; got: {err}"
1472 );
1473 let on_disk = fs::read_to_string(&path).unwrap();
1474 assert_eq!(on_disk, original);
1475 }
1476
1477 #[test]
1478 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
1479 let tmp = tempfile::tempdir().unwrap();
1480 let path = tmp.path().join("secrets.json");
1481 fs::write(&path, "{ this is not valid json").unwrap();
1484 #[cfg(unix)]
1485 {
1486 use std::os::unix::fs::PermissionsExt;
1487 let mut perms = fs::metadata(&path).unwrap().permissions();
1488 perms.set_mode(0o600);
1489 fs::set_permissions(&path, perms).unwrap();
1490 }
1491
1492 let store = FileKeyringStore::new(path.clone());
1493 let err = store.set("deepseek", "sk-new").unwrap_err();
1494 assert!(
1495 matches!(err, SecretsError::Json(_)),
1496 "set must surface the parse error rather than wiping the file; got: {err}"
1497 );
1498 let on_disk = fs::read_to_string(&path).unwrap();
1499 assert_eq!(on_disk, "{ this is not valid json");
1500 }
1501
1502 #[test]
1503 fn file_store_set_still_creates_file_when_missing() {
1504 let tmp = tempfile::tempdir().unwrap();
1509 let path = tmp.path().join("nested").join("secrets.json");
1510 let store = FileKeyringStore::new(path.clone());
1511
1512 store.set("deepseek", "sk-fresh").unwrap();
1513 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
1514 }
1515
1516 #[test]
1517 fn file_store_default_path_uses_home() {
1518 let _lock = env_lock();
1519 clear_known_envs();
1520 let tmp = tempfile::tempdir().unwrap();
1521 let _home = EnvVarGuard::set("HOME", tmp.path());
1522 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1523
1524 let path = FileKeyringStore::default_path().unwrap();
1525 assert_eq!(
1526 path,
1527 tmp.path()
1528 .join(".codewhale")
1529 .join("secrets")
1530 .join("secrets.json")
1531 );
1532 }
1533}