1#![deny(missing_docs)]
13
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19use codewhale_paths::codewhale_home_is_explicit;
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22
23pub const DEFAULT_SERVICE: &str = "deepseek";
27pub const SECRET_BACKEND_ENV: &str = "CODEWHALE_SECRET_BACKEND";
30pub const LEGACY_SECRET_BACKEND_ENV: &str = "DEEPSEEK_SECRET_BACKEND";
32const FILE_BACKEND_LABEL: &str = "file-based (~/.codewhale/secrets/)";
33
34#[derive(Debug, Error)]
36pub enum SecretsError {
37 #[error("keyring backend error: {0}")]
39 Keyring(String),
40 #[error("file-backed secret store I/O error: {0}")]
42 Io(#[from] std::io::Error),
43 #[error("file-backed secret store JSON error: {0}")]
45 Json(#[from] serde_json::Error),
46 #[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
48 InsecurePermissions {
49 path: PathBuf,
51 mode: u32,
53 },
54 #[error("secret store is read-only")]
56 ReadOnly,
57}
58
59pub trait KeyringStore: Send + Sync {
68 fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
73
74 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
79
80 fn delete(&self, key: &str) -> Result<(), SecretsError>;
85
86 fn backend_name(&self) -> &'static str;
92}
93
94#[derive(Debug, Clone)]
107pub struct DefaultKeyringStore {
108 service: String,
111}
112
113impl Default for DefaultKeyringStore {
114 fn default() -> Self {
115 Self::new(DEFAULT_SERVICE)
116 }
117}
118
119impl DefaultKeyringStore {
120 #[must_use]
122 pub fn new(service: impl Into<String>) -> Self {
123 Self {
124 service: service.into(),
125 }
126 }
127
128 pub fn probe(&self) -> Result<(), SecretsError> {
131 #[cfg(any(
132 target_os = "macos",
133 target_os = "windows",
134 all(
135 target_os = "linux",
136 not(target_env = "ohos"),
137 not(target_env = "musl")
138 )
139 ))]
140 {
141 let entry = keyring::Entry::new(&self.service, "__probe__")
146 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
147 #[cfg(any(target_os = "macos", target_os = "windows"))]
148 {
149 let _ = entry;
150 Ok(())
151 }
152 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
153 match entry.get_password() {
154 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
155 Err(keyring::Error::PlatformFailure(err)) => {
156 Err(SecretsError::Keyring(format!("platform failure: {err}")))
157 }
158 Err(keyring::Error::NoStorageAccess(err)) => {
159 Err(SecretsError::Keyring(format!("no storage access: {err}")))
160 }
161 Err(other) => Err(SecretsError::Keyring(other.to_string())),
162 }
163 }
164 #[cfg(not(any(
165 target_os = "macos",
166 target_os = "windows",
167 all(
168 target_os = "linux",
169 not(target_env = "ohos"),
170 not(target_env = "musl")
171 )
172 )))]
173 {
174 let _ = &self.service;
175 Err(SecretsError::Keyring(unsupported_keyring_message()))
176 }
177 }
178}
179
180impl KeyringStore for DefaultKeyringStore {
181 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
182 #[cfg(any(
183 target_os = "macos",
184 target_os = "windows",
185 all(
186 target_os = "linux",
187 not(target_env = "ohos"),
188 not(target_env = "musl")
189 )
190 ))]
191 {
192 let entry = keyring::Entry::new(&self.service, key)
193 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
194 match entry.get_password() {
195 Ok(value) => Ok(Some(value)),
196 Err(keyring::Error::NoEntry) => Ok(None),
197 Err(err) => Err(SecretsError::Keyring(err.to_string())),
198 }
199 }
200 #[cfg(not(any(
201 target_os = "macos",
202 target_os = "windows",
203 all(
204 target_os = "linux",
205 not(target_env = "ohos"),
206 not(target_env = "musl")
207 )
208 )))]
209 {
210 let _ = key;
211 Err(SecretsError::Keyring(unsupported_keyring_message()))
212 }
213 }
214
215 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
216 #[cfg(any(
217 target_os = "macos",
218 target_os = "windows",
219 all(
220 target_os = "linux",
221 not(target_env = "ohos"),
222 not(target_env = "musl")
223 )
224 ))]
225 {
226 let entry = keyring::Entry::new(&self.service, key)
227 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
228 entry
229 .set_password(value)
230 .map_err(|err| SecretsError::Keyring(err.to_string()))
231 }
232 #[cfg(not(any(
233 target_os = "macos",
234 target_os = "windows",
235 all(
236 target_os = "linux",
237 not(target_env = "ohos"),
238 not(target_env = "musl")
239 )
240 )))]
241 {
242 let _ = (key, value);
243 Err(SecretsError::Keyring(unsupported_keyring_message()))
244 }
245 }
246
247 fn delete(&self, key: &str) -> Result<(), SecretsError> {
248 #[cfg(any(
249 target_os = "macos",
250 target_os = "windows",
251 all(
252 target_os = "linux",
253 not(target_env = "ohos"),
254 not(target_env = "musl")
255 )
256 ))]
257 {
258 let entry = keyring::Entry::new(&self.service, key)
259 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
260 match entry.delete_credential() {
261 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
262 Err(err) => Err(SecretsError::Keyring(err.to_string())),
263 }
264 }
265 #[cfg(not(any(
266 target_os = "macos",
267 target_os = "windows",
268 all(
269 target_os = "linux",
270 not(target_env = "ohos"),
271 not(target_env = "musl")
272 )
273 )))]
274 {
275 let _ = key;
276 Err(SecretsError::Keyring(unsupported_keyring_message()))
277 }
278 }
279
280 fn backend_name(&self) -> &'static str {
281 "system keyring"
282 }
283}
284
285#[cfg(not(any(
286 target_os = "macos",
287 target_os = "windows",
288 all(
289 target_os = "linux",
290 not(target_env = "ohos"),
291 not(target_env = "musl")
292 )
293)))]
294fn unsupported_keyring_message() -> String {
295 "system keyring backend is unsupported on this platform".to_string()
296}
297
298#[derive(Debug, Default)]
305pub struct InMemoryKeyringStore {
306 entries: Mutex<HashMap<String, String>>,
308}
309
310impl InMemoryKeyringStore {
311 #[must_use]
313 pub fn new() -> Self {
314 Self::default()
315 }
316}
317
318impl KeyringStore for InMemoryKeyringStore {
319 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
320 let guard = self.entries.lock().map_err(|e| {
321 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
322 })?;
323 Ok(guard.get(key).cloned())
324 }
325
326 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
327 let mut guard = self.entries.lock().map_err(|e| {
328 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
329 })?;
330 guard.insert(key.to_string(), value.to_string());
331 Ok(())
332 }
333
334 fn delete(&self, key: &str) -> Result<(), SecretsError> {
335 let mut guard = self.entries.lock().map_err(|e| {
336 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
337 })?;
338 guard.remove(key);
339 Ok(())
340 }
341
342 fn backend_name(&self) -> &'static str {
343 "in-memory (test)"
344 }
345}
346
347#[derive(Debug, Clone)]
360pub struct FileKeyringStore {
361 path: PathBuf,
363}
364
365#[derive(Debug, Clone)]
374struct ReadOnlyFileKeyringStore {
375 primary: FileKeyringStore,
376 legacy: Option<FileKeyringStore>,
379}
380
381#[derive(Debug, Default, Serialize, Deserialize)]
382struct FileSecretsBlob {
383 #[serde(default)]
384 entries: HashMap<String, String>,
385}
386
387impl FileKeyringStore {
388 #[must_use]
390 pub fn new(path: impl Into<PathBuf>) -> Self {
391 Self { path: path.into() }
392 }
393
394 pub fn default_path() -> Result<PathBuf, SecretsError> {
401 let primary = default_codewhale_secrets_path()?;
402 if !codewhale_home_is_explicit() {
405 match legacy_deepseek_secrets_path() {
406 Ok(legacy) => {
407 if let Err(err) = Self::migrate_legacy_file_if_needed(&primary, &legacy) {
408 tracing::warn!(
409 "could not migrate legacy secret store from {} to {}: {err}",
410 legacy.display(),
411 primary.display()
412 );
413 }
414 }
415 Err(err) => {
416 tracing::warn!("could not resolve legacy secret store path: {err}");
417 }
418 }
419 }
420 Ok(primary)
421 }
422
423 pub fn default_paths_read_only() -> Result<(PathBuf, Option<PathBuf>), SecretsError> {
430 let primary = default_codewhale_secrets_path()?;
431 let legacy = (!codewhale_home_is_explicit())
432 .then(legacy_deepseek_secrets_path)
433 .transpose()?;
434 Ok((primary, legacy))
435 }
436
437 fn migrate_legacy_file_if_needed(primary: &Path, legacy: &Path) -> Result<(), SecretsError> {
438 if !legacy.exists() {
439 return Ok(());
440 }
441
442 let legacy_store = Self::new(legacy.to_path_buf());
443 let legacy_blob = legacy_store.load_unlocked()?;
444 if legacy_blob.entries.is_empty() {
445 return Ok(());
446 }
447
448 let primary_store = Self::new(primary.to_path_buf());
449 let mut primary_blob = primary_store.load_unlocked()?;
450 let mut changed = false;
451 for (key, value) in legacy_blob.entries {
452 if let std::collections::hash_map::Entry::Vacant(entry) =
453 primary_blob.entries.entry(key)
454 {
455 entry.insert(value);
456 changed = true;
457 }
458 }
459 if changed {
460 primary_store.store_unlocked(&primary_blob)?;
461 }
462 Ok(())
463 }
464
465 #[must_use]
467 pub fn path(&self) -> &Path {
468 &self.path
469 }
470
471 fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
472 if !self.path.exists() {
473 return Ok(FileSecretsBlob::default());
474 }
475 #[cfg(unix)]
479 {
480 use std::os::unix::fs::PermissionsExt;
481 let meta = fs::metadata(&self.path)?;
482 let mode = meta.permissions().mode() & 0o777;
483 if mode & 0o077 != 0 {
484 return Err(SecretsError::InsecurePermissions {
485 path: self.path.clone(),
486 mode,
487 });
488 }
489 }
490 let raw = fs::read_to_string(&self.path)?;
491 if raw.trim().is_empty() {
492 return Ok(FileSecretsBlob::default());
493 }
494 let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
495 Ok(blob)
496 }
497
498 fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
499 if let Some(parent) = self.path.parent() {
500 fs::create_dir_all(parent)?;
501 #[cfg(unix)]
502 {
503 use std::os::unix::fs::PermissionsExt;
504 let mut perms = fs::metadata(parent)?.permissions();
505 perms.set_mode(0o700);
506 let _ = fs::set_permissions(parent, perms);
507 }
508 }
509 let body = serde_json::to_string_pretty(blob)?;
510 write_private_file(&self.path, body.as_bytes())?;
511 #[cfg(unix)]
512 {
513 use std::os::unix::fs::PermissionsExt;
514 if let Ok(meta) = fs::metadata(&self.path) {
521 let mut perms = meta.permissions();
522 perms.set_mode(0o600);
523 let _ = fs::set_permissions(&self.path, perms);
524 }
525 }
526 Ok(())
527 }
528}
529
530impl ReadOnlyFileKeyringStore {
531 fn default_for_diagnostics() -> Result<Self, SecretsError> {
532 let (primary, legacy) = FileKeyringStore::default_paths_read_only()?;
533 Ok(Self::new(primary, legacy))
534 }
535
536 fn new(primary: impl Into<PathBuf>, legacy: Option<PathBuf>) -> Self {
537 Self {
538 primary: FileKeyringStore::new(primary),
539 legacy: legacy.map(FileKeyringStore::new),
540 }
541 }
542}
543
544impl KeyringStore for ReadOnlyFileKeyringStore {
545 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
546 match self.primary.get(key)? {
547 Some(value) => Ok(Some(value)),
548 None => self
549 .legacy
550 .as_ref()
551 .map_or(Ok(None), |legacy| legacy.get(key)),
552 }
553 }
554
555 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
556 Err(SecretsError::ReadOnly)
557 }
558
559 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
560 Err(SecretsError::ReadOnly)
561 }
562
563 fn backend_name(&self) -> &'static str {
564 FILE_BACKEND_LABEL
565 }
566}
567
568#[derive(Clone)]
569struct ReadOnlyKeyringStore {
570 inner: Arc<dyn KeyringStore>,
571}
572
573impl ReadOnlyKeyringStore {
574 fn new(inner: Arc<dyn KeyringStore>) -> Self {
575 Self { inner }
576 }
577}
578
579impl KeyringStore for ReadOnlyKeyringStore {
580 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
581 self.inner.get(key)
582 }
583
584 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
585 Err(SecretsError::ReadOnly)
586 }
587
588 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
589 Err(SecretsError::ReadOnly)
590 }
591
592 fn backend_name(&self) -> &'static str {
593 self.inner.backend_name()
594 }
595}
596
597fn write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
598 atomic_write_private_file(path, body)
599}
600
601fn atomic_write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
602 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
603 fs::create_dir_all(parent)?;
604 }
605 let dir = path
606 .parent()
607 .filter(|p| !p.as_os_str().is_empty())
608 .unwrap_or_else(|| Path::new("."));
609 let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(SecretsError::Io)?;
610 use std::io::Write as _;
611 tmp.write_all(body).map_err(SecretsError::Io)?;
612 tmp.flush().map_err(SecretsError::Io)?;
613 tmp.as_file().sync_all().map_err(SecretsError::Io)?;
614 #[cfg(unix)]
615 {
616 use std::os::unix::fs::PermissionsExt;
617 let perms = fs::Permissions::from_mode(0o600);
618 tmp.as_file()
619 .set_permissions(perms)
620 .map_err(SecretsError::Io)?;
621 }
622 tmp.persist(path).map_err(|e| SecretsError::Io(e.error))?;
623 Ok(())
624}
625
626impl KeyringStore for FileKeyringStore {
627 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
628 let blob = self.load_unlocked()?;
629 Ok(blob.entries.get(key).cloned())
630 }
631
632 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
633 let mut blob = self.load_unlocked()?;
639 blob.entries.insert(key.to_string(), value.to_string());
640 self.store_unlocked(&blob)
641 }
642
643 fn delete(&self, key: &str) -> Result<(), SecretsError> {
644 let mut blob = self.load_unlocked()?;
647 blob.entries.remove(key);
648 self.store_unlocked(&blob)
649 }
650
651 fn backend_name(&self) -> &'static str {
652 FILE_BACKEND_LABEL
653 }
654}
655
656fn default_codewhale_secrets_path() -> Result<PathBuf, SecretsError> {
657 Ok(codewhale_paths::codewhale_home()
658 .ok_or_else(home_resolution_error)?
659 .join("secrets")
660 .join("secrets.json"))
661}
662
663fn legacy_deepseek_secrets_path() -> Result<PathBuf, SecretsError> {
664 Ok(codewhale_paths::legacy_deepseek_home()
665 .ok_or_else(home_resolution_error)?
666 .join("secrets")
667 .join("secrets.json"))
668}
669
670fn home_resolution_error() -> SecretsError {
671 SecretsError::Io(std::io::Error::new(
672 std::io::ErrorKind::NotFound,
673 "could not resolve home directory for FileKeyringStore",
674 ))
675}
676
677#[derive(Debug, Clone, Copy, PartialEq, Eq)]
678enum SecretBackendSelection {
679 File,
680 System,
681 Unknown,
682}
683
684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
689#[serde(rename_all = "snake_case")]
690pub enum SecretBackendDiagnosticKind {
691 File,
693 System,
695 Unknown,
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
701#[serde(rename_all = "snake_case")]
702pub enum SecretBackendPresence {
703 Present,
705 Absent,
707 Unknown,
709}
710
711#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
713#[serde(rename_all = "snake_case")]
714pub enum SecretBackendInspection {
715 MetadataOnly,
717 NotProbed,
719}
720
721#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
728pub struct SecretBackendDiagnostic {
729 pub backend: SecretBackendDiagnosticKind,
731 pub inspection: SecretBackendInspection,
733 pub path: Option<PathBuf>,
735 pub presence: SecretBackendPresence,
737 pub legacy_path: Option<PathBuf>,
739 pub legacy_presence: SecretBackendPresence,
741}
742
743#[must_use]
750pub fn diagnose_secret_backend() -> SecretBackendDiagnostic {
751 match secret_backend_selection(configured_secret_backend().as_deref()) {
752 SecretBackendSelection::File => {
753 let (path, legacy_path) = FileKeyringStore::default_paths_read_only()
754 .map(|(path, legacy)| (Some(path), legacy))
755 .unwrap_or((None, None));
756 SecretBackendDiagnostic {
757 backend: SecretBackendDiagnosticKind::File,
758 inspection: SecretBackendInspection::MetadataOnly,
759 presence: metadata_presence(path.as_deref()),
760 legacy_presence: metadata_presence(legacy_path.as_deref()),
761 path,
762 legacy_path,
763 }
764 }
765 SecretBackendSelection::System => SecretBackendDiagnostic {
766 backend: SecretBackendDiagnosticKind::System,
767 inspection: SecretBackendInspection::NotProbed,
768 path: None,
769 presence: SecretBackendPresence::Unknown,
770 legacy_path: None,
771 legacy_presence: SecretBackendPresence::Unknown,
772 },
773 SecretBackendSelection::Unknown => SecretBackendDiagnostic {
774 backend: SecretBackendDiagnosticKind::Unknown,
775 inspection: SecretBackendInspection::NotProbed,
776 path: None,
777 presence: SecretBackendPresence::Unknown,
778 legacy_path: None,
779 legacy_presence: SecretBackendPresence::Unknown,
780 },
781 }
782}
783
784fn metadata_presence(path: Option<&Path>) -> SecretBackendPresence {
785 let Some(path) = path else {
786 return SecretBackendPresence::Unknown;
787 };
788 if let Some(parent) = path.parent() {
789 for ancestor in parent.ancestors() {
790 if ancestor.as_os_str().is_empty() {
791 continue;
792 }
793 match fs::symlink_metadata(ancestor) {
794 Ok(metadata)
795 if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() =>
796 {
797 return SecretBackendPresence::Unknown;
798 }
799 Ok(_) => {}
800 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
801 return SecretBackendPresence::Absent;
802 }
803 Err(_) => return SecretBackendPresence::Unknown,
804 }
805 }
806 }
807 match fs::symlink_metadata(path) {
808 Ok(metadata) if metadata.file_type().is_file() => SecretBackendPresence::Present,
809 Ok(_) => SecretBackendPresence::Unknown,
810 Err(error) if error.kind() == std::io::ErrorKind::NotFound => SecretBackendPresence::Absent,
811 Err(_) => SecretBackendPresence::Unknown,
812 }
813}
814
815fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
816 match value.map(str::trim).filter(|value| !value.is_empty()) {
817 None => SecretBackendSelection::File,
818 Some(value) => match value.to_ascii_lowercase().as_str() {
819 "file" | "local" | "json" => SecretBackendSelection::File,
820 "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
821 _ => SecretBackendSelection::Unknown,
822 },
823 }
824}
825
826fn configured_secret_backend() -> Option<String> {
827 std::env::var(SECRET_BACKEND_ENV)
828 .ok()
829 .filter(|value| !value.trim().is_empty())
830 .or_else(|| std::env::var(LEGACY_SECRET_BACKEND_ENV).ok())
831}
832
833#[derive(Clone)]
850pub struct Secrets {
851 pub store: Arc<dyn KeyringStore>,
853 service: String,
858}
859
860#[derive(Debug, Clone, Copy, PartialEq, Eq)]
866pub enum SecretSource {
867 Keyring,
869 Env,
871}
872
873impl std::fmt::Debug for Secrets {
874 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
875 f.debug_struct("Secrets")
876 .field("backend", &self.store.backend_name())
877 .field("service", &self.service)
878 .finish()
879 }
880}
881
882impl Secrets {
883 #[must_use]
886 pub fn new(store: Arc<dyn KeyringStore>) -> Self {
887 Self {
888 store,
889 service: DEFAULT_SERVICE.to_string(),
890 }
891 }
892
893 pub fn auto_detect() -> Self {
904 match secret_backend_selection(configured_secret_backend().as_deref()) {
905 SecretBackendSelection::File => Self::file_backed_default(),
906 SecretBackendSelection::Unknown => {
907 tracing::warn!(
908 "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
909 );
910 Self::file_backed_default()
911 }
912 SecretBackendSelection::System => {
913 let default_store = DefaultKeyringStore::default();
914 match default_store.probe() {
915 Ok(()) => Self::new(Arc::new(default_store)),
916 Err(err) => {
917 tracing::warn!(
918 "OS keyring unavailable ({err}); falling back to file-backed secret store"
919 );
920 Self::file_backed_default()
921 }
922 }
923 }
924 }
925 }
926
927 #[must_use]
936 pub fn auto_detect_read_only() -> Self {
937 match secret_backend_selection(configured_secret_backend().as_deref()) {
938 SecretBackendSelection::File => Self::file_backed_read_only(),
939 SecretBackendSelection::Unknown => {
940 tracing::warn!(
941 "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
942 );
943 Self::file_backed_read_only()
944 }
945 SecretBackendSelection::System => {
946 let default_store = DefaultKeyringStore::default();
947 match default_store.probe() {
948 Ok(()) => {
949 Self::new(Arc::new(ReadOnlyKeyringStore::new(Arc::new(default_store))))
950 }
951 Err(err) => {
952 tracing::warn!(
953 "OS keyring unavailable ({err}); falling back to file-backed secret store"
954 );
955 Self::file_backed_read_only()
956 }
957 }
958 }
959 }
960 }
961
962 fn file_backed_default() -> Self {
963 Self::file_backed_from_default_path(FileKeyringStore::default_path())
964 }
965
966 fn file_backed_from_default_path(path_result: Result<PathBuf, SecretsError>) -> Self {
972 match path_result {
976 Ok(path) if path.is_absolute() => Self::new(Arc::new(FileKeyringStore::new(path))),
977 Ok(path) => {
978 tracing::error!(
979 "refusing relative file-backed secret path {}; credentials will not be read or persisted",
980 path.display()
981 );
982 Self::read_only_empty_store()
983 }
984 Err(err) => {
985 tracing::error!(
986 "could not resolve file-backed secret path ({err}); credentials will not be read or persisted"
987 );
988 Self::read_only_empty_store()
989 }
990 }
991 }
992
993 fn read_only_empty_store() -> Self {
997 Self::new(Arc::new(ReadOnlyKeyringStore::new(Arc::new(
998 InMemoryKeyringStore::new(),
999 ))))
1000 }
1001
1002 #[must_use]
1011 pub fn file_backed_read_only() -> Self {
1012 let store = ReadOnlyFileKeyringStore::default_for_diagnostics().unwrap_or_else(|_| {
1013 ReadOnlyFileKeyringStore::new(PathBuf::from(".codewhale-secrets.json"), None)
1014 });
1015 Self::new(Arc::new(store))
1016 }
1017
1018 #[must_use]
1020 pub fn file_backed() -> Self {
1021 Self::file_backed_default()
1022 }
1023
1024 #[must_use]
1027 pub fn system_keyring() -> Self {
1028 let default_store = DefaultKeyringStore::default();
1029 match default_store.probe() {
1030 Ok(()) => Self::new(Arc::new(default_store)),
1031 Err(err) => {
1032 tracing::warn!(
1033 "OS keyring unavailable ({err}); falling back to file-backed secret store"
1034 );
1035 Self::file_backed_default()
1036 }
1037 }
1038 }
1039
1040 #[must_use]
1042 pub fn backend_name(&self) -> &'static str {
1043 self.store.backend_name()
1044 }
1045
1046 #[must_use]
1051 pub fn resolve(&self, name: &str) -> Option<String> {
1052 self.resolve_with_source(name).map(|(value, _)| value)
1053 }
1054
1055 #[must_use]
1057 pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
1058 if let Ok(Some(v)) = self.store.get(name)
1059 && !v.trim().is_empty()
1060 {
1061 return Some((v, SecretSource::Keyring));
1062 }
1063 env_for(name).map(|value| (value, SecretSource::Env))
1064 }
1065
1066 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
1068 self.store.set(name, value)
1069 }
1070
1071 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
1073 self.store.delete(name)
1074 }
1075
1076 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
1078 self.store.get(name)
1079 }
1080
1081 #[must_use]
1093 pub fn resolve_direct(&self, key: &str, source_hint: Option<&str>) -> Option<String> {
1094 match source_hint {
1095 Some("env") => {
1096 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
1098 }
1099 Some("keyring") | Some("file") => {
1100 self.store
1102 .get(key)
1103 .ok()
1104 .flatten()
1105 .filter(|v| !v.trim().is_empty())
1106 }
1107 Some(_) | None => {
1108 if let Ok(Some(v)) = self.store.get(key)
1110 && !v.trim().is_empty()
1111 {
1112 return Some(v);
1113 }
1114 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
1115 }
1116 }
1117 }
1118}
1119
1120#[must_use]
1153pub fn env_for(name: &str) -> Option<String> {
1154 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
1155 "deepseek" => &["DEEPSEEK_API_KEY"],
1156 "openrouter" => &["OPENROUTER_API_KEY"],
1157 "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
1158 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
1159 }
1160 "novita" | "novita-ai" | "novita_ai" => &["NOVITA_API_KEY"],
1161 "together" | "together-ai" | "together_ai" | "togetherai" => &["TOGETHER_API_KEY"],
1162 "deepinfra" | "deep-infra" | "deep_infra" => &["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
1163 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
1167 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
1168 }
1169 "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
1170 "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
1171 | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
1172 "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
1173 "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
1174 "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
1175 "vllm" | "v-llm" => &["VLLM_API_KEY"],
1176 "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
1177 "openai" => &["OPENAI_API_KEY"],
1178 "anthropic" | "claude" => &["ANTHROPIC_API_KEY"],
1179 "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
1180 "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
1181 | "volcengineark" => &[
1182 "VOLCENGINE_API_KEY",
1183 "VOLCENGINE_ARK_API_KEY",
1184 "ARK_API_KEY",
1185 ],
1186 "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
1187 | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
1188 "WANJIE_ARK_API_KEY",
1189 "WANJIE_API_KEY",
1190 "WANJIE_MAAS_API_KEY",
1191 ],
1192 "sakana" | "sakana-ai" | "sakana_ai" | "fugu" => &["FUGU_API_KEY", "SAKANA_API_KEY"],
1193 "longcat" | "long-cat" | "meituan-longcat" | "meituan" => &["LONGCAT_API_KEY"],
1194 "opencode-go" | "opencode_go" | "opencodego" => &["OPENCODE_GO_API_KEY"],
1195 "opencode-zen" | "opencode_zen" | "opencodezen" | "zen" | "opencode" => {
1196 &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"]
1197 }
1198 "meta" | "meta-ai" | "meta_ai" | "meta-model-api" | "meta_model_api" | "muse"
1199 | "muse-spark" => &["META_MODEL_API_KEY", "MODEL_API_KEY"],
1200 "xai" | "x-ai" | "x_ai" | "grok" => &["XAI_API_KEY"],
1201 "telecomjs" | "telecom-js" | "telecom_js" | "telecomjs-cn" | "tokenhub" => {
1202 &["TELECOMJS_API_KEY"]
1203 }
1204 _ => return None,
1205 };
1206 for var in candidates {
1207 if let Ok(value) = std::env::var(var)
1208 && !value.trim().is_empty()
1209 {
1210 return Some(value);
1211 }
1212 }
1213 None
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::*;
1219 use std::sync::{Mutex, OnceLock};
1220
1221 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1224 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1225 LOCK.get_or_init(|| Mutex::new(()))
1226 .lock()
1227 .unwrap_or_else(|p| p.into_inner())
1228 }
1229
1230 fn clear_known_envs() {
1231 for var in [
1232 "CODEWHALE_HOME",
1233 "DEEPSEEK_API_KEY",
1234 "OPENROUTER_API_KEY",
1235 "NOVITA_API_KEY",
1236 "NVIDIA_API_KEY",
1237 "NVIDIA_NIM_API_KEY",
1238 "FIREWORKS_API_KEY",
1239 "TOGETHER_API_KEY",
1240 "DEEPINFRA_API_KEY",
1241 "DEEPINFRA_TOKEN",
1242 "SILICONFLOW_API_KEY",
1243 "ARCEE_API_KEY",
1244 "SGLANG_API_KEY",
1245 "VLLM_API_KEY",
1246 "OLLAMA_API_KEY",
1247 "OPENAI_API_KEY",
1248 "ATLASCLOUD_API_KEY",
1249 "WANJIE_ARK_API_KEY",
1250 "WANJIE_API_KEY",
1251 "WANJIE_MAAS_API_KEY",
1252 "XIAOMI_MIMO_API_KEY",
1253 "XIAOMI_API_KEY",
1254 "MIMO_API_KEY",
1255 "FUGU_API_KEY",
1256 "SAKANA_API_KEY",
1257 "LONGCAT_API_KEY",
1258 "OPENCODE_GO_API_KEY",
1259 "OPENCODE_ZEN_API_KEY",
1260 "OPENCODE_API_KEY",
1261 "META_MODEL_API_KEY",
1262 "MODEL_API_KEY",
1263 "XAI_API_KEY",
1264 "TELECOMJS_API_KEY",
1265 SECRET_BACKEND_ENV,
1266 LEGACY_SECRET_BACKEND_ENV,
1267 ] {
1268 unsafe { std::env::remove_var(var) };
1271 }
1272 }
1273
1274 struct EnvVarGuard {
1275 name: &'static str,
1276 previous: Option<std::ffi::OsString>,
1277 }
1278
1279 impl EnvVarGuard {
1280 fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
1281 let previous = std::env::var_os(name);
1282 unsafe { std::env::set_var(name, value) };
1283 Self { name, previous }
1284 }
1285 }
1286
1287 impl Drop for EnvVarGuard {
1288 fn drop(&mut self) {
1289 match self.previous.take() {
1290 Some(value) => unsafe { std::env::set_var(self.name, value) },
1291 None => unsafe { std::env::remove_var(self.name) },
1292 }
1293 }
1294 }
1295
1296 #[test]
1297 fn backend_selection_defaults_to_file() {
1298 assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
1299 assert_eq!(
1300 secret_backend_selection(Some("")),
1301 SecretBackendSelection::File
1302 );
1303 assert_eq!(
1304 secret_backend_selection(Some(" file ")),
1305 SecretBackendSelection::File
1306 );
1307 }
1308
1309 #[test]
1310 fn backend_selection_accepts_explicit_system_keyring() {
1311 assert_eq!(
1312 secret_backend_selection(Some("system")),
1313 SecretBackendSelection::System
1314 );
1315 assert_eq!(
1316 secret_backend_selection(Some("keyring")),
1317 SecretBackendSelection::System
1318 );
1319 assert_eq!(
1320 secret_backend_selection(Some("os-keyring")),
1321 SecretBackendSelection::System
1322 );
1323 }
1324
1325 #[test]
1326 fn auto_detect_is_file_backed_by_default() {
1327 let _lock = env_lock();
1328 clear_known_envs();
1329 let tmp = tempfile::tempdir().unwrap();
1330 let _home = EnvVarGuard::set("HOME", tmp.path());
1331 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1332
1333 let secrets = Secrets::auto_detect();
1334
1335 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1336 }
1337
1338 #[test]
1339 fn auto_detect_honors_explicit_file_backend() {
1340 let _lock = env_lock();
1341 clear_known_envs();
1342 let tmp = tempfile::tempdir().unwrap();
1343 let _home = EnvVarGuard::set("HOME", tmp.path());
1344 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1345 unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
1347
1348 let secrets = Secrets::auto_detect();
1349
1350 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1351 unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
1353 }
1354
1355 #[test]
1356 fn read_only_auto_detect_reads_legacy_without_migrating_or_allowing_writes() {
1357 let _lock = env_lock();
1358 clear_known_envs();
1359 let tmp = tempfile::tempdir().unwrap();
1360 let _home = EnvVarGuard::set("HOME", tmp.path());
1361 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1362 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1363 let legacy = tmp
1364 .path()
1365 .join(".deepseek")
1366 .join("secrets")
1367 .join("secrets.json");
1368 let primary = tmp
1369 .path()
1370 .join(".codewhale")
1371 .join("secrets")
1372 .join("secrets.json");
1373 FileKeyringStore::new(&legacy)
1374 .set("moonshot", "fixture-legacy-value")
1375 .unwrap();
1376
1377 let secrets = Secrets::auto_detect_read_only();
1378
1379 assert_eq!(
1380 secrets.get("moonshot").unwrap().as_deref(),
1381 Some("fixture-legacy-value")
1382 );
1383 assert!(
1384 !primary.exists(),
1385 "diagnostic lookup must not migrate the legacy store"
1386 );
1387 assert!(
1388 matches!(
1389 secrets.set("moonshot", "replacement"),
1390 Err(SecretsError::ReadOnly)
1391 ),
1392 "the diagnostic secret facade must refuse writes"
1393 );
1394 assert!(
1395 !primary.exists(),
1396 "a refused diagnostic write must not create the primary store"
1397 );
1398 }
1399
1400 #[test]
1401 fn read_only_auto_detect_respects_explicit_codewhale_home_isolation() {
1402 let _lock = env_lock();
1403 clear_known_envs();
1404 let tmp = tempfile::tempdir().unwrap();
1405 let codewhale_home = tmp.path().join("isolated-codewhale-home");
1406 let _home = EnvVarGuard::set("HOME", tmp.path());
1407 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1408 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1409 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1410 let legacy = tmp
1411 .path()
1412 .join(".deepseek")
1413 .join("secrets")
1414 .join("secrets.json");
1415 let primary = codewhale_home.join("secrets").join("secrets.json");
1416 FileKeyringStore::new(&legacy)
1417 .set("deepseek", "synthetic-ambient-legacy-value")
1418 .unwrap();
1419
1420 let secrets = Secrets::auto_detect_read_only();
1421
1422 assert_eq!(
1423 secrets.get("deepseek").unwrap(),
1424 None,
1425 "an explicit CODEWHALE_HOME must not read ambient legacy secrets"
1426 );
1427 assert!(
1428 !primary.exists(),
1429 "diagnostic lookup must not create an isolated primary store"
1430 );
1431 assert!(
1432 matches!(
1433 secrets.set("deepseek", "replacement"),
1434 Err(SecretsError::ReadOnly)
1435 ),
1436 "the isolated diagnostic facade must refuse writes"
1437 );
1438 assert!(
1439 !primary.exists(),
1440 "a refused isolated diagnostic write must not create the primary store"
1441 );
1442 }
1443
1444 #[test]
1445 fn read_only_auto_detect_reads_the_explicit_primary_store() {
1446 let _lock = env_lock();
1447 clear_known_envs();
1448 let tmp = tempfile::tempdir().unwrap();
1449 let codewhale_home = tmp.path().join("isolated-codewhale-home");
1450 let _home = EnvVarGuard::set("HOME", tmp.path());
1451 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1452 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1453 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1454 let primary = codewhale_home.join("secrets").join("secrets.json");
1455 FileKeyringStore::new(&primary)
1456 .set("deepseek", "synthetic-isolated-primary-value")
1457 .unwrap();
1458
1459 let secrets = Secrets::auto_detect_read_only();
1460
1461 assert_eq!(
1462 secrets.get("deepseek").unwrap().as_deref(),
1463 Some("synthetic-isolated-primary-value")
1464 );
1465 }
1466
1467 #[test]
1468 fn auto_detect_honors_legacy_backend_env_alias() {
1469 let _lock = env_lock();
1470 clear_known_envs();
1471 let tmp = tempfile::tempdir().unwrap();
1472 let _home = EnvVarGuard::set("HOME", tmp.path());
1473 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1474 unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
1475
1476 let secrets = Secrets::auto_detect();
1477
1478 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1479 clear_known_envs();
1480 }
1481
1482 #[test]
1483 fn file_default_path_uses_codewhale_home() {
1484 let _lock = env_lock();
1485 clear_known_envs();
1486 let tmp = tempfile::tempdir().unwrap();
1487 let _home = EnvVarGuard::set("HOME", tmp.path());
1488 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1489
1490 let path = FileKeyringStore::default_path().unwrap();
1491
1492 assert_eq!(
1493 path,
1494 tmp.path()
1495 .join(".codewhale")
1496 .join("secrets")
1497 .join("secrets.json")
1498 );
1499 }
1500
1501 #[test]
1502 fn file_default_path_honors_codewhale_home() {
1503 let _lock = env_lock();
1504 clear_known_envs();
1505 let tmp = tempfile::tempdir().unwrap();
1506 let custom = tmp.path().join("custom-codewhale");
1507 let _home = EnvVarGuard::set("HOME", tmp.path());
1508 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1509 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
1510
1511 let path = FileKeyringStore::default_path().unwrap();
1512
1513 assert_eq!(path, custom.join("secrets").join("secrets.json"));
1514 }
1515
1516 #[test]
1517 fn file_default_path_migrates_legacy_entries_to_codewhale() {
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 let legacy = tmp
1524 .path()
1525 .join(".deepseek")
1526 .join("secrets")
1527 .join("secrets.json");
1528 FileKeyringStore::new(legacy.clone())
1529 .set("xiaomi-mimo", "legacy-mimo")
1530 .unwrap();
1531
1532 let primary = FileKeyringStore::default_path().unwrap();
1533 let primary_store = FileKeyringStore::new(primary.clone());
1534
1535 assert_eq!(
1536 primary,
1537 tmp.path()
1538 .join(".codewhale")
1539 .join("secrets")
1540 .join("secrets.json")
1541 );
1542 assert_eq!(
1543 primary_store.get("xiaomi-mimo").unwrap().as_deref(),
1544 Some("legacy-mimo")
1545 );
1546 assert!(
1547 legacy.exists(),
1548 "migration copies; it does not delete legacy data"
1549 );
1550 }
1551
1552 #[test]
1553 fn file_default_path_migration_preserves_primary_values() {
1554 let _lock = env_lock();
1555 clear_known_envs();
1556 let tmp = tempfile::tempdir().unwrap();
1557 let _home = EnvVarGuard::set("HOME", tmp.path());
1558 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1559 let legacy = tmp
1560 .path()
1561 .join(".deepseek")
1562 .join("secrets")
1563 .join("secrets.json");
1564 let primary = tmp
1565 .path()
1566 .join(".codewhale")
1567 .join("secrets")
1568 .join("secrets.json");
1569 FileKeyringStore::new(legacy)
1570 .set("openrouter", "legacy-openrouter")
1571 .unwrap();
1572 let primary_store = FileKeyringStore::new(primary.clone());
1573 primary_store
1574 .set("openrouter", "primary-openrouter")
1575 .unwrap();
1576
1577 let resolved = FileKeyringStore::default_path().unwrap();
1578
1579 assert_eq!(resolved, primary);
1580 assert_eq!(
1581 primary_store.get("openrouter").unwrap().as_deref(),
1582 Some("primary-openrouter")
1583 );
1584 }
1585
1586 #[test]
1587 fn in_memory_store_round_trips() {
1588 let store = InMemoryKeyringStore::new();
1589 assert_eq!(store.get("deepseek").unwrap(), None);
1590 store.set("deepseek", "sk-test").unwrap();
1591 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
1592 store.set("deepseek", "sk-replaced").unwrap();
1593 assert_eq!(
1594 store.get("deepseek").unwrap(),
1595 Some("sk-replaced".to_string())
1596 );
1597 store.delete("deepseek").unwrap();
1598 assert_eq!(store.get("deepseek").unwrap(), None);
1599 store.delete("missing").unwrap();
1601 }
1602
1603 #[test]
1604 fn resolve_prefers_keyring_over_env() {
1605 let _lock = env_lock();
1606 clear_known_envs();
1607 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1609
1610 let store = Arc::new(InMemoryKeyringStore::new());
1611 store.set("deepseek", "ring-key").unwrap();
1612 let secrets = Secrets::new(store);
1613
1614 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1615 assert_eq!(
1616 secrets.resolve_with_source("deepseek"),
1617 Some(("ring-key".to_string(), SecretSource::Keyring))
1618 );
1619 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1621 }
1622
1623 #[test]
1624 fn resolve_falls_back_to_env_when_keyring_empty() {
1625 let _lock = env_lock();
1626 clear_known_envs();
1627 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1629
1630 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1631 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1632 assert_eq!(
1633 secrets.resolve_with_source("deepseek"),
1634 Some(("env-fallback".to_string(), SecretSource::Env))
1635 );
1636 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1638 }
1639
1640 #[test]
1641 fn resolve_returns_none_when_both_layers_empty() {
1642 let _lock = env_lock();
1643 clear_known_envs();
1644 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1645 assert_eq!(secrets.resolve("deepseek"), None);
1646 }
1647
1648 #[test]
1649 fn resolve_treats_blank_keyring_value_as_unset() {
1650 let _lock = env_lock();
1651 clear_known_envs();
1652 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1654
1655 let store = Arc::new(InMemoryKeyringStore::new());
1656 store.set("deepseek", " ").unwrap();
1657 let secrets = Secrets::new(store);
1658 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1659 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1661 }
1662
1663 #[test]
1664 fn nvidia_env_aliases_resolve() {
1665 let _lock = env_lock();
1666 clear_known_envs();
1667 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1669 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1670 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1671 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1672 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1674 }
1675
1676 #[test]
1677 fn atlascloud_env_aliases_resolve() {
1678 let _guard = env_lock();
1679 clear_known_envs();
1680 unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1681
1682 assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1683 assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1684 assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1685
1686 clear_known_envs();
1687 }
1688
1689 #[test]
1690 fn sakana_env_aliases_resolve() {
1691 let _guard = env_lock();
1692 clear_known_envs();
1693 unsafe { std::env::set_var("FUGU_API_KEY", "fugu-key") };
1694
1695 assert_eq!(env_for("sakana").as_deref(), Some("fugu-key"));
1696 assert_eq!(env_for("sakana-ai").as_deref(), Some("fugu-key"));
1697 assert_eq!(env_for("sakana_ai").as_deref(), Some("fugu-key"));
1698 assert_eq!(env_for("fugu").as_deref(), Some("fugu-key"));
1699
1700 clear_known_envs();
1701 unsafe { std::env::set_var("SAKANA_API_KEY", "sakana-key") };
1702 assert_eq!(env_for("sakana").as_deref(), Some("sakana-key"));
1703
1704 clear_known_envs();
1705 }
1706
1707 #[test]
1708 fn wanjie_ark_env_aliases_resolve() {
1709 let _guard = env_lock();
1710 clear_known_envs();
1711 unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1712
1713 assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1714 assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1715 assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1716
1717 clear_known_envs();
1718 }
1719
1720 #[test]
1721 fn xai_env_aliases_resolve() {
1722 let _guard = env_lock();
1723 clear_known_envs();
1724 unsafe { std::env::set_var("XAI_API_KEY", "xai-key") };
1725
1726 assert_eq!(env_for("xai").as_deref(), Some("xai-key"));
1727 assert_eq!(env_for("x-ai").as_deref(), Some("xai-key"));
1728 assert_eq!(env_for("x_ai").as_deref(), Some("xai-key"));
1729 assert_eq!(env_for("grok").as_deref(), Some("xai-key"));
1730
1731 clear_known_envs();
1732 }
1733
1734 #[test]
1735 fn telecomjs_env_aliases_resolve() {
1736 let _guard = env_lock();
1737 clear_known_envs();
1738 unsafe { std::env::set_var("TELECOMJS_API_KEY", "telecom-key") };
1739
1740 for alias in [
1741 "telecomjs",
1742 "telecom-js",
1743 "telecom_js",
1744 "telecomjs-cn",
1745 "tokenhub",
1746 ] {
1747 assert_eq!(env_for(alias).as_deref(), Some("telecom-key"), "{alias}");
1748 }
1749
1750 clear_known_envs();
1751 }
1752
1753 #[test]
1754 fn opencode_go_env_aliases_resolve() {
1755 let _guard = env_lock();
1756 clear_known_envs();
1757 unsafe { std::env::set_var("OPENCODE_GO_API_KEY", "go-key") };
1758
1759 for alias in ["opencode-go", "opencode_go", "opencodego"] {
1760 assert_eq!(env_for(alias).as_deref(), Some("go-key"), "{alias}");
1761 }
1762
1763 clear_known_envs();
1764 }
1765
1766 #[test]
1767 fn opencode_zen_env_aliases_resolve() {
1768 let _guard = env_lock();
1769 clear_known_envs();
1770 unsafe { std::env::set_var("OPENCODE_ZEN_API_KEY", "zen-key") };
1771
1772 for alias in [
1773 "opencode-zen",
1774 "opencode_zen",
1775 "opencodezen",
1776 "zen",
1777 "opencode",
1778 ] {
1779 assert_eq!(env_for(alias).as_deref(), Some("zen-key"), "{alias}");
1780 }
1781
1782 clear_known_envs();
1783 }
1784
1785 #[test]
1786 fn meta_model_api_env_aliases_resolve() {
1787 let _guard = env_lock();
1788 clear_known_envs();
1789 unsafe { std::env::set_var("MODEL_API_KEY", "meta-key") };
1790
1791 for alias in [
1792 "meta",
1793 "meta-ai",
1794 "meta_ai",
1795 "meta-model-api",
1796 "meta_model_api",
1797 "muse",
1798 "muse-spark",
1799 ] {
1800 assert_eq!(env_for(alias).as_deref(), Some("meta-key"), "{alias}");
1801 }
1802
1803 clear_known_envs();
1804 unsafe { std::env::set_var("META_MODEL_API_KEY", "meta-prefixed-key") };
1805 assert_eq!(env_for("meta").as_deref(), Some("meta-prefixed-key"),);
1806
1807 clear_known_envs();
1808 }
1809
1810 #[test]
1811 fn xiaomi_mimo_env_aliases_resolve() {
1812 let _guard = env_lock();
1813 clear_known_envs();
1814 unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1815
1816 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1817 assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1818 assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1819 assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1820
1821 clear_known_envs();
1822
1823 unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1824 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1825 clear_known_envs();
1826 }
1827
1828 #[test]
1829 fn fireworks_env_aliases_resolve() {
1830 let _lock = env_lock();
1831 clear_known_envs();
1832 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1834
1835 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1836 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1837 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1839 }
1840
1841 #[test]
1842 fn together_env_aliases_resolve() {
1843 let _lock = env_lock();
1844 clear_known_envs();
1845 unsafe { std::env::set_var("TOGETHER_API_KEY", "together-key") };
1847
1848 assert_eq!(env_for("together").as_deref(), Some("together-key"));
1851 assert_eq!(env_for("together-ai").as_deref(), Some("together-key"));
1852 assert_eq!(env_for("together_ai").as_deref(), Some("together-key"));
1853 assert_eq!(env_for("togetherai").as_deref(), Some("together-key"));
1854 unsafe { std::env::remove_var("TOGETHER_API_KEY") };
1856 }
1857
1858 #[test]
1859 fn deepinfra_env_aliases_resolve() {
1860 let _lock = env_lock();
1861 clear_known_envs();
1862 unsafe { std::env::set_var("DEEPINFRA_API_KEY", "di-key") };
1864
1865 assert_eq!(env_for("deepinfra").as_deref(), Some("di-key"));
1866 assert_eq!(env_for("deep-infra").as_deref(), Some("di-key"));
1867 assert_eq!(env_for("deep_infra").as_deref(), Some("di-key"));
1868 unsafe { std::env::remove_var("DEEPINFRA_API_KEY") };
1870
1871 unsafe { std::env::set_var("DEEPINFRA_TOKEN", "di-token") };
1874 assert_eq!(env_for("deepinfra").as_deref(), Some("di-token"));
1875 unsafe { std::env::remove_var("DEEPINFRA_TOKEN") };
1877 }
1878
1879 #[test]
1880 fn novita_env_aliases_resolve() {
1881 let _lock = env_lock();
1882 clear_known_envs();
1883 unsafe { std::env::set_var("NOVITA_API_KEY", "novita-key") };
1885
1886 assert_eq!(env_for("novita").as_deref(), Some("novita-key"));
1887 assert_eq!(env_for("novita-ai").as_deref(), Some("novita-key"));
1889 assert_eq!(env_for("novita_ai").as_deref(), Some("novita-key"));
1890 unsafe { std::env::remove_var("NOVITA_API_KEY") };
1892 }
1893
1894 #[test]
1895 fn siliconflow_env_aliases_resolve() {
1896 let _lock = env_lock();
1897 clear_known_envs();
1898 unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
1900
1901 assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
1902 assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
1903 assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
1904 assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
1905 assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
1906 unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
1908 }
1909
1910 #[test]
1911 fn arcee_env_aliases_resolve() {
1912 let _lock = env_lock();
1913 clear_known_envs();
1914 unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
1916
1917 assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
1918 assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
1919 assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
1920 unsafe { std::env::remove_var("ARCEE_API_KEY") };
1922 }
1923
1924 #[test]
1925 fn moonshot_kimi_env_aliases_resolve() {
1926 let _lock = env_lock();
1927 clear_known_envs();
1928 unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
1930
1931 assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
1932 assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
1933 assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
1934 assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
1935 unsafe { std::env::remove_var("KIMI_API_KEY") };
1937 }
1938
1939 #[test]
1940 fn sglang_env_aliases_resolve() {
1941 let _lock = env_lock();
1942 clear_known_envs();
1943 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
1945
1946 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
1947 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
1948 unsafe { std::env::remove_var("SGLANG_API_KEY") };
1950 }
1951
1952 #[test]
1953 fn vllm_env_aliases_resolve() {
1954 let _lock = env_lock();
1955 clear_known_envs();
1956 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
1958
1959 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
1960 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
1961 unsafe { std::env::remove_var("VLLM_API_KEY") };
1963 }
1964
1965 #[test]
1966 fn ollama_env_aliases_resolve() {
1967 let _lock = env_lock();
1968 clear_known_envs();
1969 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
1971
1972 assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
1973 assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
1974 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
1976 }
1977
1978 #[cfg(unix)]
1979 #[test]
1980 fn file_store_round_trips_with_secure_perms() {
1981 use std::os::unix::fs::PermissionsExt;
1982
1983 let tmp = tempfile::tempdir().unwrap();
1984 let path = tmp.path().join("nested").join("secrets.json");
1985 let store = FileKeyringStore::new(path.clone());
1986 assert_eq!(store.get("deepseek").unwrap(), None);
1987 store.set("deepseek", "sk-disk").unwrap();
1988 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1989
1990 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1991 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
1992
1993 store.set("openrouter", "or-disk").unwrap();
1994 assert_eq!(
1995 store.get("openrouter").unwrap(),
1996 Some("or-disk".to_string())
1997 );
1998 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
2000
2001 store.delete("deepseek").unwrap();
2002 assert_eq!(store.get("deepseek").unwrap(), None);
2003 }
2004
2005 #[cfg(unix)]
2006 #[test]
2007 fn file_store_rejects_world_readable_file() {
2008 use std::os::unix::fs::PermissionsExt;
2009 let tmp = tempfile::tempdir().unwrap();
2010 let path = tmp.path().join("secrets.json");
2011 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
2012 let mut perms = fs::metadata(&path).unwrap().permissions();
2013 perms.set_mode(0o644);
2014 fs::set_permissions(&path, perms).unwrap();
2015
2016 let store = FileKeyringStore::new(path);
2017 let err = store.get("deepseek").unwrap_err();
2018 assert!(
2019 matches!(err, SecretsError::InsecurePermissions { .. }),
2020 "unexpected error: {err}"
2021 );
2022 }
2023
2024 #[cfg(unix)]
2030 #[test]
2031 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
2032 use std::os::unix::fs::PermissionsExt;
2033 let tmp = tempfile::tempdir().unwrap();
2034 let path = tmp.path().join("secrets.json");
2035 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
2036 fs::write(&path, original).unwrap();
2037 let mut perms = fs::metadata(&path).unwrap().permissions();
2038 perms.set_mode(0o644);
2039 fs::set_permissions(&path, perms).unwrap();
2040
2041 let store = FileKeyringStore::new(path.clone());
2042 let err = store.set("openrouter", "or-new").unwrap_err();
2043 assert!(
2044 matches!(err, SecretsError::InsecurePermissions { .. }),
2045 "set must surface the read error rather than overwriting; got: {err}"
2046 );
2047
2048 let on_disk = fs::read_to_string(&path).unwrap();
2049 assert_eq!(
2050 on_disk, original,
2051 "set must not modify the file when load_unlocked errored"
2052 );
2053 }
2054
2055 #[cfg(unix)]
2056 #[test]
2057 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
2058 use std::os::unix::fs::PermissionsExt;
2059 let tmp = tempfile::tempdir().unwrap();
2060 let path = tmp.path().join("secrets.json");
2061 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
2062 fs::write(&path, original).unwrap();
2063 let mut perms = fs::metadata(&path).unwrap().permissions();
2064 perms.set_mode(0o644);
2065 fs::set_permissions(&path, perms).unwrap();
2066
2067 let store = FileKeyringStore::new(path.clone());
2068 let err = store.delete("nvidia").unwrap_err();
2069 assert!(
2070 matches!(err, SecretsError::InsecurePermissions { .. }),
2071 "delete must surface the read error rather than wiping the file; got: {err}"
2072 );
2073 let on_disk = fs::read_to_string(&path).unwrap();
2074 assert_eq!(on_disk, original);
2075 }
2076
2077 #[test]
2078 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
2079 let tmp = tempfile::tempdir().unwrap();
2080 let path = tmp.path().join("secrets.json");
2081 fs::write(&path, "{ this is not valid json").unwrap();
2084 #[cfg(unix)]
2085 {
2086 use std::os::unix::fs::PermissionsExt;
2087 let mut perms = fs::metadata(&path).unwrap().permissions();
2088 perms.set_mode(0o600);
2089 fs::set_permissions(&path, perms).unwrap();
2090 }
2091
2092 let store = FileKeyringStore::new(path.clone());
2093 let err = store.set("deepseek", "sk-new").unwrap_err();
2094 assert!(
2095 matches!(err, SecretsError::Json(_)),
2096 "set must surface the parse error rather than wiping the file; got: {err}"
2097 );
2098 let on_disk = fs::read_to_string(&path).unwrap();
2099 assert_eq!(on_disk, "{ this is not valid json");
2100 }
2101
2102 #[test]
2103 fn file_store_set_still_creates_file_when_missing() {
2104 let tmp = tempfile::tempdir().unwrap();
2109 let path = tmp.path().join("nested").join("secrets.json");
2110 let store = FileKeyringStore::new(path.clone());
2111
2112 store.set("deepseek", "sk-fresh").unwrap();
2113 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
2114 }
2115
2116 #[test]
2117 fn file_store_default_path_uses_home() {
2118 let _lock = env_lock();
2119 clear_known_envs();
2120 let tmp = tempfile::tempdir().unwrap();
2121 let _home = EnvVarGuard::set("HOME", tmp.path());
2122 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2123
2124 let path = FileKeyringStore::default_path().unwrap();
2125 assert_eq!(
2126 path,
2127 tmp.path()
2128 .join(".codewhale")
2129 .join("secrets")
2130 .join("secrets.json")
2131 );
2132 }
2133
2134 #[test]
2135 fn default_path_with_explicit_codewhale_home_does_not_migrate_ambient_legacy() {
2136 let _lock = env_lock();
2139 clear_known_envs();
2140 let tmp = tempfile::tempdir().unwrap();
2141 let codewhale_home = tmp.path().join("isolated-codewhale-home");
2142 let _home = EnvVarGuard::set("HOME", tmp.path());
2143 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2144 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
2145 let legacy = tmp
2146 .path()
2147 .join(".deepseek")
2148 .join("secrets")
2149 .join("secrets.json");
2150 FileKeyringStore::new(&legacy)
2151 .set("deepseek", "synthetic-ambient-legacy-value")
2152 .unwrap();
2153
2154 let path = FileKeyringStore::default_path().unwrap();
2155 assert_eq!(path, codewhale_home.join("secrets").join("secrets.json"));
2156 assert!(
2157 !path.exists(),
2158 "explicit CODEWHALE_HOME must not create/migrate a primary store from ambient legacy"
2159 );
2160
2161 let secrets = Secrets::auto_detect();
2162 assert_eq!(
2163 secrets.get("deepseek").unwrap(),
2164 None,
2165 "explicit CODEWHALE_HOME must not surface ambient legacy credentials"
2166 );
2167 }
2168
2169 #[test]
2170 fn file_backed_default_refuses_relative_secret_path() {
2171 let secrets =
2174 Secrets::file_backed_from_default_path(Ok(PathBuf::from(".codewhale-secrets.json")));
2175 assert!(matches!(
2176 secrets.set("deepseek", "must-not-land-relative"),
2177 Err(SecretsError::ReadOnly)
2178 ));
2179 assert_eq!(
2180 secrets.get("deepseek").unwrap(),
2181 None,
2182 "unsafe relative fallback must not read a workspace secret file"
2183 );
2184 }
2185
2186 #[test]
2187 fn file_backed_default_refuses_writes_when_home_resolution_fails() {
2188 let err = SecretsError::Io(std::io::Error::new(
2191 std::io::ErrorKind::NotFound,
2192 "synthetic unresolved home",
2193 ));
2194 let secrets = Secrets::file_backed_from_default_path(Err(err));
2195 assert!(matches!(
2196 secrets.set("deepseek", "must-not-persist"),
2197 Err(SecretsError::ReadOnly)
2198 ));
2199 assert_eq!(secrets.get("deepseek").unwrap(), None);
2200 }
2201
2202 #[path = "diagnostic_tests.rs"]
2203 mod diagnostic_tests;
2204}