1#![deny(missing_docs)]
13
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22pub const DEFAULT_SERVICE: &str = "deepseek";
26pub const SECRET_BACKEND_ENV: &str = "CODEWHALE_SECRET_BACKEND";
29pub const LEGACY_SECRET_BACKEND_ENV: &str = "DEEPSEEK_SECRET_BACKEND";
31const FILE_BACKEND_LABEL: &str = "file-based (~/.codewhale/secrets/)";
32
33#[derive(Debug, Error)]
35pub enum SecretsError {
36 #[error("keyring backend error: {0}")]
38 Keyring(String),
39 #[error("file-backed secret store I/O error: {0}")]
41 Io(#[from] std::io::Error),
42 #[error("file-backed secret store JSON error: {0}")]
44 Json(#[from] serde_json::Error),
45 #[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
47 InsecurePermissions {
48 path: PathBuf,
50 mode: u32,
52 },
53}
54
55pub trait KeyringStore: Send + Sync {
64 fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
69
70 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
75
76 fn delete(&self, key: &str) -> Result<(), SecretsError>;
81
82 fn backend_name(&self) -> &'static str;
88}
89
90#[derive(Debug, Clone)]
103pub struct DefaultKeyringStore {
104 service: String,
107}
108
109impl Default for DefaultKeyringStore {
110 fn default() -> Self {
111 Self::new(DEFAULT_SERVICE)
112 }
113}
114
115impl DefaultKeyringStore {
116 #[must_use]
118 pub fn new(service: impl Into<String>) -> Self {
119 Self {
120 service: service.into(),
121 }
122 }
123
124 pub fn probe(&self) -> Result<(), SecretsError> {
127 #[cfg(any(
128 target_os = "macos",
129 target_os = "windows",
130 all(
131 target_os = "linux",
132 not(target_env = "ohos"),
133 not(target_env = "musl")
134 )
135 ))]
136 {
137 let entry = keyring::Entry::new(&self.service, "__probe__")
142 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
143 #[cfg(any(target_os = "macos", target_os = "windows"))]
144 {
145 let _ = entry;
146 Ok(())
147 }
148 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
149 match entry.get_password() {
150 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
151 Err(keyring::Error::PlatformFailure(err)) => {
152 Err(SecretsError::Keyring(format!("platform failure: {err}")))
153 }
154 Err(keyring::Error::NoStorageAccess(err)) => {
155 Err(SecretsError::Keyring(format!("no storage access: {err}")))
156 }
157 Err(other) => Err(SecretsError::Keyring(other.to_string())),
158 }
159 }
160 #[cfg(not(any(
161 target_os = "macos",
162 target_os = "windows",
163 all(
164 target_os = "linux",
165 not(target_env = "ohos"),
166 not(target_env = "musl")
167 )
168 )))]
169 {
170 let _ = &self.service;
171 Err(SecretsError::Keyring(unsupported_keyring_message()))
172 }
173 }
174}
175
176impl KeyringStore for DefaultKeyringStore {
177 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
178 #[cfg(any(
179 target_os = "macos",
180 target_os = "windows",
181 all(
182 target_os = "linux",
183 not(target_env = "ohos"),
184 not(target_env = "musl")
185 )
186 ))]
187 {
188 let entry = keyring::Entry::new(&self.service, key)
189 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
190 match entry.get_password() {
191 Ok(value) => Ok(Some(value)),
192 Err(keyring::Error::NoEntry) => Ok(None),
193 Err(err) => Err(SecretsError::Keyring(err.to_string())),
194 }
195 }
196 #[cfg(not(any(
197 target_os = "macos",
198 target_os = "windows",
199 all(
200 target_os = "linux",
201 not(target_env = "ohos"),
202 not(target_env = "musl")
203 )
204 )))]
205 {
206 let _ = key;
207 Err(SecretsError::Keyring(unsupported_keyring_message()))
208 }
209 }
210
211 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
212 #[cfg(any(
213 target_os = "macos",
214 target_os = "windows",
215 all(
216 target_os = "linux",
217 not(target_env = "ohos"),
218 not(target_env = "musl")
219 )
220 ))]
221 {
222 let entry = keyring::Entry::new(&self.service, key)
223 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
224 entry
225 .set_password(value)
226 .map_err(|err| SecretsError::Keyring(err.to_string()))
227 }
228 #[cfg(not(any(
229 target_os = "macos",
230 target_os = "windows",
231 all(
232 target_os = "linux",
233 not(target_env = "ohos"),
234 not(target_env = "musl")
235 )
236 )))]
237 {
238 let _ = (key, value);
239 Err(SecretsError::Keyring(unsupported_keyring_message()))
240 }
241 }
242
243 fn delete(&self, key: &str) -> Result<(), SecretsError> {
244 #[cfg(any(
245 target_os = "macos",
246 target_os = "windows",
247 all(
248 target_os = "linux",
249 not(target_env = "ohos"),
250 not(target_env = "musl")
251 )
252 ))]
253 {
254 let entry = keyring::Entry::new(&self.service, key)
255 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
256 match entry.delete_credential() {
257 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
258 Err(err) => Err(SecretsError::Keyring(err.to_string())),
259 }
260 }
261 #[cfg(not(any(
262 target_os = "macos",
263 target_os = "windows",
264 all(
265 target_os = "linux",
266 not(target_env = "ohos"),
267 not(target_env = "musl")
268 )
269 )))]
270 {
271 let _ = key;
272 Err(SecretsError::Keyring(unsupported_keyring_message()))
273 }
274 }
275
276 fn backend_name(&self) -> &'static str {
277 "system keyring"
278 }
279}
280
281#[cfg(not(any(
282 target_os = "macos",
283 target_os = "windows",
284 all(
285 target_os = "linux",
286 not(target_env = "ohos"),
287 not(target_env = "musl")
288 )
289)))]
290fn unsupported_keyring_message() -> String {
291 "system keyring backend is unsupported on this platform".to_string()
292}
293
294#[derive(Debug, Default)]
301pub struct InMemoryKeyringStore {
302 entries: Mutex<HashMap<String, String>>,
304}
305
306impl InMemoryKeyringStore {
307 #[must_use]
309 pub fn new() -> Self {
310 Self::default()
311 }
312}
313
314impl KeyringStore for InMemoryKeyringStore {
315 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
316 let guard = self.entries.lock().map_err(|e| {
317 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
318 })?;
319 Ok(guard.get(key).cloned())
320 }
321
322 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
323 let mut guard = self.entries.lock().map_err(|e| {
324 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
325 })?;
326 guard.insert(key.to_string(), value.to_string());
327 Ok(())
328 }
329
330 fn delete(&self, key: &str) -> Result<(), SecretsError> {
331 let mut guard = self.entries.lock().map_err(|e| {
332 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
333 })?;
334 guard.remove(key);
335 Ok(())
336 }
337
338 fn backend_name(&self) -> &'static str {
339 "in-memory (test)"
340 }
341}
342
343#[derive(Debug, Clone)]
356pub struct FileKeyringStore {
357 path: PathBuf,
359}
360
361#[derive(Debug, Default, Serialize, Deserialize)]
362struct FileSecretsBlob {
363 #[serde(default)]
364 entries: HashMap<String, String>,
365}
366
367impl FileKeyringStore {
368 #[must_use]
370 pub fn new(path: impl Into<PathBuf>) -> Self {
371 Self { path: path.into() }
372 }
373
374 pub fn default_path() -> Result<PathBuf, SecretsError> {
380 let primary = default_codewhale_secrets_path()?;
381 let legacy = legacy_deepseek_secrets_path()?;
382 if let Err(err) = Self::migrate_legacy_file_if_needed(&primary, &legacy) {
383 tracing::warn!(
384 "could not migrate legacy secret store from {} to {}: {err}",
385 legacy.display(),
386 primary.display()
387 );
388 }
389 Ok(primary)
390 }
391
392 fn migrate_legacy_file_if_needed(primary: &Path, legacy: &Path) -> Result<(), SecretsError> {
393 if !legacy.exists() {
394 return Ok(());
395 }
396
397 let legacy_store = Self::new(legacy.to_path_buf());
398 let legacy_blob = legacy_store.load_unlocked()?;
399 if legacy_blob.entries.is_empty() {
400 return Ok(());
401 }
402
403 let primary_store = Self::new(primary.to_path_buf());
404 let mut primary_blob = primary_store.load_unlocked()?;
405 let mut changed = false;
406 for (key, value) in legacy_blob.entries {
407 if let std::collections::hash_map::Entry::Vacant(entry) =
408 primary_blob.entries.entry(key)
409 {
410 entry.insert(value);
411 changed = true;
412 }
413 }
414 if changed {
415 primary_store.store_unlocked(&primary_blob)?;
416 }
417 Ok(())
418 }
419
420 fn home_dir() -> Result<PathBuf, SecretsError> {
421 for var in ["HOME", "USERPROFILE"] {
422 if let Ok(value) = std::env::var(var) {
423 let trimmed = value.trim();
424 if !trimmed.is_empty() {
425 return Ok(PathBuf::from(trimmed));
426 }
427 }
428 }
429
430 dirs::home_dir().ok_or_else(|| {
431 SecretsError::Io(std::io::Error::new(
432 std::io::ErrorKind::NotFound,
433 "could not resolve home directory for FileKeyringStore",
434 ))
435 })
436 }
437
438 #[must_use]
440 pub fn path(&self) -> &Path {
441 &self.path
442 }
443
444 fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
445 if !self.path.exists() {
446 return Ok(FileSecretsBlob::default());
447 }
448 #[cfg(unix)]
452 {
453 use std::os::unix::fs::PermissionsExt;
454 let meta = fs::metadata(&self.path)?;
455 let mode = meta.permissions().mode() & 0o777;
456 if mode & 0o077 != 0 {
457 return Err(SecretsError::InsecurePermissions {
458 path: self.path.clone(),
459 mode,
460 });
461 }
462 }
463 let raw = fs::read_to_string(&self.path)?;
464 if raw.trim().is_empty() {
465 return Ok(FileSecretsBlob::default());
466 }
467 let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
468 Ok(blob)
469 }
470
471 fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
472 if let Some(parent) = self.path.parent() {
473 fs::create_dir_all(parent)?;
474 #[cfg(unix)]
475 {
476 use std::os::unix::fs::PermissionsExt;
477 let mut perms = fs::metadata(parent)?.permissions();
478 perms.set_mode(0o700);
479 let _ = fs::set_permissions(parent, perms);
480 }
481 }
482 let body = serde_json::to_string_pretty(blob)?;
483 write_private_file(&self.path, body.as_bytes())?;
484 #[cfg(unix)]
485 {
486 use std::os::unix::fs::PermissionsExt;
487 if let Ok(meta) = fs::metadata(&self.path) {
494 let mut perms = meta.permissions();
495 perms.set_mode(0o600);
496 let _ = fs::set_permissions(&self.path, perms);
497 }
498 }
499 Ok(())
500 }
501}
502
503fn write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
504 atomic_write_private_file(path, body)
505}
506
507fn atomic_write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
508 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
509 fs::create_dir_all(parent)?;
510 }
511 let dir = path
512 .parent()
513 .filter(|p| !p.as_os_str().is_empty())
514 .unwrap_or_else(|| Path::new("."));
515 let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(SecretsError::Io)?;
516 use std::io::Write as _;
517 tmp.write_all(body).map_err(SecretsError::Io)?;
518 tmp.flush().map_err(SecretsError::Io)?;
519 tmp.as_file().sync_all().map_err(SecretsError::Io)?;
520 #[cfg(unix)]
521 {
522 use std::os::unix::fs::PermissionsExt;
523 let perms = fs::Permissions::from_mode(0o600);
524 tmp.as_file()
525 .set_permissions(perms)
526 .map_err(SecretsError::Io)?;
527 }
528 tmp.persist(path).map_err(|e| SecretsError::Io(e.error))?;
529 Ok(())
530}
531
532impl KeyringStore for FileKeyringStore {
533 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
534 let blob = self.load_unlocked()?;
535 Ok(blob.entries.get(key).cloned())
536 }
537
538 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
539 let mut blob = self.load_unlocked()?;
545 blob.entries.insert(key.to_string(), value.to_string());
546 self.store_unlocked(&blob)
547 }
548
549 fn delete(&self, key: &str) -> Result<(), SecretsError> {
550 let mut blob = self.load_unlocked()?;
553 blob.entries.remove(key);
554 self.store_unlocked(&blob)
555 }
556
557 fn backend_name(&self) -> &'static str {
558 FILE_BACKEND_LABEL
559 }
560}
561
562fn default_codewhale_secrets_path() -> Result<PathBuf, SecretsError> {
563 if let Ok(value) = std::env::var("CODEWHALE_HOME") {
564 let trimmed = value.trim();
565 if !trimmed.is_empty() {
566 return Ok(PathBuf::from(trimmed).join("secrets").join("secrets.json"));
567 }
568 }
569 Ok(FileKeyringStore::home_dir()?
570 .join(".codewhale")
571 .join("secrets")
572 .join("secrets.json"))
573}
574
575fn legacy_deepseek_secrets_path() -> Result<PathBuf, SecretsError> {
576 Ok(FileKeyringStore::home_dir()?
577 .join(".deepseek")
578 .join("secrets")
579 .join("secrets.json"))
580}
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583enum SecretBackendSelection {
584 File,
585 System,
586 Unknown,
587}
588
589fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
590 match value.map(str::trim).filter(|value| !value.is_empty()) {
591 None => SecretBackendSelection::File,
592 Some(value) => match value.to_ascii_lowercase().as_str() {
593 "file" | "local" | "json" => SecretBackendSelection::File,
594 "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
595 _ => SecretBackendSelection::Unknown,
596 },
597 }
598}
599
600fn configured_secret_backend() -> Option<String> {
601 std::env::var(SECRET_BACKEND_ENV)
602 .ok()
603 .filter(|value| !value.trim().is_empty())
604 .or_else(|| std::env::var(LEGACY_SECRET_BACKEND_ENV).ok())
605}
606
607#[derive(Clone)]
624pub struct Secrets {
625 pub store: Arc<dyn KeyringStore>,
627 service: String,
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640pub enum SecretSource {
641 Keyring,
643 Env,
645}
646
647impl std::fmt::Debug for Secrets {
648 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
649 f.debug_struct("Secrets")
650 .field("backend", &self.store.backend_name())
651 .field("service", &self.service)
652 .finish()
653 }
654}
655
656impl Secrets {
657 #[must_use]
660 pub fn new(store: Arc<dyn KeyringStore>) -> Self {
661 Self {
662 store,
663 service: DEFAULT_SERVICE.to_string(),
664 }
665 }
666
667 pub fn auto_detect() -> Self {
678 match secret_backend_selection(configured_secret_backend().as_deref()) {
679 SecretBackendSelection::File => Self::file_backed_default(),
680 SecretBackendSelection::Unknown => {
681 tracing::warn!(
682 "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
683 );
684 Self::file_backed_default()
685 }
686 SecretBackendSelection::System => {
687 let default_store = DefaultKeyringStore::default();
688 match default_store.probe() {
689 Ok(()) => Self::new(Arc::new(default_store)),
690 Err(err) => {
691 tracing::warn!(
692 "OS keyring unavailable ({err}); falling back to file-backed secret store"
693 );
694 Self::file_backed_default()
695 }
696 }
697 }
698 }
699 }
700
701 fn file_backed_default() -> Self {
702 let path = FileKeyringStore::default_path()
703 .unwrap_or_else(|_| PathBuf::from(".codewhale-secrets.json"));
704 Self::new(Arc::new(FileKeyringStore::new(path)))
705 }
706
707 #[must_use]
709 pub fn file_backed() -> Self {
710 Self::file_backed_default()
711 }
712
713 #[must_use]
716 pub fn system_keyring() -> Self {
717 let default_store = DefaultKeyringStore::default();
718 match default_store.probe() {
719 Ok(()) => Self::new(Arc::new(default_store)),
720 Err(err) => {
721 tracing::warn!(
722 "OS keyring unavailable ({err}); falling back to file-backed secret store"
723 );
724 Self::file_backed_default()
725 }
726 }
727 }
728
729 #[must_use]
731 pub fn backend_name(&self) -> &'static str {
732 self.store.backend_name()
733 }
734
735 #[must_use]
740 pub fn resolve(&self, name: &str) -> Option<String> {
741 self.resolve_with_source(name).map(|(value, _)| value)
742 }
743
744 #[must_use]
746 pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
747 if let Ok(Some(v)) = self.store.get(name)
748 && !v.trim().is_empty()
749 {
750 return Some((v, SecretSource::Keyring));
751 }
752 env_for(name).map(|value| (value, SecretSource::Env))
753 }
754
755 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
757 self.store.set(name, value)
758 }
759
760 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
762 self.store.delete(name)
763 }
764
765 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
767 self.store.get(name)
768 }
769
770 #[must_use]
782 pub fn resolve_direct(&self, key: &str, source_hint: Option<&str>) -> Option<String> {
783 match source_hint {
784 Some("env") => {
785 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
787 }
788 Some("keyring") | Some("file") => {
789 self.store
791 .get(key)
792 .ok()
793 .flatten()
794 .filter(|v| !v.trim().is_empty())
795 }
796 Some(_) | None => {
797 if let Ok(Some(v)) = self.store.get(key)
799 && !v.trim().is_empty()
800 {
801 return Some(v);
802 }
803 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
804 }
805 }
806 }
807}
808
809#[must_use]
841pub fn env_for(name: &str) -> Option<String> {
842 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
843 "deepseek" => &["DEEPSEEK_API_KEY"],
844 "openrouter" => &["OPENROUTER_API_KEY"],
845 "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
846 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
847 }
848 "novita" | "novita-ai" | "novita_ai" => &["NOVITA_API_KEY"],
849 "together" | "together-ai" | "together_ai" | "togetherai" => &["TOGETHER_API_KEY"],
850 "deepinfra" | "deep-infra" | "deep_infra" => &["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
851 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
855 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
856 }
857 "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
858 "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
859 | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
860 "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
861 "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
862 "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
863 "vllm" | "v-llm" => &["VLLM_API_KEY"],
864 "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
865 "openai" => &["OPENAI_API_KEY"],
866 "anthropic" | "claude" => &["ANTHROPIC_API_KEY"],
867 "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
868 "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
869 | "volcengineark" => &[
870 "VOLCENGINE_API_KEY",
871 "VOLCENGINE_ARK_API_KEY",
872 "ARK_API_KEY",
873 ],
874 "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
875 | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
876 "WANJIE_ARK_API_KEY",
877 "WANJIE_API_KEY",
878 "WANJIE_MAAS_API_KEY",
879 ],
880 "sakana" | "sakana-ai" | "sakana_ai" | "fugu" => &["FUGU_API_KEY", "SAKANA_API_KEY"],
881 "longcat" | "long-cat" | "meituan-longcat" | "meituan" => &["LONGCAT_API_KEY"],
882 "meta" | "meta-ai" | "meta_ai" | "meta-model-api" | "meta_model_api" | "muse"
883 | "muse-spark" => &["META_MODEL_API_KEY", "MODEL_API_KEY"],
884 "xai" | "x-ai" | "x_ai" | "grok" => &["XAI_API_KEY"],
885 _ => return None,
886 };
887 for var in candidates {
888 if let Ok(value) = std::env::var(var)
889 && !value.trim().is_empty()
890 {
891 return Some(value);
892 }
893 }
894 None
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use std::sync::{Mutex, OnceLock};
901
902 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
905 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
906 LOCK.get_or_init(|| Mutex::new(()))
907 .lock()
908 .unwrap_or_else(|p| p.into_inner())
909 }
910
911 fn clear_known_envs() {
912 for var in [
913 "CODEWHALE_HOME",
914 "DEEPSEEK_API_KEY",
915 "OPENROUTER_API_KEY",
916 "NOVITA_API_KEY",
917 "NVIDIA_API_KEY",
918 "NVIDIA_NIM_API_KEY",
919 "FIREWORKS_API_KEY",
920 "TOGETHER_API_KEY",
921 "DEEPINFRA_API_KEY",
922 "DEEPINFRA_TOKEN",
923 "SILICONFLOW_API_KEY",
924 "ARCEE_API_KEY",
925 "SGLANG_API_KEY",
926 "VLLM_API_KEY",
927 "OLLAMA_API_KEY",
928 "OPENAI_API_KEY",
929 "ATLASCLOUD_API_KEY",
930 "WANJIE_ARK_API_KEY",
931 "WANJIE_API_KEY",
932 "WANJIE_MAAS_API_KEY",
933 "XIAOMI_MIMO_API_KEY",
934 "XIAOMI_API_KEY",
935 "MIMO_API_KEY",
936 "FUGU_API_KEY",
937 "SAKANA_API_KEY",
938 "LONGCAT_API_KEY",
939 "META_MODEL_API_KEY",
940 "MODEL_API_KEY",
941 "XAI_API_KEY",
942 SECRET_BACKEND_ENV,
943 LEGACY_SECRET_BACKEND_ENV,
944 ] {
945 unsafe { std::env::remove_var(var) };
948 }
949 }
950
951 struct EnvVarGuard {
952 name: &'static str,
953 previous: Option<std::ffi::OsString>,
954 }
955
956 impl EnvVarGuard {
957 fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
958 let previous = std::env::var_os(name);
959 unsafe { std::env::set_var(name, value) };
960 Self { name, previous }
961 }
962 }
963
964 impl Drop for EnvVarGuard {
965 fn drop(&mut self) {
966 match self.previous.take() {
967 Some(value) => unsafe { std::env::set_var(self.name, value) },
968 None => unsafe { std::env::remove_var(self.name) },
969 }
970 }
971 }
972
973 #[test]
974 fn backend_selection_defaults_to_file() {
975 assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
976 assert_eq!(
977 secret_backend_selection(Some("")),
978 SecretBackendSelection::File
979 );
980 assert_eq!(
981 secret_backend_selection(Some(" file ")),
982 SecretBackendSelection::File
983 );
984 }
985
986 #[test]
987 fn backend_selection_accepts_explicit_system_keyring() {
988 assert_eq!(
989 secret_backend_selection(Some("system")),
990 SecretBackendSelection::System
991 );
992 assert_eq!(
993 secret_backend_selection(Some("keyring")),
994 SecretBackendSelection::System
995 );
996 assert_eq!(
997 secret_backend_selection(Some("os-keyring")),
998 SecretBackendSelection::System
999 );
1000 }
1001
1002 #[test]
1003 fn auto_detect_is_file_backed_by_default() {
1004 let _lock = env_lock();
1005 clear_known_envs();
1006 let tmp = tempfile::tempdir().unwrap();
1007 let _home = EnvVarGuard::set("HOME", tmp.path());
1008 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1009
1010 let secrets = Secrets::auto_detect();
1011
1012 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1013 }
1014
1015 #[test]
1016 fn auto_detect_honors_explicit_file_backend() {
1017 let _lock = env_lock();
1018 clear_known_envs();
1019 let tmp = tempfile::tempdir().unwrap();
1020 let _home = EnvVarGuard::set("HOME", tmp.path());
1021 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1022 unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
1024
1025 let secrets = Secrets::auto_detect();
1026
1027 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1028 unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
1030 }
1031
1032 #[test]
1033 fn auto_detect_honors_legacy_backend_env_alias() {
1034 let _lock = env_lock();
1035 clear_known_envs();
1036 let tmp = tempfile::tempdir().unwrap();
1037 let _home = EnvVarGuard::set("HOME", tmp.path());
1038 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1039 unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
1040
1041 let secrets = Secrets::auto_detect();
1042
1043 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1044 clear_known_envs();
1045 }
1046
1047 #[test]
1048 fn file_default_path_uses_codewhale_home() {
1049 let _lock = env_lock();
1050 clear_known_envs();
1051 let tmp = tempfile::tempdir().unwrap();
1052 let _home = EnvVarGuard::set("HOME", tmp.path());
1053 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1054
1055 let path = FileKeyringStore::default_path().unwrap();
1056
1057 assert_eq!(
1058 path,
1059 tmp.path()
1060 .join(".codewhale")
1061 .join("secrets")
1062 .join("secrets.json")
1063 );
1064 }
1065
1066 #[test]
1067 fn file_default_path_honors_codewhale_home() {
1068 let _lock = env_lock();
1069 clear_known_envs();
1070 let tmp = tempfile::tempdir().unwrap();
1071 let custom = tmp.path().join("custom-codewhale");
1072 let _home = EnvVarGuard::set("HOME", tmp.path());
1073 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1074 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
1075
1076 let path = FileKeyringStore::default_path().unwrap();
1077
1078 assert_eq!(path, custom.join("secrets").join("secrets.json"));
1079 }
1080
1081 #[test]
1082 fn file_default_path_migrates_legacy_entries_to_codewhale() {
1083 let _lock = env_lock();
1084 clear_known_envs();
1085 let tmp = tempfile::tempdir().unwrap();
1086 let _home = EnvVarGuard::set("HOME", tmp.path());
1087 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1088 let legacy = tmp
1089 .path()
1090 .join(".deepseek")
1091 .join("secrets")
1092 .join("secrets.json");
1093 FileKeyringStore::new(legacy.clone())
1094 .set("xiaomi-mimo", "legacy-mimo")
1095 .unwrap();
1096
1097 let primary = FileKeyringStore::default_path().unwrap();
1098 let primary_store = FileKeyringStore::new(primary.clone());
1099
1100 assert_eq!(
1101 primary,
1102 tmp.path()
1103 .join(".codewhale")
1104 .join("secrets")
1105 .join("secrets.json")
1106 );
1107 assert_eq!(
1108 primary_store.get("xiaomi-mimo").unwrap().as_deref(),
1109 Some("legacy-mimo")
1110 );
1111 assert!(
1112 legacy.exists(),
1113 "migration copies; it does not delete legacy data"
1114 );
1115 }
1116
1117 #[test]
1118 fn file_default_path_migration_preserves_primary_values() {
1119 let _lock = env_lock();
1120 clear_known_envs();
1121 let tmp = tempfile::tempdir().unwrap();
1122 let _home = EnvVarGuard::set("HOME", tmp.path());
1123 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1124 let legacy = tmp
1125 .path()
1126 .join(".deepseek")
1127 .join("secrets")
1128 .join("secrets.json");
1129 let primary = tmp
1130 .path()
1131 .join(".codewhale")
1132 .join("secrets")
1133 .join("secrets.json");
1134 FileKeyringStore::new(legacy)
1135 .set("openrouter", "legacy-openrouter")
1136 .unwrap();
1137 let primary_store = FileKeyringStore::new(primary.clone());
1138 primary_store
1139 .set("openrouter", "primary-openrouter")
1140 .unwrap();
1141
1142 let resolved = FileKeyringStore::default_path().unwrap();
1143
1144 assert_eq!(resolved, primary);
1145 assert_eq!(
1146 primary_store.get("openrouter").unwrap().as_deref(),
1147 Some("primary-openrouter")
1148 );
1149 }
1150
1151 #[test]
1152 fn in_memory_store_round_trips() {
1153 let store = InMemoryKeyringStore::new();
1154 assert_eq!(store.get("deepseek").unwrap(), None);
1155 store.set("deepseek", "sk-test").unwrap();
1156 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
1157 store.set("deepseek", "sk-replaced").unwrap();
1158 assert_eq!(
1159 store.get("deepseek").unwrap(),
1160 Some("sk-replaced".to_string())
1161 );
1162 store.delete("deepseek").unwrap();
1163 assert_eq!(store.get("deepseek").unwrap(), None);
1164 store.delete("missing").unwrap();
1166 }
1167
1168 #[test]
1169 fn resolve_prefers_keyring_over_env() {
1170 let _lock = env_lock();
1171 clear_known_envs();
1172 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1174
1175 let store = Arc::new(InMemoryKeyringStore::new());
1176 store.set("deepseek", "ring-key").unwrap();
1177 let secrets = Secrets::new(store);
1178
1179 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1180 assert_eq!(
1181 secrets.resolve_with_source("deepseek"),
1182 Some(("ring-key".to_string(), SecretSource::Keyring))
1183 );
1184 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1186 }
1187
1188 #[test]
1189 fn resolve_falls_back_to_env_when_keyring_empty() {
1190 let _lock = env_lock();
1191 clear_known_envs();
1192 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1194
1195 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1196 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1197 assert_eq!(
1198 secrets.resolve_with_source("deepseek"),
1199 Some(("env-fallback".to_string(), SecretSource::Env))
1200 );
1201 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1203 }
1204
1205 #[test]
1206 fn resolve_returns_none_when_both_layers_empty() {
1207 let _lock = env_lock();
1208 clear_known_envs();
1209 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1210 assert_eq!(secrets.resolve("deepseek"), None);
1211 }
1212
1213 #[test]
1214 fn resolve_treats_blank_keyring_value_as_unset() {
1215 let _lock = env_lock();
1216 clear_known_envs();
1217 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1219
1220 let store = Arc::new(InMemoryKeyringStore::new());
1221 store.set("deepseek", " ").unwrap();
1222 let secrets = Secrets::new(store);
1223 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1224 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1226 }
1227
1228 #[test]
1229 fn nvidia_env_aliases_resolve() {
1230 let _lock = env_lock();
1231 clear_known_envs();
1232 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1234 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1235 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1236 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1237 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1239 }
1240
1241 #[test]
1242 fn atlascloud_env_aliases_resolve() {
1243 let _guard = env_lock();
1244 clear_known_envs();
1245 unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1246
1247 assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1248 assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1249 assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1250
1251 clear_known_envs();
1252 }
1253
1254 #[test]
1255 fn sakana_env_aliases_resolve() {
1256 let _guard = env_lock();
1257 clear_known_envs();
1258 unsafe { std::env::set_var("FUGU_API_KEY", "fugu-key") };
1259
1260 assert_eq!(env_for("sakana").as_deref(), Some("fugu-key"));
1261 assert_eq!(env_for("sakana-ai").as_deref(), Some("fugu-key"));
1262 assert_eq!(env_for("sakana_ai").as_deref(), Some("fugu-key"));
1263 assert_eq!(env_for("fugu").as_deref(), Some("fugu-key"));
1264
1265 clear_known_envs();
1266 unsafe { std::env::set_var("SAKANA_API_KEY", "sakana-key") };
1267 assert_eq!(env_for("sakana").as_deref(), Some("sakana-key"));
1268
1269 clear_known_envs();
1270 }
1271
1272 #[test]
1273 fn wanjie_ark_env_aliases_resolve() {
1274 let _guard = env_lock();
1275 clear_known_envs();
1276 unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1277
1278 assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1279 assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1280 assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1281
1282 clear_known_envs();
1283 }
1284
1285 #[test]
1286 fn xai_env_aliases_resolve() {
1287 let _guard = env_lock();
1288 clear_known_envs();
1289 unsafe { std::env::set_var("XAI_API_KEY", "xai-key") };
1290
1291 assert_eq!(env_for("xai").as_deref(), Some("xai-key"));
1292 assert_eq!(env_for("x-ai").as_deref(), Some("xai-key"));
1293 assert_eq!(env_for("x_ai").as_deref(), Some("xai-key"));
1294 assert_eq!(env_for("grok").as_deref(), Some("xai-key"));
1295
1296 clear_known_envs();
1297 }
1298
1299 #[test]
1300 fn meta_model_api_env_aliases_resolve() {
1301 let _guard = env_lock();
1302 clear_known_envs();
1303 unsafe { std::env::set_var("MODEL_API_KEY", "meta-key") };
1304
1305 for alias in [
1306 "meta",
1307 "meta-ai",
1308 "meta_ai",
1309 "meta-model-api",
1310 "meta_model_api",
1311 "muse",
1312 "muse-spark",
1313 ] {
1314 assert_eq!(env_for(alias).as_deref(), Some("meta-key"), "{alias}");
1315 }
1316
1317 clear_known_envs();
1318 unsafe { std::env::set_var("META_MODEL_API_KEY", "meta-prefixed-key") };
1319 assert_eq!(env_for("meta").as_deref(), Some("meta-prefixed-key"),);
1320
1321 clear_known_envs();
1322 }
1323
1324 #[test]
1325 fn xiaomi_mimo_env_aliases_resolve() {
1326 let _guard = env_lock();
1327 clear_known_envs();
1328 unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1329
1330 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1331 assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1332 assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1333 assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1334
1335 clear_known_envs();
1336
1337 unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1338 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1339 clear_known_envs();
1340 }
1341
1342 #[test]
1343 fn fireworks_env_aliases_resolve() {
1344 let _lock = env_lock();
1345 clear_known_envs();
1346 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1348
1349 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1350 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1351 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1353 }
1354
1355 #[test]
1356 fn together_env_aliases_resolve() {
1357 let _lock = env_lock();
1358 clear_known_envs();
1359 unsafe { std::env::set_var("TOGETHER_API_KEY", "together-key") };
1361
1362 assert_eq!(env_for("together").as_deref(), Some("together-key"));
1365 assert_eq!(env_for("together-ai").as_deref(), Some("together-key"));
1366 assert_eq!(env_for("together_ai").as_deref(), Some("together-key"));
1367 assert_eq!(env_for("togetherai").as_deref(), Some("together-key"));
1368 unsafe { std::env::remove_var("TOGETHER_API_KEY") };
1370 }
1371
1372 #[test]
1373 fn deepinfra_env_aliases_resolve() {
1374 let _lock = env_lock();
1375 clear_known_envs();
1376 unsafe { std::env::set_var("DEEPINFRA_API_KEY", "di-key") };
1378
1379 assert_eq!(env_for("deepinfra").as_deref(), Some("di-key"));
1380 assert_eq!(env_for("deep-infra").as_deref(), Some("di-key"));
1381 assert_eq!(env_for("deep_infra").as_deref(), Some("di-key"));
1382 unsafe { std::env::remove_var("DEEPINFRA_API_KEY") };
1384
1385 unsafe { std::env::set_var("DEEPINFRA_TOKEN", "di-token") };
1388 assert_eq!(env_for("deepinfra").as_deref(), Some("di-token"));
1389 unsafe { std::env::remove_var("DEEPINFRA_TOKEN") };
1391 }
1392
1393 #[test]
1394 fn novita_env_aliases_resolve() {
1395 let _lock = env_lock();
1396 clear_known_envs();
1397 unsafe { std::env::set_var("NOVITA_API_KEY", "novita-key") };
1399
1400 assert_eq!(env_for("novita").as_deref(), Some("novita-key"));
1401 assert_eq!(env_for("novita-ai").as_deref(), Some("novita-key"));
1403 assert_eq!(env_for("novita_ai").as_deref(), Some("novita-key"));
1404 unsafe { std::env::remove_var("NOVITA_API_KEY") };
1406 }
1407
1408 #[test]
1409 fn siliconflow_env_aliases_resolve() {
1410 let _lock = env_lock();
1411 clear_known_envs();
1412 unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
1414
1415 assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
1416 assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
1417 assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
1418 assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
1419 assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
1420 unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
1422 }
1423
1424 #[test]
1425 fn arcee_env_aliases_resolve() {
1426 let _lock = env_lock();
1427 clear_known_envs();
1428 unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
1430
1431 assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
1432 assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
1433 assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
1434 unsafe { std::env::remove_var("ARCEE_API_KEY") };
1436 }
1437
1438 #[test]
1439 fn moonshot_kimi_env_aliases_resolve() {
1440 let _lock = env_lock();
1441 clear_known_envs();
1442 unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
1444
1445 assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
1446 assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
1447 assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
1448 assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
1449 unsafe { std::env::remove_var("KIMI_API_KEY") };
1451 }
1452
1453 #[test]
1454 fn sglang_env_aliases_resolve() {
1455 let _lock = env_lock();
1456 clear_known_envs();
1457 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
1459
1460 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
1461 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
1462 unsafe { std::env::remove_var("SGLANG_API_KEY") };
1464 }
1465
1466 #[test]
1467 fn vllm_env_aliases_resolve() {
1468 let _lock = env_lock();
1469 clear_known_envs();
1470 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
1472
1473 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
1474 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
1475 unsafe { std::env::remove_var("VLLM_API_KEY") };
1477 }
1478
1479 #[test]
1480 fn ollama_env_aliases_resolve() {
1481 let _lock = env_lock();
1482 clear_known_envs();
1483 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
1485
1486 assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
1487 assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
1488 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
1490 }
1491
1492 #[cfg(unix)]
1493 #[test]
1494 fn file_store_round_trips_with_secure_perms() {
1495 use std::os::unix::fs::PermissionsExt;
1496
1497 let tmp = tempfile::tempdir().unwrap();
1498 let path = tmp.path().join("nested").join("secrets.json");
1499 let store = FileKeyringStore::new(path.clone());
1500 assert_eq!(store.get("deepseek").unwrap(), None);
1501 store.set("deepseek", "sk-disk").unwrap();
1502 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1503
1504 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1505 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
1506
1507 store.set("openrouter", "or-disk").unwrap();
1508 assert_eq!(
1509 store.get("openrouter").unwrap(),
1510 Some("or-disk".to_string())
1511 );
1512 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
1514
1515 store.delete("deepseek").unwrap();
1516 assert_eq!(store.get("deepseek").unwrap(), None);
1517 }
1518
1519 #[cfg(unix)]
1520 #[test]
1521 fn file_store_rejects_world_readable_file() {
1522 use std::os::unix::fs::PermissionsExt;
1523 let tmp = tempfile::tempdir().unwrap();
1524 let path = tmp.path().join("secrets.json");
1525 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
1526 let mut perms = fs::metadata(&path).unwrap().permissions();
1527 perms.set_mode(0o644);
1528 fs::set_permissions(&path, perms).unwrap();
1529
1530 let store = FileKeyringStore::new(path);
1531 let err = store.get("deepseek").unwrap_err();
1532 assert!(
1533 matches!(err, SecretsError::InsecurePermissions { .. }),
1534 "unexpected error: {err}"
1535 );
1536 }
1537
1538 #[cfg(unix)]
1544 #[test]
1545 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
1546 use std::os::unix::fs::PermissionsExt;
1547 let tmp = tempfile::tempdir().unwrap();
1548 let path = tmp.path().join("secrets.json");
1549 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1550 fs::write(&path, original).unwrap();
1551 let mut perms = fs::metadata(&path).unwrap().permissions();
1552 perms.set_mode(0o644);
1553 fs::set_permissions(&path, perms).unwrap();
1554
1555 let store = FileKeyringStore::new(path.clone());
1556 let err = store.set("openrouter", "or-new").unwrap_err();
1557 assert!(
1558 matches!(err, SecretsError::InsecurePermissions { .. }),
1559 "set must surface the read error rather than overwriting; got: {err}"
1560 );
1561
1562 let on_disk = fs::read_to_string(&path).unwrap();
1563 assert_eq!(
1564 on_disk, original,
1565 "set must not modify the file when load_unlocked errored"
1566 );
1567 }
1568
1569 #[cfg(unix)]
1570 #[test]
1571 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
1572 use std::os::unix::fs::PermissionsExt;
1573 let tmp = tempfile::tempdir().unwrap();
1574 let path = tmp.path().join("secrets.json");
1575 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
1576 fs::write(&path, original).unwrap();
1577 let mut perms = fs::metadata(&path).unwrap().permissions();
1578 perms.set_mode(0o644);
1579 fs::set_permissions(&path, perms).unwrap();
1580
1581 let store = FileKeyringStore::new(path.clone());
1582 let err = store.delete("nvidia").unwrap_err();
1583 assert!(
1584 matches!(err, SecretsError::InsecurePermissions { .. }),
1585 "delete must surface the read error rather than wiping the file; got: {err}"
1586 );
1587 let on_disk = fs::read_to_string(&path).unwrap();
1588 assert_eq!(on_disk, original);
1589 }
1590
1591 #[test]
1592 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
1593 let tmp = tempfile::tempdir().unwrap();
1594 let path = tmp.path().join("secrets.json");
1595 fs::write(&path, "{ this is not valid json").unwrap();
1598 #[cfg(unix)]
1599 {
1600 use std::os::unix::fs::PermissionsExt;
1601 let mut perms = fs::metadata(&path).unwrap().permissions();
1602 perms.set_mode(0o600);
1603 fs::set_permissions(&path, perms).unwrap();
1604 }
1605
1606 let store = FileKeyringStore::new(path.clone());
1607 let err = store.set("deepseek", "sk-new").unwrap_err();
1608 assert!(
1609 matches!(err, SecretsError::Json(_)),
1610 "set must surface the parse error rather than wiping the file; got: {err}"
1611 );
1612 let on_disk = fs::read_to_string(&path).unwrap();
1613 assert_eq!(on_disk, "{ this is not valid json");
1614 }
1615
1616 #[test]
1617 fn file_store_set_still_creates_file_when_missing() {
1618 let tmp = tempfile::tempdir().unwrap();
1623 let path = tmp.path().join("nested").join("secrets.json");
1624 let store = FileKeyringStore::new(path.clone());
1625
1626 store.set("deepseek", "sk-fresh").unwrap();
1627 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
1628 }
1629
1630 #[test]
1631 fn file_store_default_path_uses_home() {
1632 let _lock = env_lock();
1633 clear_known_envs();
1634 let tmp = tempfile::tempdir().unwrap();
1635 let _home = EnvVarGuard::set("HOME", tmp.path());
1636 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1637
1638 let path = FileKeyringStore::default_path().unwrap();
1639 assert_eq!(
1640 path,
1641 tmp.path()
1642 .join(".codewhale")
1643 .join("secrets")
1644 .join("secrets.json")
1645 );
1646 }
1647}