1#![deny(missing_docs)]
13
14pub mod account;
16
17use std::collections::HashMap;
18use std::fs;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21
22use codewhale_paths::codewhale_home_is_explicit;
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26pub const DEFAULT_SERVICE: &str = "deepseek";
30pub const SECRET_BACKEND_ENV: &str = "CODEWHALE_SECRET_BACKEND";
33pub const LEGACY_SECRET_BACKEND_ENV: &str = "DEEPSEEK_SECRET_BACKEND";
35const FILE_BACKEND_LABEL: &str = "file-based (~/.codewhale/secrets/)";
36
37#[derive(Debug, Error)]
39pub enum SecretsError {
40 #[error("keyring backend error: {0}")]
42 Keyring(String),
43 #[error("file-backed secret store I/O error: {0}")]
45 Io(#[from] std::io::Error),
46 #[error("file-backed secret store JSON error: {0}")]
48 Json(#[from] serde_json::Error),
49 #[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
51 InsecurePermissions {
52 path: PathBuf,
54 mode: u32,
56 },
57 #[error("secret store is read-only")]
59 ReadOnly,
60}
61
62pub trait KeyringStore: Send + Sync {
71 fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
76
77 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
82
83 fn delete(&self, key: &str) -> Result<(), SecretsError>;
88
89 fn backend_name(&self) -> &'static str;
95}
96
97#[derive(Debug, Clone)]
110pub struct DefaultKeyringStore {
111 service: String,
114}
115
116impl Default for DefaultKeyringStore {
117 fn default() -> Self {
118 Self::new(DEFAULT_SERVICE)
119 }
120}
121
122impl DefaultKeyringStore {
123 #[must_use]
125 pub fn new(service: impl Into<String>) -> Self {
126 Self {
127 service: service.into(),
128 }
129 }
130
131 pub fn probe(&self) -> Result<(), SecretsError> {
144 #[cfg(any(
145 target_os = "macos",
146 target_os = "windows",
147 all(
148 target_os = "linux",
149 not(target_env = "ohos"),
150 not(target_env = "musl")
151 )
152 ))]
153 {
154 let entry = keyring::Entry::new(&self.service, "__probe__")
155 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
156 match entry.get_password() {
157 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
158 Err(keyring::Error::PlatformFailure(err)) => {
159 Err(SecretsError::Keyring(format!("platform failure: {err}")))
160 }
161 Err(keyring::Error::NoStorageAccess(err)) => {
162 Err(SecretsError::Keyring(format!("no storage access: {err}")))
163 }
164 Err(other) => Err(SecretsError::Keyring(other.to_string())),
165 }
166 }
167 #[cfg(not(any(
168 target_os = "macos",
169 target_os = "windows",
170 all(
171 target_os = "linux",
172 not(target_env = "ohos"),
173 not(target_env = "musl")
174 )
175 )))]
176 {
177 let _ = &self.service;
178 Err(SecretsError::Keyring(unsupported_keyring_message()))
179 }
180 }
181}
182
183impl KeyringStore for DefaultKeyringStore {
184 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
185 #[cfg(any(
186 target_os = "macos",
187 target_os = "windows",
188 all(
189 target_os = "linux",
190 not(target_env = "ohos"),
191 not(target_env = "musl")
192 )
193 ))]
194 {
195 let entry = keyring::Entry::new(&self.service, key)
196 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
197 match entry.get_password() {
198 Ok(value) => Ok(Some(value)),
199 Err(keyring::Error::NoEntry) => Ok(None),
200 Err(err) => Err(SecretsError::Keyring(err.to_string())),
201 }
202 }
203 #[cfg(not(any(
204 target_os = "macos",
205 target_os = "windows",
206 all(
207 target_os = "linux",
208 not(target_env = "ohos"),
209 not(target_env = "musl")
210 )
211 )))]
212 {
213 let _ = key;
214 Err(SecretsError::Keyring(unsupported_keyring_message()))
215 }
216 }
217
218 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
219 #[cfg(any(
220 target_os = "macos",
221 target_os = "windows",
222 all(
223 target_os = "linux",
224 not(target_env = "ohos"),
225 not(target_env = "musl")
226 )
227 ))]
228 {
229 let entry = keyring::Entry::new(&self.service, key)
230 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
231 entry
232 .set_password(value)
233 .map_err(|err| SecretsError::Keyring(err.to_string()))
234 }
235 #[cfg(not(any(
236 target_os = "macos",
237 target_os = "windows",
238 all(
239 target_os = "linux",
240 not(target_env = "ohos"),
241 not(target_env = "musl")
242 )
243 )))]
244 {
245 let _ = (key, value);
246 Err(SecretsError::Keyring(unsupported_keyring_message()))
247 }
248 }
249
250 fn delete(&self, key: &str) -> Result<(), SecretsError> {
251 #[cfg(any(
252 target_os = "macos",
253 target_os = "windows",
254 all(
255 target_os = "linux",
256 not(target_env = "ohos"),
257 not(target_env = "musl")
258 )
259 ))]
260 {
261 let entry = keyring::Entry::new(&self.service, key)
262 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
263 match entry.delete_credential() {
264 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
265 Err(err) => Err(SecretsError::Keyring(err.to_string())),
266 }
267 }
268 #[cfg(not(any(
269 target_os = "macos",
270 target_os = "windows",
271 all(
272 target_os = "linux",
273 not(target_env = "ohos"),
274 not(target_env = "musl")
275 )
276 )))]
277 {
278 let _ = key;
279 Err(SecretsError::Keyring(unsupported_keyring_message()))
280 }
281 }
282
283 fn backend_name(&self) -> &'static str {
284 "system keyring"
285 }
286}
287
288#[cfg(not(any(
289 target_os = "macos",
290 target_os = "windows",
291 all(
292 target_os = "linux",
293 not(target_env = "ohos"),
294 not(target_env = "musl")
295 )
296)))]
297fn unsupported_keyring_message() -> String {
298 "system keyring backend is unsupported on this platform".to_string()
299}
300
301#[derive(Debug, Default)]
308pub struct InMemoryKeyringStore {
309 entries: Mutex<HashMap<String, String>>,
311}
312
313impl InMemoryKeyringStore {
314 #[must_use]
316 pub fn new() -> Self {
317 Self::default()
318 }
319}
320
321impl KeyringStore for InMemoryKeyringStore {
322 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
323 let guard = self.entries.lock().map_err(|e| {
324 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
325 })?;
326 Ok(guard.get(key).cloned())
327 }
328
329 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
330 let mut guard = self.entries.lock().map_err(|e| {
331 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
332 })?;
333 guard.insert(key.to_string(), value.to_string());
334 Ok(())
335 }
336
337 fn delete(&self, key: &str) -> Result<(), SecretsError> {
338 let mut guard = self.entries.lock().map_err(|e| {
339 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
340 })?;
341 guard.remove(key);
342 Ok(())
343 }
344
345 fn backend_name(&self) -> &'static str {
346 "in-memory (test)"
347 }
348}
349
350#[derive(Debug, Clone)]
363pub struct FileKeyringStore {
364 path: PathBuf,
366}
367
368#[derive(Debug, Clone)]
377struct ReadOnlyFileKeyringStore {
378 primary: FileKeyringStore,
379 legacy: Option<FileKeyringStore>,
382}
383
384#[derive(Debug, Default, Serialize, Deserialize)]
385struct FileSecretsBlob {
386 #[serde(default)]
387 entries: HashMap<String, String>,
388}
389
390impl FileKeyringStore {
391 #[must_use]
393 pub fn new(path: impl Into<PathBuf>) -> Self {
394 Self { path: path.into() }
395 }
396
397 pub fn default_path() -> Result<PathBuf, SecretsError> {
404 let primary = default_codewhale_secrets_path()?;
405 if !codewhale_home_is_explicit() {
408 match legacy_deepseek_secrets_path() {
409 Ok(legacy) => {
410 if let Err(err) = Self::migrate_legacy_file_if_needed(&primary, &legacy) {
411 tracing::warn!(
412 "could not migrate legacy secret store from {} to {}: {err}",
413 legacy.display(),
414 primary.display()
415 );
416 }
417 }
418 Err(err) => {
419 tracing::warn!("could not resolve legacy secret store path: {err}");
420 }
421 }
422 }
423 Ok(primary)
424 }
425
426 pub fn default_paths_read_only() -> Result<(PathBuf, Option<PathBuf>), SecretsError> {
433 let primary = default_codewhale_secrets_path()?;
434 let legacy = (!codewhale_home_is_explicit())
435 .then(legacy_deepseek_secrets_path)
436 .transpose()?;
437 Ok((primary, legacy))
438 }
439
440 fn migrate_legacy_file_if_needed(primary: &Path, legacy: &Path) -> Result<(), SecretsError> {
441 if !legacy.exists() {
442 return Ok(());
443 }
444
445 let legacy_store = Self::new(legacy.to_path_buf());
446 let legacy_blob = legacy_store.load_unlocked()?;
447 if legacy_blob.entries.is_empty() {
448 return Ok(());
449 }
450
451 let primary_store = Self::new(primary.to_path_buf());
452 let mut primary_blob = primary_store.load_unlocked()?;
453 let mut changed = false;
454 for (key, value) in legacy_blob.entries {
455 if let std::collections::hash_map::Entry::Vacant(entry) =
456 primary_blob.entries.entry(key)
457 {
458 entry.insert(value);
459 changed = true;
460 }
461 }
462 if changed {
463 primary_store.store_unlocked(&primary_blob)?;
464 }
465 Ok(())
466 }
467
468 #[must_use]
470 pub fn path(&self) -> &Path {
471 &self.path
472 }
473
474 fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
475 if !self.path.exists() {
476 return Ok(FileSecretsBlob::default());
477 }
478 #[cfg(unix)]
482 {
483 use std::os::unix::fs::PermissionsExt;
484 let meta = fs::metadata(&self.path)?;
485 let mode = meta.permissions().mode() & 0o777;
486 if mode & 0o077 != 0 {
487 return Err(SecretsError::InsecurePermissions {
488 path: self.path.clone(),
489 mode,
490 });
491 }
492 }
493 let raw = fs::read_to_string(&self.path)?;
494 if raw.trim().is_empty() {
495 return Ok(FileSecretsBlob::default());
496 }
497 let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
498 Ok(blob)
499 }
500
501 fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
502 if let Some(parent) = self.path.parent() {
503 fs::create_dir_all(parent)?;
504 #[cfg(unix)]
505 {
506 use std::os::unix::fs::PermissionsExt;
507 let mut perms = fs::metadata(parent)?.permissions();
508 perms.set_mode(0o700);
509 let _ = fs::set_permissions(parent, perms);
510 }
511 }
512 let body = serde_json::to_string_pretty(blob)?;
513 write_private_file(&self.path, body.as_bytes())?;
514 #[cfg(unix)]
515 {
516 use std::os::unix::fs::PermissionsExt;
517 if let Ok(meta) = fs::metadata(&self.path) {
524 let mut perms = meta.permissions();
525 perms.set_mode(0o600);
526 let _ = fs::set_permissions(&self.path, perms);
527 }
528 }
529 Ok(())
530 }
531}
532
533impl ReadOnlyFileKeyringStore {
534 fn default_for_diagnostics() -> Result<Self, SecretsError> {
535 let (primary, legacy) = FileKeyringStore::default_paths_read_only()?;
536 Ok(Self::new(primary, legacy))
537 }
538
539 fn new(primary: impl Into<PathBuf>, legacy: Option<PathBuf>) -> Self {
540 Self {
541 primary: FileKeyringStore::new(primary),
542 legacy: legacy.map(FileKeyringStore::new),
543 }
544 }
545}
546
547impl KeyringStore for ReadOnlyFileKeyringStore {
548 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
549 match self.primary.get(key)? {
550 Some(value) => Ok(Some(value)),
551 None => self
552 .legacy
553 .as_ref()
554 .map_or(Ok(None), |legacy| legacy.get(key)),
555 }
556 }
557
558 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
559 Err(SecretsError::ReadOnly)
560 }
561
562 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
563 Err(SecretsError::ReadOnly)
564 }
565
566 fn backend_name(&self) -> &'static str {
567 FILE_BACKEND_LABEL
568 }
569}
570
571#[derive(Clone)]
572struct ReadOnlyKeyringStore {
573 inner: Arc<dyn KeyringStore>,
574}
575
576impl ReadOnlyKeyringStore {
577 fn new(inner: Arc<dyn KeyringStore>) -> Self {
578 Self { inner }
579 }
580}
581
582impl KeyringStore for ReadOnlyKeyringStore {
583 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
584 self.inner.get(key)
585 }
586
587 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
588 Err(SecretsError::ReadOnly)
589 }
590
591 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
592 Err(SecretsError::ReadOnly)
593 }
594
595 fn backend_name(&self) -> &'static str {
596 self.inner.backend_name()
597 }
598}
599
600fn write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
601 atomic_write_private_file(path, body)
602}
603
604fn atomic_write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
605 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
606 fs::create_dir_all(parent)?;
607 }
608 let dir = path
609 .parent()
610 .filter(|p| !p.as_os_str().is_empty())
611 .unwrap_or_else(|| Path::new("."));
612 let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(SecretsError::Io)?;
613 use std::io::Write as _;
614 tmp.write_all(body).map_err(SecretsError::Io)?;
615 tmp.flush().map_err(SecretsError::Io)?;
616 tmp.as_file().sync_all().map_err(SecretsError::Io)?;
617 #[cfg(unix)]
618 {
619 use std::os::unix::fs::PermissionsExt;
620 let perms = fs::Permissions::from_mode(0o600);
621 tmp.as_file()
622 .set_permissions(perms)
623 .map_err(SecretsError::Io)?;
624 }
625 tmp.persist(path).map_err(|e| SecretsError::Io(e.error))?;
626 Ok(())
627}
628
629impl KeyringStore for FileKeyringStore {
630 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
631 let blob = self.load_unlocked()?;
632 Ok(blob.entries.get(key).cloned())
633 }
634
635 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
636 let mut blob = self.load_unlocked()?;
642 blob.entries.insert(key.to_string(), value.to_string());
643 self.store_unlocked(&blob)
644 }
645
646 fn delete(&self, key: &str) -> Result<(), SecretsError> {
647 let mut blob = self.load_unlocked()?;
650 blob.entries.remove(key);
651 self.store_unlocked(&blob)
652 }
653
654 fn backend_name(&self) -> &'static str {
655 FILE_BACKEND_LABEL
656 }
657}
658
659fn default_codewhale_secrets_path() -> Result<PathBuf, SecretsError> {
660 Ok(codewhale_paths::codewhale_home()
661 .map_err(|error| {
662 SecretsError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, error))
663 })?
664 .ok_or_else(home_resolution_error)?
665 .join("secrets")
666 .join("secrets.json"))
667}
668
669fn legacy_deepseek_secrets_path() -> Result<PathBuf, SecretsError> {
670 Ok(codewhale_paths::legacy_deepseek_home()
671 .ok_or_else(home_resolution_error)?
672 .join("secrets")
673 .join("secrets.json"))
674}
675
676fn home_resolution_error() -> SecretsError {
677 SecretsError::Io(std::io::Error::new(
678 std::io::ErrorKind::NotFound,
679 "could not resolve home directory for FileKeyringStore",
680 ))
681}
682
683#[derive(Debug, Clone, Copy, PartialEq, Eq)]
684enum SecretBackendSelection {
685 File,
686 System,
687 Unknown,
688}
689
690#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
695#[serde(rename_all = "snake_case")]
696pub enum SecretBackendDiagnosticKind {
697 File,
699 System,
701 Unknown,
703}
704
705#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
707#[serde(rename_all = "snake_case")]
708pub enum SecretBackendPresence {
709 Present,
711 Absent,
713 Unknown,
715}
716
717#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
719#[serde(rename_all = "snake_case")]
720pub enum SecretBackendInspection {
721 MetadataOnly,
723 NotProbed,
725}
726
727#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
734pub struct SecretBackendDiagnostic {
735 pub backend: SecretBackendDiagnosticKind,
737 pub inspection: SecretBackendInspection,
739 pub path: Option<PathBuf>,
741 pub presence: SecretBackendPresence,
743 pub legacy_path: Option<PathBuf>,
745 pub legacy_presence: SecretBackendPresence,
747}
748
749#[must_use]
756pub fn diagnose_secret_backend() -> SecretBackendDiagnostic {
757 match secret_backend_selection(configured_secret_backend().as_deref()) {
758 SecretBackendSelection::File => {
759 let (path, legacy_path) = FileKeyringStore::default_paths_read_only()
760 .map(|(path, legacy)| (Some(path), legacy))
761 .unwrap_or((None, None));
762 SecretBackendDiagnostic {
763 backend: SecretBackendDiagnosticKind::File,
764 inspection: SecretBackendInspection::MetadataOnly,
765 presence: metadata_presence(path.as_deref()),
766 legacy_presence: metadata_presence(legacy_path.as_deref()),
767 path,
768 legacy_path,
769 }
770 }
771 SecretBackendSelection::System => SecretBackendDiagnostic {
772 backend: SecretBackendDiagnosticKind::System,
773 inspection: SecretBackendInspection::NotProbed,
774 path: None,
775 presence: SecretBackendPresence::Unknown,
776 legacy_path: None,
777 legacy_presence: SecretBackendPresence::Unknown,
778 },
779 SecretBackendSelection::Unknown => SecretBackendDiagnostic {
780 backend: SecretBackendDiagnosticKind::Unknown,
781 inspection: SecretBackendInspection::NotProbed,
782 path: None,
783 presence: SecretBackendPresence::Unknown,
784 legacy_path: None,
785 legacy_presence: SecretBackendPresence::Unknown,
786 },
787 }
788}
789
790fn metadata_presence(path: Option<&Path>) -> SecretBackendPresence {
791 let Some(path) = path else {
792 return SecretBackendPresence::Unknown;
793 };
794 if let Some(parent) = path.parent() {
795 for ancestor in parent.ancestors() {
796 if ancestor.as_os_str().is_empty() {
797 continue;
798 }
799 match fs::symlink_metadata(ancestor) {
800 Ok(metadata)
801 if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() =>
802 {
803 return SecretBackendPresence::Unknown;
804 }
805 Ok(_) => {}
806 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
807 return SecretBackendPresence::Absent;
808 }
809 Err(_) => return SecretBackendPresence::Unknown,
810 }
811 }
812 }
813 match fs::symlink_metadata(path) {
814 Ok(metadata) if metadata.file_type().is_file() => SecretBackendPresence::Present,
815 Ok(_) => SecretBackendPresence::Unknown,
816 Err(error) if error.kind() == std::io::ErrorKind::NotFound => SecretBackendPresence::Absent,
817 Err(_) => SecretBackendPresence::Unknown,
818 }
819}
820
821fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
822 match value.map(str::trim).filter(|value| !value.is_empty()) {
823 None => SecretBackendSelection::File,
824 Some(value) => match value.to_ascii_lowercase().as_str() {
825 "file" | "local" | "json" => SecretBackendSelection::File,
826 "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
827 _ => SecretBackendSelection::Unknown,
828 },
829 }
830}
831
832fn configured_secret_backend() -> Option<String> {
833 std::env::var(SECRET_BACKEND_ENV)
834 .ok()
835 .filter(|value| !value.trim().is_empty())
836 .or_else(|| std::env::var(LEGACY_SECRET_BACKEND_ENV).ok())
837}
838
839#[derive(Clone)]
856pub struct Secrets {
857 pub store: Arc<dyn KeyringStore>,
859 service: String,
864}
865
866#[derive(Debug, Clone, Copy, PartialEq, Eq)]
872pub enum SecretSource {
873 Keyring,
875 Env,
877}
878
879impl std::fmt::Debug for Secrets {
880 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
881 f.debug_struct("Secrets")
882 .field("backend", &self.store.backend_name())
883 .field("service", &self.service)
884 .finish()
885 }
886}
887
888impl Secrets {
889 #[must_use]
892 pub fn new(store: Arc<dyn KeyringStore>) -> Self {
893 Self {
894 store,
895 service: DEFAULT_SERVICE.to_string(),
896 }
897 }
898
899 pub fn auto_detect() -> Self {
910 match secret_backend_selection(configured_secret_backend().as_deref()) {
911 SecretBackendSelection::File => Self::file_backed_default(),
912 SecretBackendSelection::Unknown => {
913 tracing::warn!(
914 "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
915 );
916 Self::file_backed_default()
917 }
918 SecretBackendSelection::System => {
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 }
932
933 #[must_use]
942 pub fn auto_detect_read_only() -> Self {
943 match secret_backend_selection(configured_secret_backend().as_deref()) {
944 SecretBackendSelection::File => Self::file_backed_read_only(),
945 SecretBackendSelection::Unknown => {
946 tracing::warn!(
947 "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
948 );
949 Self::file_backed_read_only()
950 }
951 SecretBackendSelection::System => {
952 let default_store = DefaultKeyringStore::default();
953 match default_store.probe() {
954 Ok(()) => {
955 Self::new(Arc::new(ReadOnlyKeyringStore::new(Arc::new(default_store))))
956 }
957 Err(err) => {
958 tracing::warn!(
959 "OS keyring unavailable ({err}); falling back to file-backed secret store"
960 );
961 Self::file_backed_read_only()
962 }
963 }
964 }
965 }
966 }
967
968 fn file_backed_default() -> Self {
969 Self::file_backed_from_default_path(FileKeyringStore::default_path())
970 }
971
972 fn file_backed_from_default_path(path_result: Result<PathBuf, SecretsError>) -> Self {
978 match path_result {
982 Ok(path) if path.is_absolute() => Self::new(Arc::new(FileKeyringStore::new(path))),
983 Ok(path) => {
984 tracing::error!(
985 "refusing relative file-backed secret path {}; credentials will not be read or persisted",
986 path.display()
987 );
988 Self::read_only_empty_store()
989 }
990 Err(err) => {
991 tracing::error!(
992 "could not resolve file-backed secret path ({err}); credentials will not be read or persisted"
993 );
994 Self::read_only_empty_store()
995 }
996 }
997 }
998
999 fn read_only_empty_store() -> Self {
1003 Self::new(Arc::new(ReadOnlyKeyringStore::new(Arc::new(
1004 InMemoryKeyringStore::new(),
1005 ))))
1006 }
1007
1008 #[must_use]
1017 pub fn file_backed_read_only() -> Self {
1018 match ReadOnlyFileKeyringStore::default_for_diagnostics() {
1023 Ok(store) => Self::new(Arc::new(store)),
1024 Err(err) => {
1025 tracing::error!(
1026 "could not resolve the file-backed secret path ({err}); credentials will not be read. \
1027 Fix: set CODEWHALE_HOME to an absolute path or make HOME/USERPROFILE resolvable"
1028 );
1029 Self::read_only_empty_store()
1030 }
1031 }
1032 }
1033
1034 #[must_use]
1036 pub fn file_backed() -> Self {
1037 Self::file_backed_default()
1038 }
1039
1040 #[must_use]
1043 pub fn system_keyring() -> Self {
1044 let default_store = DefaultKeyringStore::default();
1045 match default_store.probe() {
1046 Ok(()) => Self::new(Arc::new(default_store)),
1047 Err(err) => {
1048 tracing::warn!(
1049 "OS keyring unavailable ({err}); falling back to file-backed secret store"
1050 );
1051 Self::file_backed_default()
1052 }
1053 }
1054 }
1055
1056 #[must_use]
1058 pub fn backend_name(&self) -> &'static str {
1059 self.store.backend_name()
1060 }
1061
1062 #[must_use]
1067 pub fn resolve(&self, name: &str) -> Option<String> {
1068 self.resolve_with_source(name).map(|(value, _)| value)
1069 }
1070
1071 #[must_use]
1073 pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
1074 if let Ok(Some(v)) = self.store.get(name)
1075 && !v.trim().is_empty()
1076 {
1077 return Some((v, SecretSource::Keyring));
1078 }
1079 env_for(name).map(|value| (value, SecretSource::Env))
1080 }
1081
1082 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
1084 self.store.set(name, value)
1085 }
1086
1087 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
1089 self.store.delete(name)
1090 }
1091
1092 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
1094 self.store.get(name)
1095 }
1096
1097 #[must_use]
1109 pub fn resolve_direct(&self, key: &str, source_hint: Option<&str>) -> Option<String> {
1110 match source_hint {
1111 Some("env") => {
1112 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
1114 }
1115 Some("keyring") | Some("file") => {
1116 self.store
1118 .get(key)
1119 .ok()
1120 .flatten()
1121 .filter(|v| !v.trim().is_empty())
1122 }
1123 Some(_) | None => {
1124 if let Ok(Some(v)) = self.store.get(key)
1126 && !v.trim().is_empty()
1127 {
1128 return Some(v);
1129 }
1130 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
1131 }
1132 }
1133 }
1134}
1135
1136#[must_use]
1171pub fn env_for(name: &str) -> Option<String> {
1172 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
1173 "deepseek" => &["DEEPSEEK_API_KEY"],
1174 "openrouter" => &["OPENROUTER_API_KEY"],
1175 "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
1176 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
1177 }
1178 "novita" | "novita-ai" | "novita_ai" => &["NOVITA_API_KEY"],
1179 "together" | "together-ai" | "together_ai" | "togetherai" => &["TOGETHER_API_KEY"],
1180 "deepinfra" | "deep-infra" | "deep_infra" => &["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
1181 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
1185 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
1186 }
1187 "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
1188 "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
1189 | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
1190 "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
1191 "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
1192 "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
1193 "vllm" | "v-llm" => &["VLLM_API_KEY"],
1194 "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
1195 "ollama-cloud" | "ollama_cloud" => &["OLLAMA_CLOUD_API_KEY", "OLLAMA_API_KEY"],
1196 "openai" => &["OPENAI_API_KEY"],
1197 "anthropic" | "claude" => &["ANTHROPIC_API_KEY"],
1198 "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
1199 "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
1200 | "volcengineark" => &[
1201 "VOLCENGINE_API_KEY",
1202 "VOLCENGINE_ARK_API_KEY",
1203 "ARK_API_KEY",
1204 ],
1205 "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
1206 | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
1207 "WANJIE_ARK_API_KEY",
1208 "WANJIE_API_KEY",
1209 "WANJIE_MAAS_API_KEY",
1210 ],
1211 "sakana" | "sakana-ai" | "sakana_ai" | "fugu" => &["FUGU_API_KEY", "SAKANA_API_KEY"],
1212 "longcat" | "long-cat" | "meituan-longcat" | "meituan" => &["LONGCAT_API_KEY"],
1213 "opencode-go" | "opencode_go" | "opencodego" => &["OPENCODE_GO_API_KEY"],
1214 "opencode-zen" | "opencode_zen" | "opencodezen" | "zen" | "opencode" => {
1215 &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"]
1216 }
1217 "meta" | "meta-ai" | "meta_ai" | "meta-model-api" | "meta_model_api" | "muse"
1218 | "muse-spark" => &["META_MODEL_API_KEY", "MODEL_API_KEY"],
1219 "xai" | "x-ai" | "x_ai" | "grok" => &["XAI_API_KEY"],
1220 "telecomjs" | "telecom-js" | "telecom_js" | "telecomjs-cn" | "tokenhub" => {
1221 &["TELECOMJS_API_KEY"]
1222 }
1223 "edenai" | "eden-ai" | "eden_ai" => &["EDENAI_API_KEY"],
1224 "modelstudio-token-plan"
1227 | "modelstudio_token_plan"
1228 | "modelstudio-token-plan-anthropic"
1229 | "modelstudio_token_plan_anthropic"
1230 | "modelstudio-coding-plan"
1231 | "modelstudio_coding_plan"
1232 | "modelstudio-coding-plan-anthropic"
1233 | "modelstudio_coding_plan_anthropic"
1234 | "modelstudio"
1235 | "dashscope"
1236 | "alibaba-token-plan"
1237 | "alibaba-coding-plan" => &["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"],
1238 _ => return None,
1239 };
1240 for var in candidates {
1241 if let Ok(value) = std::env::var(var)
1242 && !value.trim().is_empty()
1243 {
1244 return Some(value);
1245 }
1246 }
1247 None
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252 use super::*;
1253 use std::sync::{Mutex, OnceLock};
1254
1255 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1258 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1259 LOCK.get_or_init(|| Mutex::new(()))
1260 .lock()
1261 .unwrap_or_else(|p| p.into_inner())
1262 }
1263
1264 fn clear_known_envs() {
1265 for var in [
1266 "CODEWHALE_HOME",
1267 "DEEPSEEK_API_KEY",
1268 "OPENROUTER_API_KEY",
1269 "NOVITA_API_KEY",
1270 "NVIDIA_API_KEY",
1271 "NVIDIA_NIM_API_KEY",
1272 "FIREWORKS_API_KEY",
1273 "TOGETHER_API_KEY",
1274 "DEEPINFRA_API_KEY",
1275 "DEEPINFRA_TOKEN",
1276 "SILICONFLOW_API_KEY",
1277 "ARCEE_API_KEY",
1278 "SGLANG_API_KEY",
1279 "VLLM_API_KEY",
1280 "OLLAMA_API_KEY",
1281 "OLLAMA_CLOUD_API_KEY",
1282 "OPENAI_API_KEY",
1283 "ATLASCLOUD_API_KEY",
1284 "WANJIE_ARK_API_KEY",
1285 "WANJIE_API_KEY",
1286 "WANJIE_MAAS_API_KEY",
1287 "XIAOMI_MIMO_API_KEY",
1288 "XIAOMI_API_KEY",
1289 "MIMO_API_KEY",
1290 "FUGU_API_KEY",
1291 "SAKANA_API_KEY",
1292 "LONGCAT_API_KEY",
1293 "OPENCODE_GO_API_KEY",
1294 "OPENCODE_ZEN_API_KEY",
1295 "OPENCODE_API_KEY",
1296 "META_MODEL_API_KEY",
1297 "MODEL_API_KEY",
1298 "XAI_API_KEY",
1299 "TELECOMJS_API_KEY",
1300 "EDENAI_API_KEY",
1301 "MODELSTUDIO_API_KEY",
1302 "DASHSCOPE_API_KEY",
1303 SECRET_BACKEND_ENV,
1304 LEGACY_SECRET_BACKEND_ENV,
1305 ] {
1306 unsafe { std::env::remove_var(var) };
1309 }
1310 }
1311
1312 struct EnvVarGuard {
1313 name: &'static str,
1314 previous: Option<std::ffi::OsString>,
1315 }
1316
1317 impl EnvVarGuard {
1318 fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
1319 let previous = std::env::var_os(name);
1320 unsafe { std::env::set_var(name, value) };
1321 Self { name, previous }
1322 }
1323 }
1324
1325 impl Drop for EnvVarGuard {
1326 fn drop(&mut self) {
1327 match self.previous.take() {
1328 Some(value) => unsafe { std::env::set_var(self.name, value) },
1329 None => unsafe { std::env::remove_var(self.name) },
1330 }
1331 }
1332 }
1333
1334 #[test]
1340 #[ignore = "touches the real OS keyring; run on a desktop machine"]
1341 fn probe_performs_a_real_backend_read() {
1342 let store = DefaultKeyringStore::new("codewhale-probe-live-check");
1343 store
1344 .probe()
1345 .expect("the native keyring backend should be reachable on this machine");
1346 }
1347
1348 #[test]
1349 fn backend_selection_defaults_to_file() {
1350 assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
1351 assert_eq!(
1352 secret_backend_selection(Some("")),
1353 SecretBackendSelection::File
1354 );
1355 assert_eq!(
1356 secret_backend_selection(Some(" file ")),
1357 SecretBackendSelection::File
1358 );
1359 }
1360
1361 #[test]
1362 fn backend_selection_accepts_explicit_system_keyring() {
1363 assert_eq!(
1364 secret_backend_selection(Some("system")),
1365 SecretBackendSelection::System
1366 );
1367 assert_eq!(
1368 secret_backend_selection(Some("keyring")),
1369 SecretBackendSelection::System
1370 );
1371 assert_eq!(
1372 secret_backend_selection(Some("os-keyring")),
1373 SecretBackendSelection::System
1374 );
1375 }
1376
1377 #[test]
1378 fn auto_detect_is_file_backed_by_default() {
1379 let _lock = env_lock();
1380 clear_known_envs();
1381 let tmp = tempfile::tempdir().unwrap();
1382 let _home = EnvVarGuard::set("HOME", tmp.path());
1383 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1384
1385 let secrets = Secrets::auto_detect();
1386
1387 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1388 }
1389
1390 #[test]
1391 fn auto_detect_honors_explicit_file_backend() {
1392 let _lock = env_lock();
1393 clear_known_envs();
1394 let tmp = tempfile::tempdir().unwrap();
1395 let _home = EnvVarGuard::set("HOME", tmp.path());
1396 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1397 unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
1399
1400 let secrets = Secrets::auto_detect();
1401
1402 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1403 unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
1405 }
1406
1407 #[test]
1408 fn read_only_auto_detect_reads_legacy_without_migrating_or_allowing_writes() {
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 _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1415 let legacy = tmp
1416 .path()
1417 .join(".deepseek")
1418 .join("secrets")
1419 .join("secrets.json");
1420 let primary = tmp
1421 .path()
1422 .join(".codewhale")
1423 .join("secrets")
1424 .join("secrets.json");
1425 FileKeyringStore::new(&legacy)
1426 .set("moonshot", "fixture-legacy-value")
1427 .unwrap();
1428
1429 let secrets = Secrets::auto_detect_read_only();
1430
1431 assert_eq!(
1432 secrets.get("moonshot").unwrap().as_deref(),
1433 Some("fixture-legacy-value")
1434 );
1435 assert!(
1436 !primary.exists(),
1437 "diagnostic lookup must not migrate the legacy store"
1438 );
1439 assert!(
1440 matches!(
1441 secrets.set("moonshot", "replacement"),
1442 Err(SecretsError::ReadOnly)
1443 ),
1444 "the diagnostic secret facade must refuse writes"
1445 );
1446 assert!(
1447 !primary.exists(),
1448 "a refused diagnostic write must not create the primary store"
1449 );
1450 }
1451
1452 #[test]
1453 fn read_only_auto_detect_respects_explicit_codewhale_home_isolation() {
1454 let _lock = env_lock();
1455 clear_known_envs();
1456 let tmp = tempfile::tempdir().unwrap();
1457 let codewhale_home = tmp.path().join("isolated-codewhale-home");
1458 let _home = EnvVarGuard::set("HOME", tmp.path());
1459 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1460 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1461 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1462 let legacy = tmp
1463 .path()
1464 .join(".deepseek")
1465 .join("secrets")
1466 .join("secrets.json");
1467 let primary = codewhale_home.join("secrets").join("secrets.json");
1468 FileKeyringStore::new(&legacy)
1469 .set("deepseek", "synthetic-ambient-legacy-value")
1470 .unwrap();
1471
1472 let secrets = Secrets::auto_detect_read_only();
1473
1474 assert_eq!(
1475 secrets.get("deepseek").unwrap(),
1476 None,
1477 "an explicit CODEWHALE_HOME must not read ambient legacy secrets"
1478 );
1479 assert!(
1480 !primary.exists(),
1481 "diagnostic lookup must not create an isolated primary store"
1482 );
1483 assert!(
1484 matches!(
1485 secrets.set("deepseek", "replacement"),
1486 Err(SecretsError::ReadOnly)
1487 ),
1488 "the isolated diagnostic facade must refuse writes"
1489 );
1490 assert!(
1491 !primary.exists(),
1492 "a refused isolated diagnostic write must not create the primary store"
1493 );
1494 }
1495
1496 struct CwdGuard {
1499 previous: PathBuf,
1500 }
1501
1502 impl CwdGuard {
1503 fn enter(path: &Path) -> Self {
1504 let previous = std::env::current_dir().unwrap();
1505 std::env::set_current_dir(path).unwrap();
1506 Self { previous }
1507 }
1508 }
1509
1510 impl Drop for CwdGuard {
1511 fn drop(&mut self) {
1512 std::env::set_current_dir(&self.previous).unwrap();
1513 }
1514 }
1515
1516 #[test]
1517 fn file_backed_read_only_never_reads_a_cwd_relative_store() {
1518 let _lock = env_lock();
1519 clear_known_envs();
1520 let tmp = tempfile::tempdir().unwrap();
1521 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", "relative-codewhale-home");
1524 let planted = tmp.path().join(".codewhale-secrets.json");
1525 std::fs::write(
1526 &planted,
1527 r#"{"entries":{"deepseek":"planted-cwd-credential"}}"#,
1528 )
1529 .unwrap();
1530 let _cwd = CwdGuard::enter(tmp.path());
1531
1532 let secrets = Secrets::file_backed_read_only();
1533
1534 assert_eq!(
1535 secrets.get("deepseek").unwrap(),
1536 None,
1537 "a failed home resolution must not turn a planted cwd file into the credential store"
1538 );
1539 assert!(
1540 matches!(
1541 secrets.set("deepseek", "replacement"),
1542 Err(SecretsError::ReadOnly)
1543 ),
1544 "the failed-resolution diagnostic facade must still refuse writes"
1545 );
1546 }
1547
1548 #[test]
1549 fn read_only_auto_detect_reads_the_explicit_primary_store() {
1550 let _lock = env_lock();
1551 clear_known_envs();
1552 let tmp = tempfile::tempdir().unwrap();
1553 let codewhale_home = tmp.path().join("isolated-codewhale-home");
1554 let _home = EnvVarGuard::set("HOME", tmp.path());
1555 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1556 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1557 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1558 let primary = codewhale_home.join("secrets").join("secrets.json");
1559 FileKeyringStore::new(&primary)
1560 .set("deepseek", "synthetic-isolated-primary-value")
1561 .unwrap();
1562
1563 let secrets = Secrets::auto_detect_read_only();
1564
1565 assert_eq!(
1566 secrets.get("deepseek").unwrap().as_deref(),
1567 Some("synthetic-isolated-primary-value")
1568 );
1569 }
1570
1571 #[test]
1572 fn auto_detect_honors_legacy_backend_env_alias() {
1573 let _lock = env_lock();
1574 clear_known_envs();
1575 let tmp = tempfile::tempdir().unwrap();
1576 let _home = EnvVarGuard::set("HOME", tmp.path());
1577 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1578 unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
1579
1580 let secrets = Secrets::auto_detect();
1581
1582 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1583 clear_known_envs();
1584 }
1585
1586 #[test]
1587 fn file_default_path_uses_codewhale_home() {
1588 let _lock = env_lock();
1589 clear_known_envs();
1590 let tmp = tempfile::tempdir().unwrap();
1591 let _home = EnvVarGuard::set("HOME", tmp.path());
1592 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1593
1594 let path = FileKeyringStore::default_path().unwrap();
1595
1596 assert_eq!(
1597 path,
1598 tmp.path()
1599 .join(".codewhale")
1600 .join("secrets")
1601 .join("secrets.json")
1602 );
1603 }
1604
1605 #[test]
1606 fn file_default_path_honors_codewhale_home() {
1607 let _lock = env_lock();
1608 clear_known_envs();
1609 let tmp = tempfile::tempdir().unwrap();
1610 let custom = tmp.path().join("custom-codewhale");
1611 let _home = EnvVarGuard::set("HOME", tmp.path());
1612 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1613 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
1614
1615 let path = FileKeyringStore::default_path().unwrap();
1616
1617 assert_eq!(path, custom.join("secrets").join("secrets.json"));
1618 }
1619
1620 #[test]
1621 fn file_default_path_migrates_legacy_entries_to_codewhale() {
1622 let _lock = env_lock();
1623 clear_known_envs();
1624 let tmp = tempfile::tempdir().unwrap();
1625 let _home = EnvVarGuard::set("HOME", tmp.path());
1626 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1627 let legacy = tmp
1628 .path()
1629 .join(".deepseek")
1630 .join("secrets")
1631 .join("secrets.json");
1632 FileKeyringStore::new(legacy.clone())
1633 .set("xiaomi-mimo", "legacy-mimo")
1634 .unwrap();
1635
1636 let primary = FileKeyringStore::default_path().unwrap();
1637 let primary_store = FileKeyringStore::new(primary.clone());
1638
1639 assert_eq!(
1640 primary,
1641 tmp.path()
1642 .join(".codewhale")
1643 .join("secrets")
1644 .join("secrets.json")
1645 );
1646 assert_eq!(
1647 primary_store.get("xiaomi-mimo").unwrap().as_deref(),
1648 Some("legacy-mimo")
1649 );
1650 assert!(
1651 legacy.exists(),
1652 "migration copies; it does not delete legacy data"
1653 );
1654 }
1655
1656 #[test]
1657 fn file_default_path_migration_preserves_primary_values() {
1658 let _lock = env_lock();
1659 clear_known_envs();
1660 let tmp = tempfile::tempdir().unwrap();
1661 let _home = EnvVarGuard::set("HOME", tmp.path());
1662 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1663 let legacy = tmp
1664 .path()
1665 .join(".deepseek")
1666 .join("secrets")
1667 .join("secrets.json");
1668 let primary = tmp
1669 .path()
1670 .join(".codewhale")
1671 .join("secrets")
1672 .join("secrets.json");
1673 FileKeyringStore::new(legacy)
1674 .set("openrouter", "legacy-openrouter")
1675 .unwrap();
1676 let primary_store = FileKeyringStore::new(primary.clone());
1677 primary_store
1678 .set("openrouter", "primary-openrouter")
1679 .unwrap();
1680
1681 let resolved = FileKeyringStore::default_path().unwrap();
1682
1683 assert_eq!(resolved, primary);
1684 assert_eq!(
1685 primary_store.get("openrouter").unwrap().as_deref(),
1686 Some("primary-openrouter")
1687 );
1688 }
1689
1690 #[test]
1691 fn in_memory_store_round_trips() {
1692 let store = InMemoryKeyringStore::new();
1693 assert_eq!(store.get("deepseek").unwrap(), None);
1694 store.set("deepseek", "sk-test").unwrap();
1695 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
1696 store.set("deepseek", "sk-replaced").unwrap();
1697 assert_eq!(
1698 store.get("deepseek").unwrap(),
1699 Some("sk-replaced".to_string())
1700 );
1701 store.delete("deepseek").unwrap();
1702 assert_eq!(store.get("deepseek").unwrap(), None);
1703 store.delete("missing").unwrap();
1705 }
1706
1707 #[test]
1708 fn resolve_prefers_keyring_over_env() {
1709 let _lock = env_lock();
1710 clear_known_envs();
1711 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1713
1714 let store = Arc::new(InMemoryKeyringStore::new());
1715 store.set("deepseek", "ring-key").unwrap();
1716 let secrets = Secrets::new(store);
1717
1718 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1719 assert_eq!(
1720 secrets.resolve_with_source("deepseek"),
1721 Some(("ring-key".to_string(), SecretSource::Keyring))
1722 );
1723 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1725 }
1726
1727 #[test]
1728 fn resolve_falls_back_to_env_when_keyring_empty() {
1729 let _lock = env_lock();
1730 clear_known_envs();
1731 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1733
1734 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1735 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1736 assert_eq!(
1737 secrets.resolve_with_source("deepseek"),
1738 Some(("env-fallback".to_string(), SecretSource::Env))
1739 );
1740 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1742 }
1743
1744 #[test]
1745 fn resolve_returns_none_when_both_layers_empty() {
1746 let _lock = env_lock();
1747 clear_known_envs();
1748 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1749 assert_eq!(secrets.resolve("deepseek"), None);
1750 }
1751
1752 #[test]
1753 fn resolve_treats_blank_keyring_value_as_unset() {
1754 let _lock = env_lock();
1755 clear_known_envs();
1756 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1758
1759 let store = Arc::new(InMemoryKeyringStore::new());
1760 store.set("deepseek", " ").unwrap();
1761 let secrets = Secrets::new(store);
1762 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1763 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1765 }
1766
1767 #[test]
1768 fn nvidia_env_aliases_resolve() {
1769 let _lock = env_lock();
1770 clear_known_envs();
1771 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1773 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1774 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1775 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1776 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1778 }
1779
1780 #[test]
1781 fn atlascloud_env_aliases_resolve() {
1782 let _guard = env_lock();
1783 clear_known_envs();
1784 unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1785
1786 assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1787 assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1788 assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1789
1790 clear_known_envs();
1791 }
1792
1793 #[test]
1794 fn sakana_env_aliases_resolve() {
1795 let _guard = env_lock();
1796 clear_known_envs();
1797 unsafe { std::env::set_var("FUGU_API_KEY", "fugu-key") };
1798
1799 assert_eq!(env_for("sakana").as_deref(), Some("fugu-key"));
1800 assert_eq!(env_for("sakana-ai").as_deref(), Some("fugu-key"));
1801 assert_eq!(env_for("sakana_ai").as_deref(), Some("fugu-key"));
1802 assert_eq!(env_for("fugu").as_deref(), Some("fugu-key"));
1803
1804 clear_known_envs();
1805 unsafe { std::env::set_var("SAKANA_API_KEY", "sakana-key") };
1806 assert_eq!(env_for("sakana").as_deref(), Some("sakana-key"));
1807
1808 clear_known_envs();
1809 }
1810
1811 #[test]
1812 fn wanjie_ark_env_aliases_resolve() {
1813 let _guard = env_lock();
1814 clear_known_envs();
1815 unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1816
1817 assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1818 assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1819 assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1820
1821 clear_known_envs();
1822 }
1823
1824 #[test]
1825 fn xai_env_aliases_resolve() {
1826 let _guard = env_lock();
1827 clear_known_envs();
1828 unsafe { std::env::set_var("XAI_API_KEY", "xai-key") };
1829
1830 assert_eq!(env_for("xai").as_deref(), Some("xai-key"));
1831 assert_eq!(env_for("x-ai").as_deref(), Some("xai-key"));
1832 assert_eq!(env_for("x_ai").as_deref(), Some("xai-key"));
1833 assert_eq!(env_for("grok").as_deref(), Some("xai-key"));
1834
1835 clear_known_envs();
1836 }
1837
1838 #[test]
1839 fn telecomjs_env_aliases_resolve() {
1840 let _guard = env_lock();
1841 clear_known_envs();
1842 unsafe { std::env::set_var("TELECOMJS_API_KEY", "telecom-key") };
1843
1844 for alias in [
1845 "telecomjs",
1846 "telecom-js",
1847 "telecom_js",
1848 "telecomjs-cn",
1849 "tokenhub",
1850 ] {
1851 assert_eq!(env_for(alias).as_deref(), Some("telecom-key"), "{alias}");
1852 }
1853
1854 clear_known_envs();
1855 }
1856
1857 #[test]
1858 fn edenai_env_aliases_resolve() {
1859 let _guard = env_lock();
1860 clear_known_envs();
1861 unsafe { std::env::set_var("EDENAI_API_KEY", "eden-key") };
1862
1863 for alias in ["edenai", "eden-ai", "eden_ai"] {
1864 assert_eq!(env_for(alias).as_deref(), Some("eden-key"), "{alias}");
1865 }
1866
1867 clear_known_envs();
1868 }
1869
1870 #[test]
1871 fn opencode_go_env_aliases_resolve() {
1872 let _guard = env_lock();
1873 clear_known_envs();
1874 unsafe { std::env::set_var("OPENCODE_GO_API_KEY", "go-key") };
1875
1876 for alias in ["opencode-go", "opencode_go", "opencodego"] {
1877 assert_eq!(env_for(alias).as_deref(), Some("go-key"), "{alias}");
1878 }
1879
1880 clear_known_envs();
1881 }
1882
1883 #[test]
1884 fn modelstudio_variants_share_one_env_convention() {
1885 let _guard = env_lock();
1886 clear_known_envs();
1887 unsafe { std::env::set_var("MODELSTUDIO_API_KEY", "ms-key") };
1888
1889 for alias in [
1890 "modelstudio-token-plan",
1891 "modelstudio-token-plan-anthropic",
1892 "modelstudio-coding-plan",
1893 "modelstudio-coding-plan-anthropic",
1894 "modelstudio",
1895 "dashscope",
1896 "alibaba-token-plan",
1897 "alibaba-coding-plan",
1898 ] {
1899 assert_eq!(env_for(alias).as_deref(), Some("ms-key"), "{alias}");
1900 }
1901
1902 clear_known_envs();
1903 unsafe { std::env::set_var("DASHSCOPE_API_KEY", "dashscope-key") };
1904 assert_eq!(
1905 env_for("modelstudio-token-plan").as_deref(),
1906 Some("dashscope-key"),
1907 "DASHSCOPE_API_KEY is the fallback for the same account"
1908 );
1909
1910 clear_known_envs();
1911 }
1912
1913 #[test]
1914 fn opencode_zen_env_aliases_resolve() {
1915 let _guard = env_lock();
1916 clear_known_envs();
1917 unsafe { std::env::set_var("OPENCODE_ZEN_API_KEY", "zen-key") };
1918
1919 for alias in [
1920 "opencode-zen",
1921 "opencode_zen",
1922 "opencodezen",
1923 "zen",
1924 "opencode",
1925 ] {
1926 assert_eq!(env_for(alias).as_deref(), Some("zen-key"), "{alias}");
1927 }
1928
1929 clear_known_envs();
1930 }
1931
1932 #[test]
1933 fn meta_model_api_env_aliases_resolve() {
1934 let _guard = env_lock();
1935 clear_known_envs();
1936 unsafe { std::env::set_var("MODEL_API_KEY", "meta-key") };
1937
1938 for alias in [
1939 "meta",
1940 "meta-ai",
1941 "meta_ai",
1942 "meta-model-api",
1943 "meta_model_api",
1944 "muse",
1945 "muse-spark",
1946 ] {
1947 assert_eq!(env_for(alias).as_deref(), Some("meta-key"), "{alias}");
1948 }
1949
1950 clear_known_envs();
1951 unsafe { std::env::set_var("META_MODEL_API_KEY", "meta-prefixed-key") };
1952 assert_eq!(env_for("meta").as_deref(), Some("meta-prefixed-key"),);
1953
1954 clear_known_envs();
1955 }
1956
1957 #[test]
1958 fn xiaomi_mimo_env_aliases_resolve() {
1959 let _guard = env_lock();
1960 clear_known_envs();
1961 unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1962
1963 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1964 assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1965 assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1966 assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1967
1968 clear_known_envs();
1969
1970 unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1971 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1972 clear_known_envs();
1973 }
1974
1975 #[test]
1976 fn fireworks_env_aliases_resolve() {
1977 let _lock = env_lock();
1978 clear_known_envs();
1979 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1981
1982 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1983 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1984 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1986 }
1987
1988 #[test]
1989 fn together_env_aliases_resolve() {
1990 let _lock = env_lock();
1991 clear_known_envs();
1992 unsafe { std::env::set_var("TOGETHER_API_KEY", "together-key") };
1994
1995 assert_eq!(env_for("together").as_deref(), Some("together-key"));
1998 assert_eq!(env_for("together-ai").as_deref(), Some("together-key"));
1999 assert_eq!(env_for("together_ai").as_deref(), Some("together-key"));
2000 assert_eq!(env_for("togetherai").as_deref(), Some("together-key"));
2001 unsafe { std::env::remove_var("TOGETHER_API_KEY") };
2003 }
2004
2005 #[test]
2006 fn deepinfra_env_aliases_resolve() {
2007 let _lock = env_lock();
2008 clear_known_envs();
2009 unsafe { std::env::set_var("DEEPINFRA_API_KEY", "di-key") };
2011
2012 assert_eq!(env_for("deepinfra").as_deref(), Some("di-key"));
2013 assert_eq!(env_for("deep-infra").as_deref(), Some("di-key"));
2014 assert_eq!(env_for("deep_infra").as_deref(), Some("di-key"));
2015 unsafe { std::env::remove_var("DEEPINFRA_API_KEY") };
2017
2018 unsafe { std::env::set_var("DEEPINFRA_TOKEN", "di-token") };
2021 assert_eq!(env_for("deepinfra").as_deref(), Some("di-token"));
2022 unsafe { std::env::remove_var("DEEPINFRA_TOKEN") };
2024 }
2025
2026 #[test]
2027 fn novita_env_aliases_resolve() {
2028 let _lock = env_lock();
2029 clear_known_envs();
2030 unsafe { std::env::set_var("NOVITA_API_KEY", "novita-key") };
2032
2033 assert_eq!(env_for("novita").as_deref(), Some("novita-key"));
2034 assert_eq!(env_for("novita-ai").as_deref(), Some("novita-key"));
2036 assert_eq!(env_for("novita_ai").as_deref(), Some("novita-key"));
2037 unsafe { std::env::remove_var("NOVITA_API_KEY") };
2039 }
2040
2041 #[test]
2042 fn siliconflow_env_aliases_resolve() {
2043 let _lock = env_lock();
2044 clear_known_envs();
2045 unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
2047
2048 assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
2049 assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
2050 assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
2051 assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
2052 assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
2053 unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
2055 }
2056
2057 #[test]
2058 fn arcee_env_aliases_resolve() {
2059 let _lock = env_lock();
2060 clear_known_envs();
2061 unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
2063
2064 assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
2065 assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
2066 assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
2067 unsafe { std::env::remove_var("ARCEE_API_KEY") };
2069 }
2070
2071 #[test]
2072 fn moonshot_kimi_env_aliases_resolve() {
2073 let _lock = env_lock();
2074 clear_known_envs();
2075 unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
2077
2078 assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
2079 assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
2080 assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
2081 assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
2082 unsafe { std::env::remove_var("KIMI_API_KEY") };
2084 }
2085
2086 #[test]
2087 fn sglang_env_aliases_resolve() {
2088 let _lock = env_lock();
2089 clear_known_envs();
2090 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
2092
2093 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
2094 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
2095 unsafe { std::env::remove_var("SGLANG_API_KEY") };
2097 }
2098
2099 #[test]
2100 fn vllm_env_aliases_resolve() {
2101 let _lock = env_lock();
2102 clear_known_envs();
2103 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
2105
2106 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
2107 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
2108 unsafe { std::env::remove_var("VLLM_API_KEY") };
2110 }
2111
2112 #[test]
2113 fn ollama_env_aliases_resolve() {
2114 let _lock = env_lock();
2115 clear_known_envs();
2116 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
2118
2119 assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
2120 assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
2121 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
2123 }
2124
2125 #[test]
2126 fn ollama_cloud_env_prefers_pi_name_then_official_name() {
2127 let _lock = env_lock();
2128 clear_known_envs();
2129 unsafe {
2131 std::env::set_var("OLLAMA_CLOUD_API_KEY", "cloud-specific-key");
2132 std::env::set_var("OLLAMA_API_KEY", "official-fallback-key");
2133 }
2134
2135 assert_eq!(
2136 env_for("ollama-cloud").as_deref(),
2137 Some("cloud-specific-key")
2138 );
2139 assert_eq!(
2140 env_for("ollama_cloud").as_deref(),
2141 Some("cloud-specific-key")
2142 );
2143 assert_eq!(env_for("ollama").as_deref(), Some("official-fallback-key"));
2146
2147 unsafe { std::env::remove_var("OLLAMA_CLOUD_API_KEY") };
2149 assert_eq!(
2150 env_for("ollama-cloud").as_deref(),
2151 Some("official-fallback-key")
2152 );
2153 clear_known_envs();
2154 }
2155
2156 #[cfg(unix)]
2157 #[test]
2158 fn file_store_round_trips_with_secure_perms() {
2159 use std::os::unix::fs::PermissionsExt;
2160
2161 let tmp = tempfile::tempdir().unwrap();
2162 let path = tmp.path().join("nested").join("secrets.json");
2163 let store = FileKeyringStore::new(path.clone());
2164 assert_eq!(store.get("deepseek").unwrap(), None);
2165 store.set("deepseek", "sk-disk").unwrap();
2166 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
2167
2168 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2169 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
2170
2171 store.set("openrouter", "or-disk").unwrap();
2172 assert_eq!(
2173 store.get("openrouter").unwrap(),
2174 Some("or-disk".to_string())
2175 );
2176 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
2178
2179 store.delete("deepseek").unwrap();
2180 assert_eq!(store.get("deepseek").unwrap(), None);
2181 }
2182
2183 #[cfg(unix)]
2184 #[test]
2185 fn file_store_rejects_world_readable_file() {
2186 use std::os::unix::fs::PermissionsExt;
2187 let tmp = tempfile::tempdir().unwrap();
2188 let path = tmp.path().join("secrets.json");
2189 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
2190 let mut perms = fs::metadata(&path).unwrap().permissions();
2191 perms.set_mode(0o644);
2192 fs::set_permissions(&path, perms).unwrap();
2193
2194 let store = FileKeyringStore::new(path);
2195 let err = store.get("deepseek").unwrap_err();
2196 assert!(
2197 matches!(err, SecretsError::InsecurePermissions { .. }),
2198 "unexpected error: {err}"
2199 );
2200 }
2201
2202 #[cfg(unix)]
2208 #[test]
2209 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
2210 use std::os::unix::fs::PermissionsExt;
2211 let tmp = tempfile::tempdir().unwrap();
2212 let path = tmp.path().join("secrets.json");
2213 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
2214 fs::write(&path, original).unwrap();
2215 let mut perms = fs::metadata(&path).unwrap().permissions();
2216 perms.set_mode(0o644);
2217 fs::set_permissions(&path, perms).unwrap();
2218
2219 let store = FileKeyringStore::new(path.clone());
2220 let err = store.set("openrouter", "or-new").unwrap_err();
2221 assert!(
2222 matches!(err, SecretsError::InsecurePermissions { .. }),
2223 "set must surface the read error rather than overwriting; got: {err}"
2224 );
2225
2226 let on_disk = fs::read_to_string(&path).unwrap();
2227 assert_eq!(
2228 on_disk, original,
2229 "set must not modify the file when load_unlocked errored"
2230 );
2231 }
2232
2233 #[cfg(unix)]
2234 #[test]
2235 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
2236 use std::os::unix::fs::PermissionsExt;
2237 let tmp = tempfile::tempdir().unwrap();
2238 let path = tmp.path().join("secrets.json");
2239 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
2240 fs::write(&path, original).unwrap();
2241 let mut perms = fs::metadata(&path).unwrap().permissions();
2242 perms.set_mode(0o644);
2243 fs::set_permissions(&path, perms).unwrap();
2244
2245 let store = FileKeyringStore::new(path.clone());
2246 let err = store.delete("nvidia").unwrap_err();
2247 assert!(
2248 matches!(err, SecretsError::InsecurePermissions { .. }),
2249 "delete must surface the read error rather than wiping the file; got: {err}"
2250 );
2251 let on_disk = fs::read_to_string(&path).unwrap();
2252 assert_eq!(on_disk, original);
2253 }
2254
2255 #[test]
2256 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
2257 let tmp = tempfile::tempdir().unwrap();
2258 let path = tmp.path().join("secrets.json");
2259 fs::write(&path, "{ this is not valid json").unwrap();
2262 #[cfg(unix)]
2263 {
2264 use std::os::unix::fs::PermissionsExt;
2265 let mut perms = fs::metadata(&path).unwrap().permissions();
2266 perms.set_mode(0o600);
2267 fs::set_permissions(&path, perms).unwrap();
2268 }
2269
2270 let store = FileKeyringStore::new(path.clone());
2271 let err = store.set("deepseek", "sk-new").unwrap_err();
2272 assert!(
2273 matches!(err, SecretsError::Json(_)),
2274 "set must surface the parse error rather than wiping the file; got: {err}"
2275 );
2276 let on_disk = fs::read_to_string(&path).unwrap();
2277 assert_eq!(on_disk, "{ this is not valid json");
2278 }
2279
2280 #[test]
2281 fn file_store_set_still_creates_file_when_missing() {
2282 let tmp = tempfile::tempdir().unwrap();
2287 let path = tmp.path().join("nested").join("secrets.json");
2288 let store = FileKeyringStore::new(path.clone());
2289
2290 store.set("deepseek", "sk-fresh").unwrap();
2291 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
2292 }
2293
2294 #[test]
2295 fn file_store_default_path_uses_home() {
2296 let _lock = env_lock();
2297 clear_known_envs();
2298 let tmp = tempfile::tempdir().unwrap();
2299 let _home = EnvVarGuard::set("HOME", tmp.path());
2300 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2301
2302 let path = FileKeyringStore::default_path().unwrap();
2303 assert_eq!(
2304 path,
2305 tmp.path()
2306 .join(".codewhale")
2307 .join("secrets")
2308 .join("secrets.json")
2309 );
2310 }
2311
2312 #[test]
2313 fn default_path_with_explicit_codewhale_home_does_not_migrate_ambient_legacy() {
2314 let _lock = env_lock();
2317 clear_known_envs();
2318 let tmp = tempfile::tempdir().unwrap();
2319 let codewhale_home = tmp.path().join("isolated-codewhale-home");
2320 let _home = EnvVarGuard::set("HOME", tmp.path());
2321 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2322 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
2323 let legacy = tmp
2324 .path()
2325 .join(".deepseek")
2326 .join("secrets")
2327 .join("secrets.json");
2328 FileKeyringStore::new(&legacy)
2329 .set("deepseek", "synthetic-ambient-legacy-value")
2330 .unwrap();
2331
2332 let path = FileKeyringStore::default_path().unwrap();
2333 assert_eq!(path, codewhale_home.join("secrets").join("secrets.json"));
2334 assert!(
2335 !path.exists(),
2336 "explicit CODEWHALE_HOME must not create/migrate a primary store from ambient legacy"
2337 );
2338
2339 let secrets = Secrets::auto_detect();
2340 assert_eq!(
2341 secrets.get("deepseek").unwrap(),
2342 None,
2343 "explicit CODEWHALE_HOME must not surface ambient legacy credentials"
2344 );
2345 }
2346
2347 #[test]
2348 fn file_backed_default_refuses_relative_secret_path() {
2349 let secrets =
2352 Secrets::file_backed_from_default_path(Ok(PathBuf::from(".codewhale-secrets.json")));
2353 assert!(matches!(
2354 secrets.set("deepseek", "must-not-land-relative"),
2355 Err(SecretsError::ReadOnly)
2356 ));
2357 assert_eq!(
2358 secrets.get("deepseek").unwrap(),
2359 None,
2360 "unsafe relative fallback must not read a workspace secret file"
2361 );
2362 }
2363
2364 #[test]
2365 fn file_backed_default_refuses_writes_when_home_resolution_fails() {
2366 let err = SecretsError::Io(std::io::Error::new(
2369 std::io::ErrorKind::NotFound,
2370 "synthetic unresolved home",
2371 ));
2372 let secrets = Secrets::file_backed_from_default_path(Err(err));
2373 assert!(matches!(
2374 secrets.set("deepseek", "must-not-persist"),
2375 Err(SecretsError::ReadOnly)
2376 ));
2377 assert_eq!(secrets.get("deepseek").unwrap(), None);
2378 }
2379
2380 #[path = "diagnostic_tests.rs"]
2381 mod diagnostic_tests;
2382}