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 #[error("secret store is read-only")]
55 ReadOnly,
56}
57
58pub trait KeyringStore: Send + Sync {
67 fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
72
73 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
78
79 fn delete(&self, key: &str) -> Result<(), SecretsError>;
84
85 fn backend_name(&self) -> &'static str;
91}
92
93#[derive(Debug, Clone)]
106pub struct DefaultKeyringStore {
107 service: String,
110}
111
112impl Default for DefaultKeyringStore {
113 fn default() -> Self {
114 Self::new(DEFAULT_SERVICE)
115 }
116}
117
118impl DefaultKeyringStore {
119 #[must_use]
121 pub fn new(service: impl Into<String>) -> Self {
122 Self {
123 service: service.into(),
124 }
125 }
126
127 pub fn probe(&self) -> Result<(), SecretsError> {
130 #[cfg(any(
131 target_os = "macos",
132 target_os = "windows",
133 all(
134 target_os = "linux",
135 not(target_env = "ohos"),
136 not(target_env = "musl")
137 )
138 ))]
139 {
140 let entry = keyring::Entry::new(&self.service, "__probe__")
145 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
146 #[cfg(any(target_os = "macos", target_os = "windows"))]
147 {
148 let _ = entry;
149 Ok(())
150 }
151 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
152 match entry.get_password() {
153 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
154 Err(keyring::Error::PlatformFailure(err)) => {
155 Err(SecretsError::Keyring(format!("platform failure: {err}")))
156 }
157 Err(keyring::Error::NoStorageAccess(err)) => {
158 Err(SecretsError::Keyring(format!("no storage access: {err}")))
159 }
160 Err(other) => Err(SecretsError::Keyring(other.to_string())),
161 }
162 }
163 #[cfg(not(any(
164 target_os = "macos",
165 target_os = "windows",
166 all(
167 target_os = "linux",
168 not(target_env = "ohos"),
169 not(target_env = "musl")
170 )
171 )))]
172 {
173 let _ = &self.service;
174 Err(SecretsError::Keyring(unsupported_keyring_message()))
175 }
176 }
177}
178
179impl KeyringStore for DefaultKeyringStore {
180 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
181 #[cfg(any(
182 target_os = "macos",
183 target_os = "windows",
184 all(
185 target_os = "linux",
186 not(target_env = "ohos"),
187 not(target_env = "musl")
188 )
189 ))]
190 {
191 let entry = keyring::Entry::new(&self.service, key)
192 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
193 match entry.get_password() {
194 Ok(value) => Ok(Some(value)),
195 Err(keyring::Error::NoEntry) => Ok(None),
196 Err(err) => Err(SecretsError::Keyring(err.to_string())),
197 }
198 }
199 #[cfg(not(any(
200 target_os = "macos",
201 target_os = "windows",
202 all(
203 target_os = "linux",
204 not(target_env = "ohos"),
205 not(target_env = "musl")
206 )
207 )))]
208 {
209 let _ = key;
210 Err(SecretsError::Keyring(unsupported_keyring_message()))
211 }
212 }
213
214 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
215 #[cfg(any(
216 target_os = "macos",
217 target_os = "windows",
218 all(
219 target_os = "linux",
220 not(target_env = "ohos"),
221 not(target_env = "musl")
222 )
223 ))]
224 {
225 let entry = keyring::Entry::new(&self.service, key)
226 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
227 entry
228 .set_password(value)
229 .map_err(|err| SecretsError::Keyring(err.to_string()))
230 }
231 #[cfg(not(any(
232 target_os = "macos",
233 target_os = "windows",
234 all(
235 target_os = "linux",
236 not(target_env = "ohos"),
237 not(target_env = "musl")
238 )
239 )))]
240 {
241 let _ = (key, value);
242 Err(SecretsError::Keyring(unsupported_keyring_message()))
243 }
244 }
245
246 fn delete(&self, key: &str) -> Result<(), SecretsError> {
247 #[cfg(any(
248 target_os = "macos",
249 target_os = "windows",
250 all(
251 target_os = "linux",
252 not(target_env = "ohos"),
253 not(target_env = "musl")
254 )
255 ))]
256 {
257 let entry = keyring::Entry::new(&self.service, key)
258 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
259 match entry.delete_credential() {
260 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
261 Err(err) => Err(SecretsError::Keyring(err.to_string())),
262 }
263 }
264 #[cfg(not(any(
265 target_os = "macos",
266 target_os = "windows",
267 all(
268 target_os = "linux",
269 not(target_env = "ohos"),
270 not(target_env = "musl")
271 )
272 )))]
273 {
274 let _ = key;
275 Err(SecretsError::Keyring(unsupported_keyring_message()))
276 }
277 }
278
279 fn backend_name(&self) -> &'static str {
280 "system keyring"
281 }
282}
283
284#[cfg(not(any(
285 target_os = "macos",
286 target_os = "windows",
287 all(
288 target_os = "linux",
289 not(target_env = "ohos"),
290 not(target_env = "musl")
291 )
292)))]
293fn unsupported_keyring_message() -> String {
294 "system keyring backend is unsupported on this platform".to_string()
295}
296
297#[derive(Debug, Default)]
304pub struct InMemoryKeyringStore {
305 entries: Mutex<HashMap<String, String>>,
307}
308
309impl InMemoryKeyringStore {
310 #[must_use]
312 pub fn new() -> Self {
313 Self::default()
314 }
315}
316
317impl KeyringStore for InMemoryKeyringStore {
318 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
319 let guard = self.entries.lock().map_err(|e| {
320 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
321 })?;
322 Ok(guard.get(key).cloned())
323 }
324
325 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
326 let mut guard = self.entries.lock().map_err(|e| {
327 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
328 })?;
329 guard.insert(key.to_string(), value.to_string());
330 Ok(())
331 }
332
333 fn delete(&self, key: &str) -> Result<(), SecretsError> {
334 let mut guard = self.entries.lock().map_err(|e| {
335 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
336 })?;
337 guard.remove(key);
338 Ok(())
339 }
340
341 fn backend_name(&self) -> &'static str {
342 "in-memory (test)"
343 }
344}
345
346#[derive(Debug, Clone)]
359pub struct FileKeyringStore {
360 path: PathBuf,
362}
363
364#[derive(Debug, Clone)]
373struct ReadOnlyFileKeyringStore {
374 primary: FileKeyringStore,
375 legacy: Option<FileKeyringStore>,
378}
379
380#[derive(Debug, Default, Serialize, Deserialize)]
381struct FileSecretsBlob {
382 #[serde(default)]
383 entries: HashMap<String, String>,
384}
385
386impl FileKeyringStore {
387 #[must_use]
389 pub fn new(path: impl Into<PathBuf>) -> Self {
390 Self { path: path.into() }
391 }
392
393 pub fn default_path() -> Result<PathBuf, SecretsError> {
400 let primary = default_codewhale_secrets_path()?;
401 if !codewhale_home_is_explicit() {
404 match legacy_deepseek_secrets_path() {
405 Ok(legacy) => {
406 if let Err(err) = Self::migrate_legacy_file_if_needed(&primary, &legacy) {
407 tracing::warn!(
408 "could not migrate legacy secret store from {} to {}: {err}",
409 legacy.display(),
410 primary.display()
411 );
412 }
413 }
414 Err(err) => {
415 tracing::warn!("could not resolve legacy secret store path: {err}");
416 }
417 }
418 }
419 Ok(primary)
420 }
421
422 pub fn default_paths_read_only() -> Result<(PathBuf, Option<PathBuf>), SecretsError> {
429 let primary = default_codewhale_secrets_path()?;
430 let legacy = (!codewhale_home_is_explicit())
431 .then(legacy_deepseek_secrets_path)
432 .transpose()?;
433 Ok((primary, legacy))
434 }
435
436 fn migrate_legacy_file_if_needed(primary: &Path, legacy: &Path) -> Result<(), SecretsError> {
437 if !legacy.exists() {
438 return Ok(());
439 }
440
441 let legacy_store = Self::new(legacy.to_path_buf());
442 let legacy_blob = legacy_store.load_unlocked()?;
443 if legacy_blob.entries.is_empty() {
444 return Ok(());
445 }
446
447 let primary_store = Self::new(primary.to_path_buf());
448 let mut primary_blob = primary_store.load_unlocked()?;
449 let mut changed = false;
450 for (key, value) in legacy_blob.entries {
451 if let std::collections::hash_map::Entry::Vacant(entry) =
452 primary_blob.entries.entry(key)
453 {
454 entry.insert(value);
455 changed = true;
456 }
457 }
458 if changed {
459 primary_store.store_unlocked(&primary_blob)?;
460 }
461 Ok(())
462 }
463
464 fn home_dir() -> Result<PathBuf, SecretsError> {
465 for var in ["HOME", "USERPROFILE"] {
466 if let Ok(value) = std::env::var(var) {
467 let trimmed = value.trim();
468 if !trimmed.is_empty() {
469 return Ok(PathBuf::from(trimmed));
470 }
471 }
472 }
473
474 dirs::home_dir().ok_or_else(|| {
475 SecretsError::Io(std::io::Error::new(
476 std::io::ErrorKind::NotFound,
477 "could not resolve home directory for FileKeyringStore",
478 ))
479 })
480 }
481
482 #[must_use]
484 pub fn path(&self) -> &Path {
485 &self.path
486 }
487
488 fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
489 if !self.path.exists() {
490 return Ok(FileSecretsBlob::default());
491 }
492 #[cfg(unix)]
496 {
497 use std::os::unix::fs::PermissionsExt;
498 let meta = fs::metadata(&self.path)?;
499 let mode = meta.permissions().mode() & 0o777;
500 if mode & 0o077 != 0 {
501 return Err(SecretsError::InsecurePermissions {
502 path: self.path.clone(),
503 mode,
504 });
505 }
506 }
507 let raw = fs::read_to_string(&self.path)?;
508 if raw.trim().is_empty() {
509 return Ok(FileSecretsBlob::default());
510 }
511 let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
512 Ok(blob)
513 }
514
515 fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
516 if let Some(parent) = self.path.parent() {
517 fs::create_dir_all(parent)?;
518 #[cfg(unix)]
519 {
520 use std::os::unix::fs::PermissionsExt;
521 let mut perms = fs::metadata(parent)?.permissions();
522 perms.set_mode(0o700);
523 let _ = fs::set_permissions(parent, perms);
524 }
525 }
526 let body = serde_json::to_string_pretty(blob)?;
527 write_private_file(&self.path, body.as_bytes())?;
528 #[cfg(unix)]
529 {
530 use std::os::unix::fs::PermissionsExt;
531 if let Ok(meta) = fs::metadata(&self.path) {
538 let mut perms = meta.permissions();
539 perms.set_mode(0o600);
540 let _ = fs::set_permissions(&self.path, perms);
541 }
542 }
543 Ok(())
544 }
545}
546
547impl ReadOnlyFileKeyringStore {
548 fn default_for_diagnostics() -> Result<Self, SecretsError> {
549 let (primary, legacy) = FileKeyringStore::default_paths_read_only()?;
550 Ok(Self::new(primary, legacy))
551 }
552
553 fn new(primary: impl Into<PathBuf>, legacy: Option<PathBuf>) -> Self {
554 Self {
555 primary: FileKeyringStore::new(primary),
556 legacy: legacy.map(FileKeyringStore::new),
557 }
558 }
559}
560
561impl KeyringStore for ReadOnlyFileKeyringStore {
562 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
563 match self.primary.get(key)? {
564 Some(value) => Ok(Some(value)),
565 None => self
566 .legacy
567 .as_ref()
568 .map_or(Ok(None), |legacy| legacy.get(key)),
569 }
570 }
571
572 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
573 Err(SecretsError::ReadOnly)
574 }
575
576 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
577 Err(SecretsError::ReadOnly)
578 }
579
580 fn backend_name(&self) -> &'static str {
581 FILE_BACKEND_LABEL
582 }
583}
584
585#[derive(Clone)]
586struct ReadOnlyKeyringStore {
587 inner: Arc<dyn KeyringStore>,
588}
589
590impl ReadOnlyKeyringStore {
591 fn new(inner: Arc<dyn KeyringStore>) -> Self {
592 Self { inner }
593 }
594}
595
596impl KeyringStore for ReadOnlyKeyringStore {
597 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
598 self.inner.get(key)
599 }
600
601 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
602 Err(SecretsError::ReadOnly)
603 }
604
605 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
606 Err(SecretsError::ReadOnly)
607 }
608
609 fn backend_name(&self) -> &'static str {
610 self.inner.backend_name()
611 }
612}
613
614fn write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
615 atomic_write_private_file(path, body)
616}
617
618fn atomic_write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
619 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
620 fs::create_dir_all(parent)?;
621 }
622 let dir = path
623 .parent()
624 .filter(|p| !p.as_os_str().is_empty())
625 .unwrap_or_else(|| Path::new("."));
626 let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(SecretsError::Io)?;
627 use std::io::Write as _;
628 tmp.write_all(body).map_err(SecretsError::Io)?;
629 tmp.flush().map_err(SecretsError::Io)?;
630 tmp.as_file().sync_all().map_err(SecretsError::Io)?;
631 #[cfg(unix)]
632 {
633 use std::os::unix::fs::PermissionsExt;
634 let perms = fs::Permissions::from_mode(0o600);
635 tmp.as_file()
636 .set_permissions(perms)
637 .map_err(SecretsError::Io)?;
638 }
639 tmp.persist(path).map_err(|e| SecretsError::Io(e.error))?;
640 Ok(())
641}
642
643impl KeyringStore for FileKeyringStore {
644 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
645 let blob = self.load_unlocked()?;
646 Ok(blob.entries.get(key).cloned())
647 }
648
649 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
650 let mut blob = self.load_unlocked()?;
656 blob.entries.insert(key.to_string(), value.to_string());
657 self.store_unlocked(&blob)
658 }
659
660 fn delete(&self, key: &str) -> Result<(), SecretsError> {
661 let mut blob = self.load_unlocked()?;
664 blob.entries.remove(key);
665 self.store_unlocked(&blob)
666 }
667
668 fn backend_name(&self) -> &'static str {
669 FILE_BACKEND_LABEL
670 }
671}
672
673fn default_codewhale_secrets_path() -> Result<PathBuf, SecretsError> {
674 if let Ok(value) = std::env::var("CODEWHALE_HOME") {
675 let trimmed = value.trim();
676 if !trimmed.is_empty() {
677 return Ok(PathBuf::from(trimmed).join("secrets").join("secrets.json"));
678 }
679 }
680 Ok(FileKeyringStore::home_dir()?
681 .join(".codewhale")
682 .join("secrets")
683 .join("secrets.json"))
684}
685
686fn legacy_deepseek_secrets_path() -> Result<PathBuf, SecretsError> {
687 Ok(FileKeyringStore::home_dir()?
688 .join(".deepseek")
689 .join("secrets")
690 .join("secrets.json"))
691}
692
693fn codewhale_home_is_explicit() -> bool {
696 std::env::var("CODEWHALE_HOME").is_ok_and(|value| !value.trim().is_empty())
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700enum SecretBackendSelection {
701 File,
702 System,
703 Unknown,
704}
705
706fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
707 match value.map(str::trim).filter(|value| !value.is_empty()) {
708 None => SecretBackendSelection::File,
709 Some(value) => match value.to_ascii_lowercase().as_str() {
710 "file" | "local" | "json" => SecretBackendSelection::File,
711 "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
712 _ => SecretBackendSelection::Unknown,
713 },
714 }
715}
716
717fn configured_secret_backend() -> Option<String> {
718 std::env::var(SECRET_BACKEND_ENV)
719 .ok()
720 .filter(|value| !value.trim().is_empty())
721 .or_else(|| std::env::var(LEGACY_SECRET_BACKEND_ENV).ok())
722}
723
724#[derive(Clone)]
741pub struct Secrets {
742 pub store: Arc<dyn KeyringStore>,
744 service: String,
749}
750
751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757pub enum SecretSource {
758 Keyring,
760 Env,
762}
763
764impl std::fmt::Debug for Secrets {
765 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
766 f.debug_struct("Secrets")
767 .field("backend", &self.store.backend_name())
768 .field("service", &self.service)
769 .finish()
770 }
771}
772
773impl Secrets {
774 #[must_use]
777 pub fn new(store: Arc<dyn KeyringStore>) -> Self {
778 Self {
779 store,
780 service: DEFAULT_SERVICE.to_string(),
781 }
782 }
783
784 pub fn auto_detect() -> Self {
795 match secret_backend_selection(configured_secret_backend().as_deref()) {
796 SecretBackendSelection::File => Self::file_backed_default(),
797 SecretBackendSelection::Unknown => {
798 tracing::warn!(
799 "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
800 );
801 Self::file_backed_default()
802 }
803 SecretBackendSelection::System => {
804 let default_store = DefaultKeyringStore::default();
805 match default_store.probe() {
806 Ok(()) => Self::new(Arc::new(default_store)),
807 Err(err) => {
808 tracing::warn!(
809 "OS keyring unavailable ({err}); falling back to file-backed secret store"
810 );
811 Self::file_backed_default()
812 }
813 }
814 }
815 }
816 }
817
818 #[must_use]
827 pub fn auto_detect_read_only() -> Self {
828 match secret_backend_selection(configured_secret_backend().as_deref()) {
829 SecretBackendSelection::File => Self::file_backed_read_only(),
830 SecretBackendSelection::Unknown => {
831 tracing::warn!(
832 "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
833 );
834 Self::file_backed_read_only()
835 }
836 SecretBackendSelection::System => {
837 let default_store = DefaultKeyringStore::default();
838 match default_store.probe() {
839 Ok(()) => {
840 Self::new(Arc::new(ReadOnlyKeyringStore::new(Arc::new(default_store))))
841 }
842 Err(err) => {
843 tracing::warn!(
844 "OS keyring unavailable ({err}); falling back to file-backed secret store"
845 );
846 Self::file_backed_read_only()
847 }
848 }
849 }
850 }
851 }
852
853 fn file_backed_default() -> Self {
854 Self::file_backed_from_default_path(FileKeyringStore::default_path())
855 }
856
857 fn file_backed_from_default_path(path_result: Result<PathBuf, SecretsError>) -> Self {
863 match path_result {
867 Ok(path) if path.is_absolute() => Self::new(Arc::new(FileKeyringStore::new(path))),
868 Ok(path) => {
869 tracing::error!(
870 "refusing relative file-backed secret path {}; credentials will not be read or persisted",
871 path.display()
872 );
873 Self::read_only_empty_store()
874 }
875 Err(err) => {
876 tracing::error!(
877 "could not resolve file-backed secret path ({err}); credentials will not be read or persisted"
878 );
879 Self::read_only_empty_store()
880 }
881 }
882 }
883
884 fn read_only_empty_store() -> Self {
888 Self::new(Arc::new(ReadOnlyKeyringStore::new(Arc::new(
889 InMemoryKeyringStore::new(),
890 ))))
891 }
892
893 #[must_use]
902 pub fn file_backed_read_only() -> Self {
903 let store = ReadOnlyFileKeyringStore::default_for_diagnostics().unwrap_or_else(|_| {
904 ReadOnlyFileKeyringStore::new(PathBuf::from(".codewhale-secrets.json"), None)
905 });
906 Self::new(Arc::new(store))
907 }
908
909 #[must_use]
911 pub fn file_backed() -> Self {
912 Self::file_backed_default()
913 }
914
915 #[must_use]
918 pub fn system_keyring() -> Self {
919 let default_store = DefaultKeyringStore::default();
920 match default_store.probe() {
921 Ok(()) => Self::new(Arc::new(default_store)),
922 Err(err) => {
923 tracing::warn!(
924 "OS keyring unavailable ({err}); falling back to file-backed secret store"
925 );
926 Self::file_backed_default()
927 }
928 }
929 }
930
931 #[must_use]
933 pub fn backend_name(&self) -> &'static str {
934 self.store.backend_name()
935 }
936
937 #[must_use]
942 pub fn resolve(&self, name: &str) -> Option<String> {
943 self.resolve_with_source(name).map(|(value, _)| value)
944 }
945
946 #[must_use]
948 pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
949 if let Ok(Some(v)) = self.store.get(name)
950 && !v.trim().is_empty()
951 {
952 return Some((v, SecretSource::Keyring));
953 }
954 env_for(name).map(|value| (value, SecretSource::Env))
955 }
956
957 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
959 self.store.set(name, value)
960 }
961
962 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
964 self.store.delete(name)
965 }
966
967 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
969 self.store.get(name)
970 }
971
972 #[must_use]
984 pub fn resolve_direct(&self, key: &str, source_hint: Option<&str>) -> Option<String> {
985 match source_hint {
986 Some("env") => {
987 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
989 }
990 Some("keyring") | Some("file") => {
991 self.store
993 .get(key)
994 .ok()
995 .flatten()
996 .filter(|v| !v.trim().is_empty())
997 }
998 Some(_) | None => {
999 if let Ok(Some(v)) = self.store.get(key)
1001 && !v.trim().is_empty()
1002 {
1003 return Some(v);
1004 }
1005 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
1006 }
1007 }
1008 }
1009}
1010
1011#[must_use]
1044pub fn env_for(name: &str) -> Option<String> {
1045 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
1046 "deepseek" => &["DEEPSEEK_API_KEY"],
1047 "openrouter" => &["OPENROUTER_API_KEY"],
1048 "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
1049 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
1050 }
1051 "novita" | "novita-ai" | "novita_ai" => &["NOVITA_API_KEY"],
1052 "together" | "together-ai" | "together_ai" | "togetherai" => &["TOGETHER_API_KEY"],
1053 "deepinfra" | "deep-infra" | "deep_infra" => &["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
1054 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
1058 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
1059 }
1060 "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
1061 "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
1062 | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
1063 "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
1064 "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
1065 "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
1066 "vllm" | "v-llm" => &["VLLM_API_KEY"],
1067 "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
1068 "openai" => &["OPENAI_API_KEY"],
1069 "anthropic" | "claude" => &["ANTHROPIC_API_KEY"],
1070 "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
1071 "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
1072 | "volcengineark" => &[
1073 "VOLCENGINE_API_KEY",
1074 "VOLCENGINE_ARK_API_KEY",
1075 "ARK_API_KEY",
1076 ],
1077 "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
1078 | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
1079 "WANJIE_ARK_API_KEY",
1080 "WANJIE_API_KEY",
1081 "WANJIE_MAAS_API_KEY",
1082 ],
1083 "sakana" | "sakana-ai" | "sakana_ai" | "fugu" => &["FUGU_API_KEY", "SAKANA_API_KEY"],
1084 "longcat" | "long-cat" | "meituan-longcat" | "meituan" => &["LONGCAT_API_KEY"],
1085 "opencode-go" | "opencode_go" | "opencodego" => &["OPENCODE_GO_API_KEY"],
1086 "opencode-zen" | "opencode_zen" | "opencodezen" | "zen" | "opencode" => {
1087 &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"]
1088 }
1089 "meta" | "meta-ai" | "meta_ai" | "meta-model-api" | "meta_model_api" | "muse"
1090 | "muse-spark" => &["META_MODEL_API_KEY", "MODEL_API_KEY"],
1091 "xai" | "x-ai" | "x_ai" | "grok" => &["XAI_API_KEY"],
1092 "telecomjs" | "telecom-js" | "telecom_js" | "telecomjs-cn" | "tokenhub" => {
1093 &["TELECOMJS_API_KEY"]
1094 }
1095 _ => return None,
1096 };
1097 for var in candidates {
1098 if let Ok(value) = std::env::var(var)
1099 && !value.trim().is_empty()
1100 {
1101 return Some(value);
1102 }
1103 }
1104 None
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109 use super::*;
1110 use std::sync::{Mutex, OnceLock};
1111
1112 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1115 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1116 LOCK.get_or_init(|| Mutex::new(()))
1117 .lock()
1118 .unwrap_or_else(|p| p.into_inner())
1119 }
1120
1121 fn clear_known_envs() {
1122 for var in [
1123 "CODEWHALE_HOME",
1124 "DEEPSEEK_API_KEY",
1125 "OPENROUTER_API_KEY",
1126 "NOVITA_API_KEY",
1127 "NVIDIA_API_KEY",
1128 "NVIDIA_NIM_API_KEY",
1129 "FIREWORKS_API_KEY",
1130 "TOGETHER_API_KEY",
1131 "DEEPINFRA_API_KEY",
1132 "DEEPINFRA_TOKEN",
1133 "SILICONFLOW_API_KEY",
1134 "ARCEE_API_KEY",
1135 "SGLANG_API_KEY",
1136 "VLLM_API_KEY",
1137 "OLLAMA_API_KEY",
1138 "OPENAI_API_KEY",
1139 "ATLASCLOUD_API_KEY",
1140 "WANJIE_ARK_API_KEY",
1141 "WANJIE_API_KEY",
1142 "WANJIE_MAAS_API_KEY",
1143 "XIAOMI_MIMO_API_KEY",
1144 "XIAOMI_API_KEY",
1145 "MIMO_API_KEY",
1146 "FUGU_API_KEY",
1147 "SAKANA_API_KEY",
1148 "LONGCAT_API_KEY",
1149 "OPENCODE_GO_API_KEY",
1150 "OPENCODE_ZEN_API_KEY",
1151 "OPENCODE_API_KEY",
1152 "META_MODEL_API_KEY",
1153 "MODEL_API_KEY",
1154 "XAI_API_KEY",
1155 "TELECOMJS_API_KEY",
1156 SECRET_BACKEND_ENV,
1157 LEGACY_SECRET_BACKEND_ENV,
1158 ] {
1159 unsafe { std::env::remove_var(var) };
1162 }
1163 }
1164
1165 struct EnvVarGuard {
1166 name: &'static str,
1167 previous: Option<std::ffi::OsString>,
1168 }
1169
1170 impl EnvVarGuard {
1171 fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
1172 let previous = std::env::var_os(name);
1173 unsafe { std::env::set_var(name, value) };
1174 Self { name, previous }
1175 }
1176 }
1177
1178 impl Drop for EnvVarGuard {
1179 fn drop(&mut self) {
1180 match self.previous.take() {
1181 Some(value) => unsafe { std::env::set_var(self.name, value) },
1182 None => unsafe { std::env::remove_var(self.name) },
1183 }
1184 }
1185 }
1186
1187 #[test]
1188 fn backend_selection_defaults_to_file() {
1189 assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
1190 assert_eq!(
1191 secret_backend_selection(Some("")),
1192 SecretBackendSelection::File
1193 );
1194 assert_eq!(
1195 secret_backend_selection(Some(" file ")),
1196 SecretBackendSelection::File
1197 );
1198 }
1199
1200 #[test]
1201 fn backend_selection_accepts_explicit_system_keyring() {
1202 assert_eq!(
1203 secret_backend_selection(Some("system")),
1204 SecretBackendSelection::System
1205 );
1206 assert_eq!(
1207 secret_backend_selection(Some("keyring")),
1208 SecretBackendSelection::System
1209 );
1210 assert_eq!(
1211 secret_backend_selection(Some("os-keyring")),
1212 SecretBackendSelection::System
1213 );
1214 }
1215
1216 #[test]
1217 fn auto_detect_is_file_backed_by_default() {
1218 let _lock = env_lock();
1219 clear_known_envs();
1220 let tmp = tempfile::tempdir().unwrap();
1221 let _home = EnvVarGuard::set("HOME", tmp.path());
1222 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1223
1224 let secrets = Secrets::auto_detect();
1225
1226 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1227 }
1228
1229 #[test]
1230 fn auto_detect_honors_explicit_file_backend() {
1231 let _lock = env_lock();
1232 clear_known_envs();
1233 let tmp = tempfile::tempdir().unwrap();
1234 let _home = EnvVarGuard::set("HOME", tmp.path());
1235 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1236 unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
1238
1239 let secrets = Secrets::auto_detect();
1240
1241 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1242 unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
1244 }
1245
1246 #[test]
1247 fn read_only_auto_detect_reads_legacy_without_migrating_or_allowing_writes() {
1248 let _lock = env_lock();
1249 clear_known_envs();
1250 let tmp = tempfile::tempdir().unwrap();
1251 let _home = EnvVarGuard::set("HOME", tmp.path());
1252 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1253 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1254 let legacy = tmp
1255 .path()
1256 .join(".deepseek")
1257 .join("secrets")
1258 .join("secrets.json");
1259 let primary = tmp
1260 .path()
1261 .join(".codewhale")
1262 .join("secrets")
1263 .join("secrets.json");
1264 FileKeyringStore::new(&legacy)
1265 .set("moonshot", "fixture-legacy-value")
1266 .unwrap();
1267
1268 let secrets = Secrets::auto_detect_read_only();
1269
1270 assert_eq!(
1271 secrets.get("moonshot").unwrap().as_deref(),
1272 Some("fixture-legacy-value")
1273 );
1274 assert!(
1275 !primary.exists(),
1276 "diagnostic lookup must not migrate the legacy store"
1277 );
1278 assert!(
1279 matches!(
1280 secrets.set("moonshot", "replacement"),
1281 Err(SecretsError::ReadOnly)
1282 ),
1283 "the diagnostic secret facade must refuse writes"
1284 );
1285 assert!(
1286 !primary.exists(),
1287 "a refused diagnostic write must not create the primary store"
1288 );
1289 }
1290
1291 #[test]
1292 fn read_only_auto_detect_respects_explicit_codewhale_home_isolation() {
1293 let _lock = env_lock();
1294 clear_known_envs();
1295 let tmp = tempfile::tempdir().unwrap();
1296 let codewhale_home = tmp.path().join("isolated-codewhale-home");
1297 let _home = EnvVarGuard::set("HOME", tmp.path());
1298 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1299 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1300 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1301 let legacy = tmp
1302 .path()
1303 .join(".deepseek")
1304 .join("secrets")
1305 .join("secrets.json");
1306 let primary = codewhale_home.join("secrets").join("secrets.json");
1307 FileKeyringStore::new(&legacy)
1308 .set("deepseek", "synthetic-ambient-legacy-value")
1309 .unwrap();
1310
1311 let secrets = Secrets::auto_detect_read_only();
1312
1313 assert_eq!(
1314 secrets.get("deepseek").unwrap(),
1315 None,
1316 "an explicit CODEWHALE_HOME must not read ambient legacy secrets"
1317 );
1318 assert!(
1319 !primary.exists(),
1320 "diagnostic lookup must not create an isolated primary store"
1321 );
1322 assert!(
1323 matches!(
1324 secrets.set("deepseek", "replacement"),
1325 Err(SecretsError::ReadOnly)
1326 ),
1327 "the isolated diagnostic facade must refuse writes"
1328 );
1329 assert!(
1330 !primary.exists(),
1331 "a refused isolated diagnostic write must not create the primary store"
1332 );
1333 }
1334
1335 #[test]
1336 fn read_only_auto_detect_reads_the_explicit_primary_store() {
1337 let _lock = env_lock();
1338 clear_known_envs();
1339 let tmp = tempfile::tempdir().unwrap();
1340 let codewhale_home = tmp.path().join("isolated-codewhale-home");
1341 let _home = EnvVarGuard::set("HOME", tmp.path());
1342 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1343 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1344 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1345 let primary = codewhale_home.join("secrets").join("secrets.json");
1346 FileKeyringStore::new(&primary)
1347 .set("deepseek", "synthetic-isolated-primary-value")
1348 .unwrap();
1349
1350 let secrets = Secrets::auto_detect_read_only();
1351
1352 assert_eq!(
1353 secrets.get("deepseek").unwrap().as_deref(),
1354 Some("synthetic-isolated-primary-value")
1355 );
1356 }
1357
1358 #[test]
1359 fn auto_detect_honors_legacy_backend_env_alias() {
1360 let _lock = env_lock();
1361 clear_known_envs();
1362 let tmp = tempfile::tempdir().unwrap();
1363 let _home = EnvVarGuard::set("HOME", tmp.path());
1364 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1365 unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
1366
1367 let secrets = Secrets::auto_detect();
1368
1369 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1370 clear_known_envs();
1371 }
1372
1373 #[test]
1374 fn file_default_path_uses_codewhale_home() {
1375 let _lock = env_lock();
1376 clear_known_envs();
1377 let tmp = tempfile::tempdir().unwrap();
1378 let _home = EnvVarGuard::set("HOME", tmp.path());
1379 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1380
1381 let path = FileKeyringStore::default_path().unwrap();
1382
1383 assert_eq!(
1384 path,
1385 tmp.path()
1386 .join(".codewhale")
1387 .join("secrets")
1388 .join("secrets.json")
1389 );
1390 }
1391
1392 #[test]
1393 fn file_default_path_honors_codewhale_home() {
1394 let _lock = env_lock();
1395 clear_known_envs();
1396 let tmp = tempfile::tempdir().unwrap();
1397 let custom = tmp.path().join("custom-codewhale");
1398 let _home = EnvVarGuard::set("HOME", tmp.path());
1399 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1400 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
1401
1402 let path = FileKeyringStore::default_path().unwrap();
1403
1404 assert_eq!(path, custom.join("secrets").join("secrets.json"));
1405 }
1406
1407 #[test]
1408 fn file_default_path_migrates_legacy_entries_to_codewhale() {
1409 let _lock = env_lock();
1410 clear_known_envs();
1411 let tmp = tempfile::tempdir().unwrap();
1412 let _home = EnvVarGuard::set("HOME", tmp.path());
1413 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1414 let legacy = tmp
1415 .path()
1416 .join(".deepseek")
1417 .join("secrets")
1418 .join("secrets.json");
1419 FileKeyringStore::new(legacy.clone())
1420 .set("xiaomi-mimo", "legacy-mimo")
1421 .unwrap();
1422
1423 let primary = FileKeyringStore::default_path().unwrap();
1424 let primary_store = FileKeyringStore::new(primary.clone());
1425
1426 assert_eq!(
1427 primary,
1428 tmp.path()
1429 .join(".codewhale")
1430 .join("secrets")
1431 .join("secrets.json")
1432 );
1433 assert_eq!(
1434 primary_store.get("xiaomi-mimo").unwrap().as_deref(),
1435 Some("legacy-mimo")
1436 );
1437 assert!(
1438 legacy.exists(),
1439 "migration copies; it does not delete legacy data"
1440 );
1441 }
1442
1443 #[test]
1444 fn file_default_path_migration_preserves_primary_values() {
1445 let _lock = env_lock();
1446 clear_known_envs();
1447 let tmp = tempfile::tempdir().unwrap();
1448 let _home = EnvVarGuard::set("HOME", tmp.path());
1449 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1450 let legacy = tmp
1451 .path()
1452 .join(".deepseek")
1453 .join("secrets")
1454 .join("secrets.json");
1455 let primary = tmp
1456 .path()
1457 .join(".codewhale")
1458 .join("secrets")
1459 .join("secrets.json");
1460 FileKeyringStore::new(legacy)
1461 .set("openrouter", "legacy-openrouter")
1462 .unwrap();
1463 let primary_store = FileKeyringStore::new(primary.clone());
1464 primary_store
1465 .set("openrouter", "primary-openrouter")
1466 .unwrap();
1467
1468 let resolved = FileKeyringStore::default_path().unwrap();
1469
1470 assert_eq!(resolved, primary);
1471 assert_eq!(
1472 primary_store.get("openrouter").unwrap().as_deref(),
1473 Some("primary-openrouter")
1474 );
1475 }
1476
1477 #[test]
1478 fn in_memory_store_round_trips() {
1479 let store = InMemoryKeyringStore::new();
1480 assert_eq!(store.get("deepseek").unwrap(), None);
1481 store.set("deepseek", "sk-test").unwrap();
1482 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
1483 store.set("deepseek", "sk-replaced").unwrap();
1484 assert_eq!(
1485 store.get("deepseek").unwrap(),
1486 Some("sk-replaced".to_string())
1487 );
1488 store.delete("deepseek").unwrap();
1489 assert_eq!(store.get("deepseek").unwrap(), None);
1490 store.delete("missing").unwrap();
1492 }
1493
1494 #[test]
1495 fn resolve_prefers_keyring_over_env() {
1496 let _lock = env_lock();
1497 clear_known_envs();
1498 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1500
1501 let store = Arc::new(InMemoryKeyringStore::new());
1502 store.set("deepseek", "ring-key").unwrap();
1503 let secrets = Secrets::new(store);
1504
1505 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1506 assert_eq!(
1507 secrets.resolve_with_source("deepseek"),
1508 Some(("ring-key".to_string(), SecretSource::Keyring))
1509 );
1510 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1512 }
1513
1514 #[test]
1515 fn resolve_falls_back_to_env_when_keyring_empty() {
1516 let _lock = env_lock();
1517 clear_known_envs();
1518 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1520
1521 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1522 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1523 assert_eq!(
1524 secrets.resolve_with_source("deepseek"),
1525 Some(("env-fallback".to_string(), SecretSource::Env))
1526 );
1527 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1529 }
1530
1531 #[test]
1532 fn resolve_returns_none_when_both_layers_empty() {
1533 let _lock = env_lock();
1534 clear_known_envs();
1535 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1536 assert_eq!(secrets.resolve("deepseek"), None);
1537 }
1538
1539 #[test]
1540 fn resolve_treats_blank_keyring_value_as_unset() {
1541 let _lock = env_lock();
1542 clear_known_envs();
1543 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1545
1546 let store = Arc::new(InMemoryKeyringStore::new());
1547 store.set("deepseek", " ").unwrap();
1548 let secrets = Secrets::new(store);
1549 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1550 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1552 }
1553
1554 #[test]
1555 fn nvidia_env_aliases_resolve() {
1556 let _lock = env_lock();
1557 clear_known_envs();
1558 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1560 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1561 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1562 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1563 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1565 }
1566
1567 #[test]
1568 fn atlascloud_env_aliases_resolve() {
1569 let _guard = env_lock();
1570 clear_known_envs();
1571 unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1572
1573 assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1574 assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1575 assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1576
1577 clear_known_envs();
1578 }
1579
1580 #[test]
1581 fn sakana_env_aliases_resolve() {
1582 let _guard = env_lock();
1583 clear_known_envs();
1584 unsafe { std::env::set_var("FUGU_API_KEY", "fugu-key") };
1585
1586 assert_eq!(env_for("sakana").as_deref(), Some("fugu-key"));
1587 assert_eq!(env_for("sakana-ai").as_deref(), Some("fugu-key"));
1588 assert_eq!(env_for("sakana_ai").as_deref(), Some("fugu-key"));
1589 assert_eq!(env_for("fugu").as_deref(), Some("fugu-key"));
1590
1591 clear_known_envs();
1592 unsafe { std::env::set_var("SAKANA_API_KEY", "sakana-key") };
1593 assert_eq!(env_for("sakana").as_deref(), Some("sakana-key"));
1594
1595 clear_known_envs();
1596 }
1597
1598 #[test]
1599 fn wanjie_ark_env_aliases_resolve() {
1600 let _guard = env_lock();
1601 clear_known_envs();
1602 unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1603
1604 assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1605 assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1606 assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1607
1608 clear_known_envs();
1609 }
1610
1611 #[test]
1612 fn xai_env_aliases_resolve() {
1613 let _guard = env_lock();
1614 clear_known_envs();
1615 unsafe { std::env::set_var("XAI_API_KEY", "xai-key") };
1616
1617 assert_eq!(env_for("xai").as_deref(), Some("xai-key"));
1618 assert_eq!(env_for("x-ai").as_deref(), Some("xai-key"));
1619 assert_eq!(env_for("x_ai").as_deref(), Some("xai-key"));
1620 assert_eq!(env_for("grok").as_deref(), Some("xai-key"));
1621
1622 clear_known_envs();
1623 }
1624
1625 #[test]
1626 fn telecomjs_env_aliases_resolve() {
1627 let _guard = env_lock();
1628 clear_known_envs();
1629 unsafe { std::env::set_var("TELECOMJS_API_KEY", "telecom-key") };
1630
1631 for alias in [
1632 "telecomjs",
1633 "telecom-js",
1634 "telecom_js",
1635 "telecomjs-cn",
1636 "tokenhub",
1637 ] {
1638 assert_eq!(env_for(alias).as_deref(), Some("telecom-key"), "{alias}");
1639 }
1640
1641 clear_known_envs();
1642 }
1643
1644 #[test]
1645 fn opencode_go_env_aliases_resolve() {
1646 let _guard = env_lock();
1647 clear_known_envs();
1648 unsafe { std::env::set_var("OPENCODE_GO_API_KEY", "go-key") };
1649
1650 for alias in ["opencode-go", "opencode_go", "opencodego"] {
1651 assert_eq!(env_for(alias).as_deref(), Some("go-key"), "{alias}");
1652 }
1653
1654 clear_known_envs();
1655 }
1656
1657 #[test]
1658 fn opencode_zen_env_aliases_resolve() {
1659 let _guard = env_lock();
1660 clear_known_envs();
1661 unsafe { std::env::set_var("OPENCODE_ZEN_API_KEY", "zen-key") };
1662
1663 for alias in [
1664 "opencode-zen",
1665 "opencode_zen",
1666 "opencodezen",
1667 "zen",
1668 "opencode",
1669 ] {
1670 assert_eq!(env_for(alias).as_deref(), Some("zen-key"), "{alias}");
1671 }
1672
1673 clear_known_envs();
1674 }
1675
1676 #[test]
1677 fn meta_model_api_env_aliases_resolve() {
1678 let _guard = env_lock();
1679 clear_known_envs();
1680 unsafe { std::env::set_var("MODEL_API_KEY", "meta-key") };
1681
1682 for alias in [
1683 "meta",
1684 "meta-ai",
1685 "meta_ai",
1686 "meta-model-api",
1687 "meta_model_api",
1688 "muse",
1689 "muse-spark",
1690 ] {
1691 assert_eq!(env_for(alias).as_deref(), Some("meta-key"), "{alias}");
1692 }
1693
1694 clear_known_envs();
1695 unsafe { std::env::set_var("META_MODEL_API_KEY", "meta-prefixed-key") };
1696 assert_eq!(env_for("meta").as_deref(), Some("meta-prefixed-key"),);
1697
1698 clear_known_envs();
1699 }
1700
1701 #[test]
1702 fn xiaomi_mimo_env_aliases_resolve() {
1703 let _guard = env_lock();
1704 clear_known_envs();
1705 unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1706
1707 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1708 assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1709 assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1710 assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1711
1712 clear_known_envs();
1713
1714 unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1715 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1716 clear_known_envs();
1717 }
1718
1719 #[test]
1720 fn fireworks_env_aliases_resolve() {
1721 let _lock = env_lock();
1722 clear_known_envs();
1723 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1725
1726 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1727 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1728 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1730 }
1731
1732 #[test]
1733 fn together_env_aliases_resolve() {
1734 let _lock = env_lock();
1735 clear_known_envs();
1736 unsafe { std::env::set_var("TOGETHER_API_KEY", "together-key") };
1738
1739 assert_eq!(env_for("together").as_deref(), Some("together-key"));
1742 assert_eq!(env_for("together-ai").as_deref(), Some("together-key"));
1743 assert_eq!(env_for("together_ai").as_deref(), Some("together-key"));
1744 assert_eq!(env_for("togetherai").as_deref(), Some("together-key"));
1745 unsafe { std::env::remove_var("TOGETHER_API_KEY") };
1747 }
1748
1749 #[test]
1750 fn deepinfra_env_aliases_resolve() {
1751 let _lock = env_lock();
1752 clear_known_envs();
1753 unsafe { std::env::set_var("DEEPINFRA_API_KEY", "di-key") };
1755
1756 assert_eq!(env_for("deepinfra").as_deref(), Some("di-key"));
1757 assert_eq!(env_for("deep-infra").as_deref(), Some("di-key"));
1758 assert_eq!(env_for("deep_infra").as_deref(), Some("di-key"));
1759 unsafe { std::env::remove_var("DEEPINFRA_API_KEY") };
1761
1762 unsafe { std::env::set_var("DEEPINFRA_TOKEN", "di-token") };
1765 assert_eq!(env_for("deepinfra").as_deref(), Some("di-token"));
1766 unsafe { std::env::remove_var("DEEPINFRA_TOKEN") };
1768 }
1769
1770 #[test]
1771 fn novita_env_aliases_resolve() {
1772 let _lock = env_lock();
1773 clear_known_envs();
1774 unsafe { std::env::set_var("NOVITA_API_KEY", "novita-key") };
1776
1777 assert_eq!(env_for("novita").as_deref(), Some("novita-key"));
1778 assert_eq!(env_for("novita-ai").as_deref(), Some("novita-key"));
1780 assert_eq!(env_for("novita_ai").as_deref(), Some("novita-key"));
1781 unsafe { std::env::remove_var("NOVITA_API_KEY") };
1783 }
1784
1785 #[test]
1786 fn siliconflow_env_aliases_resolve() {
1787 let _lock = env_lock();
1788 clear_known_envs();
1789 unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
1791
1792 assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
1793 assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
1794 assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
1795 assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
1796 assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
1797 unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
1799 }
1800
1801 #[test]
1802 fn arcee_env_aliases_resolve() {
1803 let _lock = env_lock();
1804 clear_known_envs();
1805 unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
1807
1808 assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
1809 assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
1810 assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
1811 unsafe { std::env::remove_var("ARCEE_API_KEY") };
1813 }
1814
1815 #[test]
1816 fn moonshot_kimi_env_aliases_resolve() {
1817 let _lock = env_lock();
1818 clear_known_envs();
1819 unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
1821
1822 assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
1823 assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
1824 assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
1825 assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
1826 unsafe { std::env::remove_var("KIMI_API_KEY") };
1828 }
1829
1830 #[test]
1831 fn sglang_env_aliases_resolve() {
1832 let _lock = env_lock();
1833 clear_known_envs();
1834 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
1836
1837 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
1838 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
1839 unsafe { std::env::remove_var("SGLANG_API_KEY") };
1841 }
1842
1843 #[test]
1844 fn vllm_env_aliases_resolve() {
1845 let _lock = env_lock();
1846 clear_known_envs();
1847 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
1849
1850 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
1851 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
1852 unsafe { std::env::remove_var("VLLM_API_KEY") };
1854 }
1855
1856 #[test]
1857 fn ollama_env_aliases_resolve() {
1858 let _lock = env_lock();
1859 clear_known_envs();
1860 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
1862
1863 assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
1864 assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
1865 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
1867 }
1868
1869 #[cfg(unix)]
1870 #[test]
1871 fn file_store_round_trips_with_secure_perms() {
1872 use std::os::unix::fs::PermissionsExt;
1873
1874 let tmp = tempfile::tempdir().unwrap();
1875 let path = tmp.path().join("nested").join("secrets.json");
1876 let store = FileKeyringStore::new(path.clone());
1877 assert_eq!(store.get("deepseek").unwrap(), None);
1878 store.set("deepseek", "sk-disk").unwrap();
1879 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1880
1881 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1882 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
1883
1884 store.set("openrouter", "or-disk").unwrap();
1885 assert_eq!(
1886 store.get("openrouter").unwrap(),
1887 Some("or-disk".to_string())
1888 );
1889 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1891
1892 store.delete("deepseek").unwrap();
1893 assert_eq!(store.get("deepseek").unwrap(), None);
1894 }
1895
1896 #[cfg(unix)]
1897 #[test]
1898 fn file_store_rejects_world_readable_file() {
1899 use std::os::unix::fs::PermissionsExt;
1900 let tmp = tempfile::tempdir().unwrap();
1901 let path = tmp.path().join("secrets.json");
1902 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
1903 let mut perms = fs::metadata(&path).unwrap().permissions();
1904 perms.set_mode(0o644);
1905 fs::set_permissions(&path, perms).unwrap();
1906
1907 let store = FileKeyringStore::new(path);
1908 let err = store.get("deepseek").unwrap_err();
1909 assert!(
1910 matches!(err, SecretsError::InsecurePermissions { .. }),
1911 "unexpected error: {err}"
1912 );
1913 }
1914
1915 #[cfg(unix)]
1921 #[test]
1922 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
1923 use std::os::unix::fs::PermissionsExt;
1924 let tmp = tempfile::tempdir().unwrap();
1925 let path = tmp.path().join("secrets.json");
1926 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1927 fs::write(&path, original).unwrap();
1928 let mut perms = fs::metadata(&path).unwrap().permissions();
1929 perms.set_mode(0o644);
1930 fs::set_permissions(&path, perms).unwrap();
1931
1932 let store = FileKeyringStore::new(path.clone());
1933 let err = store.set("openrouter", "or-new").unwrap_err();
1934 assert!(
1935 matches!(err, SecretsError::InsecurePermissions { .. }),
1936 "set must surface the read error rather than overwriting; got: {err}"
1937 );
1938
1939 let on_disk = fs::read_to_string(&path).unwrap();
1940 assert_eq!(
1941 on_disk, original,
1942 "set must not modify the file when load_unlocked errored"
1943 );
1944 }
1945
1946 #[cfg(unix)]
1947 #[test]
1948 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
1949 use std::os::unix::fs::PermissionsExt;
1950 let tmp = tempfile::tempdir().unwrap();
1951 let path = tmp.path().join("secrets.json");
1952 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1953 fs::write(&path, original).unwrap();
1954 let mut perms = fs::metadata(&path).unwrap().permissions();
1955 perms.set_mode(0o644);
1956 fs::set_permissions(&path, perms).unwrap();
1957
1958 let store = FileKeyringStore::new(path.clone());
1959 let err = store.delete("nvidia").unwrap_err();
1960 assert!(
1961 matches!(err, SecretsError::InsecurePermissions { .. }),
1962 "delete must surface the read error rather than wiping the file; got: {err}"
1963 );
1964 let on_disk = fs::read_to_string(&path).unwrap();
1965 assert_eq!(on_disk, original);
1966 }
1967
1968 #[test]
1969 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
1970 let tmp = tempfile::tempdir().unwrap();
1971 let path = tmp.path().join("secrets.json");
1972 fs::write(&path, "{ this is not valid json").unwrap();
1975 #[cfg(unix)]
1976 {
1977 use std::os::unix::fs::PermissionsExt;
1978 let mut perms = fs::metadata(&path).unwrap().permissions();
1979 perms.set_mode(0o600);
1980 fs::set_permissions(&path, perms).unwrap();
1981 }
1982
1983 let store = FileKeyringStore::new(path.clone());
1984 let err = store.set("deepseek", "sk-new").unwrap_err();
1985 assert!(
1986 matches!(err, SecretsError::Json(_)),
1987 "set must surface the parse error rather than wiping the file; got: {err}"
1988 );
1989 let on_disk = fs::read_to_string(&path).unwrap();
1990 assert_eq!(on_disk, "{ this is not valid json");
1991 }
1992
1993 #[test]
1994 fn file_store_set_still_creates_file_when_missing() {
1995 let tmp = tempfile::tempdir().unwrap();
2000 let path = tmp.path().join("nested").join("secrets.json");
2001 let store = FileKeyringStore::new(path.clone());
2002
2003 store.set("deepseek", "sk-fresh").unwrap();
2004 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
2005 }
2006
2007 #[test]
2008 fn file_store_default_path_uses_home() {
2009 let _lock = env_lock();
2010 clear_known_envs();
2011 let tmp = tempfile::tempdir().unwrap();
2012 let _home = EnvVarGuard::set("HOME", tmp.path());
2013 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2014
2015 let path = FileKeyringStore::default_path().unwrap();
2016 assert_eq!(
2017 path,
2018 tmp.path()
2019 .join(".codewhale")
2020 .join("secrets")
2021 .join("secrets.json")
2022 );
2023 }
2024
2025 #[test]
2026 fn default_path_with_explicit_codewhale_home_does_not_migrate_ambient_legacy() {
2027 let _lock = env_lock();
2030 clear_known_envs();
2031 let tmp = tempfile::tempdir().unwrap();
2032 let codewhale_home = tmp.path().join("isolated-codewhale-home");
2033 let _home = EnvVarGuard::set("HOME", tmp.path());
2034 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2035 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
2036 let legacy = tmp
2037 .path()
2038 .join(".deepseek")
2039 .join("secrets")
2040 .join("secrets.json");
2041 FileKeyringStore::new(&legacy)
2042 .set("deepseek", "synthetic-ambient-legacy-value")
2043 .unwrap();
2044
2045 let path = FileKeyringStore::default_path().unwrap();
2046 assert_eq!(path, codewhale_home.join("secrets").join("secrets.json"));
2047 assert!(
2048 !path.exists(),
2049 "explicit CODEWHALE_HOME must not create/migrate a primary store from ambient legacy"
2050 );
2051
2052 let secrets = Secrets::auto_detect();
2053 assert_eq!(
2054 secrets.get("deepseek").unwrap(),
2055 None,
2056 "explicit CODEWHALE_HOME must not surface ambient legacy credentials"
2057 );
2058 }
2059
2060 #[test]
2061 fn file_backed_default_refuses_relative_secret_path() {
2062 let secrets =
2065 Secrets::file_backed_from_default_path(Ok(PathBuf::from(".codewhale-secrets.json")));
2066 assert!(matches!(
2067 secrets.set("deepseek", "must-not-land-relative"),
2068 Err(SecretsError::ReadOnly)
2069 ));
2070 assert_eq!(
2071 secrets.get("deepseek").unwrap(),
2072 None,
2073 "unsafe relative fallback must not read a workspace secret file"
2074 );
2075 }
2076
2077 #[test]
2078 fn file_backed_default_refuses_writes_when_home_resolution_fails() {
2079 let err = SecretsError::Io(std::io::Error::new(
2082 std::io::ErrorKind::NotFound,
2083 "synthetic unresolved home",
2084 ));
2085 let secrets = Secrets::file_backed_from_default_path(Err(err));
2086 assert!(matches!(
2087 secrets.set("deepseek", "must-not-persist"),
2088 Err(SecretsError::ReadOnly)
2089 ));
2090 assert_eq!(secrets.get("deepseek").unwrap(), None);
2091 }
2092}