返回 CodeWhale
lib.rs
根目录 / crates / secrets / src / lib.rs
1 //! Secret storage for CodeWhale API keys.
2 //!
3 //! Provides a small abstraction (`KeyringStore`) plus a default
4 //! file-based implementation (`FileKeyringStore`), an opt-in OS keyring
5 //! implementation (`DefaultKeyringStore`), and an in-memory store for tests
6 //! (`InMemoryKeyringStore`).
7 //!
8 //! Higher-level lookup through [`Secrets::resolve`] checks the secret store first
9 //! and falls back to environment variables. Config-file precedence lives in the
10 //! config crate so user-facing commands can keep `config -> secret store -> env`
11 //! explicit at the call site.
12 #![deny(missing_docs)]
13
14 /// Shared secure-storage contract for the Codewhale account session.
15 pub mod account;
16
17 use std::collections::HashMap;
18 use std::fs;
19 use std::path::{Path, PathBuf};
20 use std::sync::{Arc, Mutex};
21
22 use codewhale_paths::codewhale_home_is_explicit;
23 use serde::{Deserialize, Serialize};
24 use thiserror::Error;
25
26 /// Default OS keychain service name. Kept as `deepseek` for compatibility
27 /// with credentials saved before the CodeWhale rename. macOS users can verify
28 /// entries with `security find-generic-password -s deepseek -a <provider>`.
29 pub const DEFAULT_SERVICE: &str = "deepseek";
30 /// Select the secret storage backend. Supported values are `file` (default)
31 /// and `system`/`keyring` for the OS credential store.
32 pub const SECRET_BACKEND_ENV: &str = "CODEWHALE_SECRET_BACKEND";
33 /// Legacy alias for [`SECRET_BACKEND_ENV`].
34 pub const LEGACY_SECRET_BACKEND_ENV: &str = "DEEPSEEK_SECRET_BACKEND";
35 const FILE_BACKEND_LABEL: &str = "file-based (~/.codewhale/secrets/)";
36
37 /// Errors that may arise from a [`KeyringStore`] backend.
38 #[derive(Debug, Error)]
39 pub enum SecretsError {
40 /// Underlying OS keyring backend reported an error.
41 #[error("keyring backend error: {0}")]
42 Keyring(String),
43 /// File-backed fallback I/O error.
44 #[error("file-backed secret store I/O error: {0}")]
45 Io(#[from] std::io::Error),
46 /// File-backed fallback JSON (de)serialisation error.
47 #[error("file-backed secret store JSON error: {0}")]
48 Json(#[from] serde_json::Error),
49 /// Caught when a stored secret on disk has unsafe permissions.
50 #[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
51 InsecurePermissions {
52 /// Absolute path to the secrets file.
53 path: PathBuf,
54 /// Observed unix permission mode.
55 mode: u32,
56 },
57 /// A caller attempted to modify a diagnostic-only secret store.
58 #[error("secret store is read-only")]
59 ReadOnly,
60 }
61
62 /// Abstract secret store trait.
63 ///
64 /// Concrete implementations may use the OS keyring ([`DefaultKeyringStore`]),
65 /// a JSON file under `~/.codewhale/secrets/` ([`FileKeyringStore`]), or an
66 /// in-memory map for tests ([`InMemoryKeyringStore`]).
67 ///
68 /// All implementations must be [`Send`] + [`Sync`] so they can be shared
69 /// across threads via [`Arc`].
70 pub trait KeyringStore: Send + Sync {
71 /// Read a secret by key.
72 ///
73 /// Returns `Ok(None)` if no entry exists for the given key. Returns
74 /// `Err` only on backend failures (I/O errors, keyring access issues).
75 fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
76
77 /// Write a secret, replacing any existing value for the same key.
78 ///
79 /// Creates the backing store (e.g. the JSON file) on first write if
80 /// it does not yet exist.
81 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
82
83 /// Remove a secret by key.
84 ///
85 /// Implementations should succeed (no-op) if the entry is already absent
86 /// rather than returning an error.
87 fn delete(&self, key: &str) -> Result<(), SecretsError>;
88
89 /// Short, human-readable label for this backend.
90 ///
91 /// Used by diagnostic output (e.g. `doctor` command) to indicate which
92 /// storage backend is active. Examples: `"file-based (~/.codewhale/secrets/)"`,
93 /// `"system keyring"`, `"in-memory (test)"`.
94 fn backend_name(&self) -> &'static str;
95 }
96
97 /// OS-native keyring backend.
98 ///
99 /// Wraps the platform credential store:
100 /// - **macOS**: Keychain (via `security` framework)
101 /// - **Windows**: Credential Manager
102 /// - **Linux**: Secret Service (GNOME Keyring / kwallet via dbus), excluding OHOS
103 ///
104 /// This backend is opt-in -- set the [`SECRET_BACKEND_ENV`] environment
105 /// variable to `system` or `keyring` to activate it. On platforms without
106 /// a configured native keyring dependency, [`probe`](DefaultKeyringStore::probe)
107 /// returns an unsupported error so [`Secrets::auto_detect`] can transparently
108 /// fall back to [`FileKeyringStore`].
109 #[derive(Debug, Clone)]
110 pub struct DefaultKeyringStore {
111 /// Keyring service name used to namespace stored credentials.
112 /// Defaults to [`DEFAULT_SERVICE`].
113 service: String,
114 }
115
116 impl Default for DefaultKeyringStore {
117 fn default() -> Self {
118 Self::new(DEFAULT_SERVICE)
119 }
120 }
121
122 impl DefaultKeyringStore {
123 /// Build a new store with the given service name.
124 #[must_use]
125 pub fn new(service: impl Into<String>) -> Self {
126 Self {
127 service: service.into(),
128 }
129 }
130
131 /// Probe the OS keyring without writing anything. Returns `Ok(())` if
132 /// a backend is reachable, otherwise an error describing why not.
133 ///
134 /// The probe reads a deliberately-nonexistent entry: reaching the
135 /// backend and learning the entry is absent *is* the reachability
136 /// signal. This does not prompt on any supported platform — macOS only
137 /// surfaces Keychain UI when accessing an *existing* item owned by
138 /// another application, and Windows Credential Manager never prompts
139 /// for a missing target — so `__probe__` under our own service name is
140 /// safe to read. `Entry::new` alone validates only argument shapes,
141 /// which left this probe a no-op on macOS/Windows and the documented
142 /// file-store fallback unreachable there (#5172).
143 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
183 impl 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 )))]
297 fn unsupported_keyring_message() -> String {
298 "system keyring backend is unsupported on this platform".to_string()
299 }
300
301 /// In-memory keyring store for tests.
302 ///
303 /// Stores secrets in a [`HashMap`] protected by a [`Mutex`]. Not persisted
304 /// to disk -- all entries are lost when the process exits. This is the
305 /// preferred store for unit tests because it requires no filesystem setup
306 /// and is safe to use in parallel test threads.
307 #[derive(Debug, Default)]
308 pub struct InMemoryKeyringStore {
309 /// Thread-safe map of key-value pairs.
310 entries: Mutex<HashMap<String, String>>,
311 }
312
313 impl InMemoryKeyringStore {
314 /// Create an empty store.
315 #[must_use]
316 pub fn new() -> Self {
317 Self::default()
318 }
319 }
320
321 impl 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 /// JSON-on-disk secret store for headless environments.
351 ///
352 /// This is the default backend. Secrets are serialised as a JSON object
353 /// at `<home>/.codewhale/secrets/secrets.json` with Unix file mode `0600`
354 /// (owner read/write only). The parent directory is created with mode `0700`
355 /// if it does not exist.
356 ///
357 /// On Unix, the store rejects files whose permissions are more permissive
358 /// than `0600` (i.e. group or world bits are set). This prevents other
359 /// users on the system from reading stored credentials. On Windows, the
360 /// ACL model is too different to enforce programmatically; callers are
361 /// responsible for placing the file in a per-user directory.
362 #[derive(Debug, Clone)]
363 pub struct FileKeyringStore {
364 /// Absolute path to the JSON secrets file.
365 path: PathBuf,
366 }
367
368 /// File-backed secret lookup that never migrates or changes either store.
369 ///
370 /// Normal runtime credential resolution keeps its additive legacy migration:
371 /// older entries under `~/.deepseek/secrets/` are copied into the Codewhale
372 /// location before use. Diagnostic commands need the same read precedence
373 /// without creating that destination, so this store reads the primary file
374 /// first and falls back to the legacy file only when the primary has no entry
375 /// and the Codewhale home is not explicitly isolated.
376 #[derive(Debug, Clone)]
377 struct ReadOnlyFileKeyringStore {
378 primary: FileKeyringStore,
379 /// The ambient legacy store is unavailable when `CODEWHALE_HOME` is an
380 /// explicit isolation boundary.
381 legacy: Option<FileKeyringStore>,
382 }
383
384 #[derive(Debug, Default, Serialize, Deserialize)]
385 struct FileSecretsBlob {
386 #[serde(default)]
387 entries: HashMap<String, String>,
388 }
389
390 impl FileKeyringStore {
391 /// Build a store backed by the given JSON file path.
392 #[must_use]
393 pub fn new(path: impl Into<PathBuf>) -> Self {
394 Self { path: path.into() }
395 }
396
397 /// Default path: `<home>/.codewhale/secrets/secrets.json`. Honours
398 /// `CODEWHALE_HOME`, then `HOME`, `USERPROFILE`, and finally the platform
399 /// home directory from the `dirs` crate. On first use, non-conflicting
400 /// entries from the legacy `<home>/.deepseek/secrets/secrets.json` file are
401 /// copied into the CodeWhale store — unless `CODEWHALE_HOME` is explicit,
402 /// in which case ambient `$HOME/.deepseek` credentials are never imported.
403 pub fn default_path() -> Result<PathBuf, SecretsError> {
404 let primary = default_codewhale_secrets_path()?;
405 // Match the diagnostic isolation boundary: an explicit Codewhale home
406 // must not silently pull ambient legacy DeepSeek credentials.
407 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 /// Resolve the primary and legacy secret paths without performing legacy
427 /// migration.
428 ///
429 /// This is intended for diagnostic-only lookup. Runtime and authentication
430 /// flows must keep using [`Self::default_path`] so their existing additive
431 /// migration behavior remains unchanged.
432 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 /// Path used for storage.
469 #[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 // Reject files with unsafe permissions on unix. On Windows the
479 // ACL model is too different to enforce here; the caller is
480 // responsible for placing the file in a per-user directory.
481 #[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 // Best-effort 0o600 — matches the parent-dir chmod above which
518 // is also `let _ = ...`. Filesystems that don't support Unix
519 // chmod (Docker bind-mounts of NTFS, network shares — #897)
520 // would otherwise fail the whole save here even though the
521 // blob already wrote successfully. The host's native ACLs
522 // are doing access control in those environments.
523 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
533 impl 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
547 impl 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)]
572 struct ReadOnlyKeyringStore {
573 inner: Arc<dyn KeyringStore>,
574 }
575
576 impl ReadOnlyKeyringStore {
577 fn new(inner: Arc<dyn KeyringStore>) -> Self {
578 Self { inner }
579 }
580 }
581
582 impl 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
600 fn write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
601 atomic_write_private_file(path, body)
602 }
603
604 fn 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
629 impl 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 // load_unlocked already returns Ok(default) for a missing file, so the
637 // first-write-creates-the-file path is preserved. Any other Err
638 // (insecure permissions, corrupt JSON, transient I/O) MUST surface to
639 // the caller — propagating it via `unwrap_or_default()` silently
640 // wipes every previously stored secret on the next `store_unlocked`.
641 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 // Same invariant as `set`: never fall back to an empty blob on read
648 // error, or `delete <one-key>` becomes `delete <every-key>`.
649 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
659 fn 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
669 fn 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
676 fn 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)]
684 enum SecretBackendSelection {
685 File,
686 System,
687 Unknown,
688 }
689
690 /// Secret-store backend selected by configuration for a structural diagnostic.
691 ///
692 /// This type deliberately describes only configuration and filesystem shape.
693 /// It never implies that a provider credential exists.
694 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
695 #[serde(rename_all = "snake_case")]
696 pub enum SecretBackendDiagnosticKind {
697 /// The JSON file store is selected.
698 File,
699 /// The operating-system credential store is selected.
700 System,
701 /// The configured backend value is unsupported.
702 Unknown,
703 }
704
705 /// Whether a secret-store path is present according to metadata only.
706 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
707 #[serde(rename_all = "snake_case")]
708 pub enum SecretBackendPresence {
709 /// A regular file exists at the resolved path.
710 Present,
711 /// No filesystem entry exists at the resolved path.
712 Absent,
713 /// Presence is unavailable or the entry is not a regular file.
714 Unknown,
715 }
716
717 /// Scope of inspection performed for a structural secret-backend diagnostic.
718 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
719 #[serde(rename_all = "snake_case")]
720 pub enum SecretBackendInspection {
721 /// Only filesystem metadata was inspected; file contents were not opened.
722 MetadataOnly,
723 /// The backend was not constructed, probed, or read.
724 NotProbed,
725 }
726
727 /// Secret-safe structural description of the configured credential backend.
728 ///
729 /// File-backed diagnostics expose resolved paths and regular-file presence from
730 /// metadata without opening either store. System backends intentionally report
731 /// `unknown` / `not_probed`: constructing or probing an OS keyring can show a
732 /// user prompt even when no credential value is requested.
733 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
734 pub struct SecretBackendDiagnostic {
735 /// Configured backend family.
736 pub backend: SecretBackendDiagnosticKind,
737 /// Inspection performed to produce this report.
738 pub inspection: SecretBackendInspection,
739 /// Canonical file-store path, when the file backend is selected.
740 pub path: Option<PathBuf>,
741 /// Metadata-only presence of the canonical file-store path.
742 pub presence: SecretBackendPresence,
743 /// Ambient legacy file-store path, suppressed by explicit `CODEWHALE_HOME`.
744 pub legacy_path: Option<PathBuf>,
745 /// Metadata-only presence of the legacy file-store path.
746 pub legacy_presence: SecretBackendPresence,
747 }
748
749 /// Describe the configured credential backend without probing or reading it.
750 ///
751 /// This function never constructs [`DefaultKeyringStore`], calls
752 /// [`KeyringStore::get`], opens a secret file, performs legacy migration, or
753 /// creates filesystem state. It is suitable for ordinary status and doctor
754 /// commands.
755 #[must_use]
756 pub 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
790 fn 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
821 fn 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
832 fn 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 /// High-level facade combining a [`KeyringStore`] with environment variable fallbacks.
840 ///
841 /// Lookup precedence: **secret store -> env -> none**. Callers that also
842 /// have a TOML config layer must wire that themselves at the very end
843 /// of the chain (the config crate handles this).
844 ///
845 /// # Examples
846 ///
847 /// ```no_run
848 /// use codewhale_secrets::Secrets;
849 ///
850 /// let secrets = Secrets::auto_detect();
851 /// if let Some(key) = secrets.resolve("deepseek") {
852 /// // use the API key
853 /// }
854 /// ```
855 #[derive(Clone)]
856 pub struct Secrets {
857 /// Underlying secret store backend.
858 pub store: Arc<dyn KeyringStore>,
859 /// Owner identifier within the secret store (typically `"deepseek"`).
860 /// The `key` parameter passed to [`resolve`](Secrets::resolve) is
861 /// forwarded to the store as-is, while environment variables are
862 /// looked up by canonical provider name via [`env_for`].
863 service: String,
864 }
865
866 /// Identifies which layer in the resolution chain supplied a secret.
867 ///
868 /// Returned by [`Secrets::resolve_with_source`] so callers can
869 /// distinguish whether a value came from the configured store or from
870 /// a process environment variable.
871 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
872 pub enum SecretSource {
873 /// The secret was returned by the configured [`KeyringStore`] backend.
874 Keyring,
875 /// The secret was found in a process environment variable.
876 Env,
877 }
878
879 impl 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
888 impl Secrets {
889 /// Build a new facade around the given store, using the
890 /// [`DEFAULT_SERVICE`] service name.
891 #[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 /// Auto-detect the best available backend based on the environment.
900 ///
901 /// Selection logic:
902 /// 1. If [`SECRET_BACKEND_ENV`] is set to `system`/`keyring`/`os`/`os-keyring`,
903 /// probe the OS keyring. If the probe succeeds, use it; otherwise
904 /// fall back to the file-based store with a warning.
905 /// 2. If the env var is unset, empty, or `file`/`local`/`json`, use
906 /// the file-based store directly.
907 /// 3. If the env var is set to an unrecognised value, log a warning
908 /// and use the file-based store.
909 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 /// Auto-detect a secret backend for diagnostics without permitting writes
934 /// or legacy migration.
935 ///
936 /// The selected backend and lookup precedence match [`Self::auto_detect`],
937 /// but file-backed lookup reads the Codewhale location first and the legacy
938 /// location second instead of copying legacy entries into a new file. This
939 /// lets status and doctor reports label a saved credential without changing
940 /// user state.
941 #[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 /// Build the writable default store only when the resolved path is safe.
973 ///
974 /// Keeping the resolution result as an argument gives the no-home and
975 /// relative-path branches direct regression coverage. Both must refuse
976 /// writes rather than placing credentials in the caller's workspace.
977 fn file_backed_from_default_path(path_result: Result<PathBuf, SecretsError>) -> Self {
978 // Never fall back to a workspace-relative secrets path. Writing
979 // credential material beside the cwd is readable by tools and easy to
980 // commit. If home resolution fails, use a write-refusing store.
981 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 /// An unavailable default path must be hermetic: neither inspect an
1000 /// accidental workspace file nor create one. The read-only wrapper keeps
1001 /// the public API's write failure explicit while reads safely report empty.
1002 fn read_only_empty_store() -> Self {
1003 Self::new(Arc::new(ReadOnlyKeyringStore::new(Arc::new(
1004 InMemoryKeyringStore::new(),
1005 ))))
1006 }
1007
1008 /// Construct a file-backed diagnostic store without migration or write
1009 /// capability.
1010 ///
1011 /// This reads the Codewhale file first and the legacy file second (unless
1012 /// `CODEWHALE_HOME` is explicit), but never copies legacy entries into a
1013 /// primary store. It intentionally bypasses an opted-in OS keyring so
1014 /// callers that only need non-secret diagnostics do not cause a platform
1015 /// credential prompt.
1016 #[must_use]
1017 pub fn file_backed_read_only() -> Self {
1018 // Fail closed like the writable path in `file_backed_from_default_path`:
1019 // never fall back to a cwd-relative credential path. A planted
1020 // `.codewhale-secrets.json` beside the working directory must not
1021 // become the credential store when home resolution fails.
1022 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 /// Construct the file-backed default backend directly.
1035 #[must_use]
1036 pub fn file_backed() -> Self {
1037 Self::file_backed_default()
1038 }
1039
1040 /// Construct the opt-in OS credential backend, falling back to the
1041 /// file-backed store when the platform backend is unavailable.
1042 #[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 /// Backend label, suitable for `doctor` output.
1057 #[must_use]
1058 pub fn backend_name(&self) -> &'static str {
1059 self.store.backend_name()
1060 }
1061
1062 /// Resolve a secret with `secret store → env → none` precedence.
1063 ///
1064 /// `name` is the canonical provider name or a supported provider alias.
1065 /// Empty strings on either layer are treated as "not set".
1066 #[must_use]
1067 pub fn resolve(&self, name: &str) -> Option<String> {
1068 self.resolve_with_source(name).map(|(value, _)| value)
1069 }
1070
1071 /// Resolve a secret and report which layer supplied it.
1072 #[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 /// Convenience: write a secret through the underlying store.
1083 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
1084 self.store.set(name, value)
1085 }
1086
1087 /// Convenience: delete a secret through the underlying store.
1088 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
1089 self.store.delete(name)
1090 }
1091
1092 /// Convenience: read a secret directly (no env fallback).
1093 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
1094 self.store.get(name)
1095 }
1096
1097 /// Resolve a secret by key name with an optional source constraint.
1098 ///
1099 /// This is the fleet-worker secret resolution path. Unlike
1100 /// [`resolve`](Secrets::resolve), this does NOT map provider names
1101 /// to their canonical env vars — the caller controls the exact key
1102 /// and resolution order.
1103 ///
1104 /// `source_hint` controls the resolution order:
1105 /// - `Some("env")` — only check environment variables
1106 /// - `Some("keyring")` — only check the keyring/file store
1107 /// - `None` — try the store first, then fall back to environment
1108 #[must_use]
1109 pub fn resolve_direct(&self, key: &str, source_hint: Option<&str>) -> Option<String> {
1110 match source_hint {
1111 Some("env") => {
1112 // Only check process environment — skip the store entirely.
1113 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
1114 }
1115 Some("keyring") | Some("file") => {
1116 // Only check the store backend.
1117 self.store
1118 .get(key)
1119 .ok()
1120 .flatten()
1121 .filter(|v| !v.trim().is_empty())
1122 }
1123 Some(_) | None => {
1124 // Default: store first, then env fallback.
1125 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 /// Map a canonical provider name to its environment variable(s), returning
1137 /// the first non-empty value found.
1138 ///
1139 /// Provider names are case-insensitive. Supported providers and their
1140 /// environment variables:
1141 ///
1142 /// | Provider | Env var(s) |
1143 /// |---|---|
1144 /// | `deepseek` | `DEEPSEEK_API_KEY` |
1145 /// | `openrouter` | `OPENROUTER_API_KEY` |
1146 /// | `xiaomi-mimo` / `mimo` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |
1147 /// | `novita` / `novita-ai` | `NOVITA_API_KEY` |
1148 /// | `nvidia` / `nvidia-nim` / `nim` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY`, `DEEPSEEK_API_KEY` |
1149 /// | `fireworks` / `fireworks-ai` | `FIREWORKS_API_KEY` |
1150 /// | `together` / `togetherai` | `TOGETHER_API_KEY` |
1151 /// | `deepinfra` | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` |
1152 /// | `siliconflow` / `siliconflow-cn` | `SILICONFLOW_API_KEY` |
1153 /// | `arcee` / `arcee-ai` | `ARCEE_API_KEY` |
1154 /// | `moonshot` / `kimi` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |
1155 /// | `sglang` | `SGLANG_API_KEY` |
1156 /// | `vllm` | `VLLM_API_KEY` |
1157 /// | `ollama` | `OLLAMA_API_KEY` |
1158 /// | `openai` | `OPENAI_API_KEY` |
1159 /// | `atlascloud` / `atlas` | `ATLASCLOUD_API_KEY` |
1160 /// | `volcengine` / `ark` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |
1161 /// | `wanjie` / `wanjie-ark` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |
1162 /// | `meta` / `muse-spark` | `META_MODEL_API_KEY`, `MODEL_API_KEY` |
1163 /// | `xai` / `grok` | `XAI_API_KEY` |
1164 /// | `telecomjs` / `tokenhub` | `TELECOMJS_API_KEY` |
1165 ///
1166 /// Returns `None` if the provider is not recognised or none of its
1167 /// candidate environment variables are set to a non-empty value.
1168 #[must_use]
1169 pub fn env_for(name: &str) -> Option<String> {
1170 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
1171 "deepseek" => &["DEEPSEEK_API_KEY"],
1172 "openrouter" => &["OPENROUTER_API_KEY"],
1173 "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
1174 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
1175 }
1176 "novita" | "novita-ai" | "novita_ai" => &["NOVITA_API_KEY"],
1177 "together" | "together-ai" | "together_ai" | "togetherai" => &["TOGETHER_API_KEY"],
1178 "deepinfra" | "deep-infra" | "deep_infra" => &["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
1179 // NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
1180 // catalog endpoint accepts the same DeepSeek-issued key when no
1181 // dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
1182 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
1183 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
1184 }
1185 "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
1186 "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
1187 | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
1188 "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
1189 "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
1190 "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
1191 "vllm" | "v-llm" => &["VLLM_API_KEY"],
1192 "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
1193 "openai" => &["OPENAI_API_KEY"],
1194 "anthropic" | "claude" => &["ANTHROPIC_API_KEY"],
1195 "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
1196 "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
1197 | "volcengineark" => &[
1198 "VOLCENGINE_API_KEY",
1199 "VOLCENGINE_ARK_API_KEY",
1200 "ARK_API_KEY",
1201 ],
1202 "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
1203 | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
1204 "WANJIE_ARK_API_KEY",
1205 "WANJIE_API_KEY",
1206 "WANJIE_MAAS_API_KEY",
1207 ],
1208 "sakana" | "sakana-ai" | "sakana_ai" | "fugu" => &["FUGU_API_KEY", "SAKANA_API_KEY"],
1209 "longcat" | "long-cat" | "meituan-longcat" | "meituan" => &["LONGCAT_API_KEY"],
1210 "opencode-go" | "opencode_go" | "opencodego" => &["OPENCODE_GO_API_KEY"],
1211 "opencode-zen" | "opencode_zen" | "opencodezen" | "zen" | "opencode" => {
1212 &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"]
1213 }
1214 "meta" | "meta-ai" | "meta_ai" | "meta-model-api" | "meta_model_api" | "muse"
1215 | "muse-spark" => &["META_MODEL_API_KEY", "MODEL_API_KEY"],
1216 "xai" | "x-ai" | "x_ai" | "grok" => &["XAI_API_KEY"],
1217 "telecomjs" | "telecom-js" | "telecom_js" | "telecomjs-cn" | "tokenhub" => {
1218 &["TELECOMJS_API_KEY"]
1219 }
1220 // One Alibaba Cloud Model Studio account authenticates every plan /
1221 // dialect variant; all four names share one env convention.
1222 "modelstudio-token-plan"
1223 | "modelstudio_token_plan"
1224 | "modelstudio-token-plan-anthropic"
1225 | "modelstudio_token_plan_anthropic"
1226 | "modelstudio-coding-plan"
1227 | "modelstudio_coding_plan"
1228 | "modelstudio-coding-plan-anthropic"
1229 | "modelstudio_coding_plan_anthropic"
1230 | "modelstudio"
1231 | "dashscope"
1232 | "alibaba-token-plan"
1233 | "alibaba-coding-plan" => &["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"],
1234 _ => return None,
1235 };
1236 for var in candidates {
1237 if let Ok(value) = std::env::var(var)
1238 && !value.trim().is_empty()
1239 {
1240 return Some(value);
1241 }
1242 }
1243 None
1244 }
1245
1246 #[cfg(test)]
1247 mod tests {
1248 use super::*;
1249 use std::sync::{Mutex, OnceLock};
1250
1251 /// Serialise env-mutating tests: tests in this module poke
1252 /// `DEEPSEEK_API_KEY` etc., which is process-global.
1253 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1254 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1255 LOCK.get_or_init(|| Mutex::new(()))
1256 .lock()
1257 .unwrap_or_else(|p| p.into_inner())
1258 }
1259
1260 fn clear_known_envs() {
1261 for var in [
1262 "CODEWHALE_HOME",
1263 "DEEPSEEK_API_KEY",
1264 "OPENROUTER_API_KEY",
1265 "NOVITA_API_KEY",
1266 "NVIDIA_API_KEY",
1267 "NVIDIA_NIM_API_KEY",
1268 "FIREWORKS_API_KEY",
1269 "TOGETHER_API_KEY",
1270 "DEEPINFRA_API_KEY",
1271 "DEEPINFRA_TOKEN",
1272 "SILICONFLOW_API_KEY",
1273 "ARCEE_API_KEY",
1274 "SGLANG_API_KEY",
1275 "VLLM_API_KEY",
1276 "OLLAMA_API_KEY",
1277 "OPENAI_API_KEY",
1278 "ATLASCLOUD_API_KEY",
1279 "WANJIE_ARK_API_KEY",
1280 "WANJIE_API_KEY",
1281 "WANJIE_MAAS_API_KEY",
1282 "XIAOMI_MIMO_API_KEY",
1283 "XIAOMI_API_KEY",
1284 "MIMO_API_KEY",
1285 "FUGU_API_KEY",
1286 "SAKANA_API_KEY",
1287 "LONGCAT_API_KEY",
1288 "OPENCODE_GO_API_KEY",
1289 "OPENCODE_ZEN_API_KEY",
1290 "OPENCODE_API_KEY",
1291 "META_MODEL_API_KEY",
1292 "MODEL_API_KEY",
1293 "XAI_API_KEY",
1294 "TELECOMJS_API_KEY",
1295 "MODELSTUDIO_API_KEY",
1296 "DASHSCOPE_API_KEY",
1297 SECRET_BACKEND_ENV,
1298 LEGACY_SECRET_BACKEND_ENV,
1299 ] {
1300 // Safety: tests serialise on env_lock(); the broader
1301 // workspace has the same pattern in `crates/config`.
1302 unsafe { std::env::remove_var(var) };
1303 }
1304 }
1305
1306 struct EnvVarGuard {
1307 name: &'static str,
1308 previous: Option<std::ffi::OsString>,
1309 }
1310
1311 impl EnvVarGuard {
1312 fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
1313 let previous = std::env::var_os(name);
1314 unsafe { std::env::set_var(name, value) };
1315 Self { name, previous }
1316 }
1317 }
1318
1319 impl Drop for EnvVarGuard {
1320 fn drop(&mut self) {
1321 match self.previous.take() {
1322 Some(value) => unsafe { std::env::set_var(self.name, value) },
1323 None => unsafe { std::env::remove_var(self.name) },
1324 }
1325 }
1326 }
1327
1328 /// Live check for #5172: on macOS/Windows the probe used to return Ok
1329 /// without touching the backend at all. Run explicitly with
1330 /// `cargo test -p codewhale-secrets -- --ignored` on a desktop machine:
1331 /// a healthy native keyring answers a read of the deliberately absent
1332 /// `__probe__` entry with NoEntry, silently, and the probe succeeds.
1333 #[test]
1334 #[ignore = "touches the real OS keyring; run on a desktop machine"]
1335 fn probe_performs_a_real_backend_read() {
1336 let store = DefaultKeyringStore::new("codewhale-probe-live-check");
1337 store
1338 .probe()
1339 .expect("the native keyring backend should be reachable on this machine");
1340 }
1341
1342 #[test]
1343 fn backend_selection_defaults_to_file() {
1344 assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
1345 assert_eq!(
1346 secret_backend_selection(Some("")),
1347 SecretBackendSelection::File
1348 );
1349 assert_eq!(
1350 secret_backend_selection(Some(" file ")),
1351 SecretBackendSelection::File
1352 );
1353 }
1354
1355 #[test]
1356 fn backend_selection_accepts_explicit_system_keyring() {
1357 assert_eq!(
1358 secret_backend_selection(Some("system")),
1359 SecretBackendSelection::System
1360 );
1361 assert_eq!(
1362 secret_backend_selection(Some("keyring")),
1363 SecretBackendSelection::System
1364 );
1365 assert_eq!(
1366 secret_backend_selection(Some("os-keyring")),
1367 SecretBackendSelection::System
1368 );
1369 }
1370
1371 #[test]
1372 fn auto_detect_is_file_backed_by_default() {
1373 let _lock = env_lock();
1374 clear_known_envs();
1375 let tmp = tempfile::tempdir().unwrap();
1376 let _home = EnvVarGuard::set("HOME", tmp.path());
1377 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1378
1379 let secrets = Secrets::auto_detect();
1380
1381 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1382 }
1383
1384 #[test]
1385 fn auto_detect_honors_explicit_file_backend() {
1386 let _lock = env_lock();
1387 clear_known_envs();
1388 let tmp = tempfile::tempdir().unwrap();
1389 let _home = EnvVarGuard::set("HOME", tmp.path());
1390 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1391 // Safety: env mutation guarded by env_lock().
1392 unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
1393
1394 let secrets = Secrets::auto_detect();
1395
1396 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1397 // Safety: env mutation guarded by env_lock().
1398 unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
1399 }
1400
1401 #[test]
1402 fn read_only_auto_detect_reads_legacy_without_migrating_or_allowing_writes() {
1403 let _lock = env_lock();
1404 clear_known_envs();
1405 let tmp = tempfile::tempdir().unwrap();
1406 let _home = EnvVarGuard::set("HOME", tmp.path());
1407 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1408 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1409 let legacy = tmp
1410 .path()
1411 .join(".deepseek")
1412 .join("secrets")
1413 .join("secrets.json");
1414 let primary = tmp
1415 .path()
1416 .join(".codewhale")
1417 .join("secrets")
1418 .join("secrets.json");
1419 FileKeyringStore::new(&legacy)
1420 .set("moonshot", "fixture-legacy-value")
1421 .unwrap();
1422
1423 let secrets = Secrets::auto_detect_read_only();
1424
1425 assert_eq!(
1426 secrets.get("moonshot").unwrap().as_deref(),
1427 Some("fixture-legacy-value")
1428 );
1429 assert!(
1430 !primary.exists(),
1431 "diagnostic lookup must not migrate the legacy store"
1432 );
1433 assert!(
1434 matches!(
1435 secrets.set("moonshot", "replacement"),
1436 Err(SecretsError::ReadOnly)
1437 ),
1438 "the diagnostic secret facade must refuse writes"
1439 );
1440 assert!(
1441 !primary.exists(),
1442 "a refused diagnostic write must not create the primary store"
1443 );
1444 }
1445
1446 #[test]
1447 fn read_only_auto_detect_respects_explicit_codewhale_home_isolation() {
1448 let _lock = env_lock();
1449 clear_known_envs();
1450 let tmp = tempfile::tempdir().unwrap();
1451 let codewhale_home = tmp.path().join("isolated-codewhale-home");
1452 let _home = EnvVarGuard::set("HOME", tmp.path());
1453 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1454 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1455 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1456 let legacy = tmp
1457 .path()
1458 .join(".deepseek")
1459 .join("secrets")
1460 .join("secrets.json");
1461 let primary = codewhale_home.join("secrets").join("secrets.json");
1462 FileKeyringStore::new(&legacy)
1463 .set("deepseek", "synthetic-ambient-legacy-value")
1464 .unwrap();
1465
1466 let secrets = Secrets::auto_detect_read_only();
1467
1468 assert_eq!(
1469 secrets.get("deepseek").unwrap(),
1470 None,
1471 "an explicit CODEWHALE_HOME must not read ambient legacy secrets"
1472 );
1473 assert!(
1474 !primary.exists(),
1475 "diagnostic lookup must not create an isolated primary store"
1476 );
1477 assert!(
1478 matches!(
1479 secrets.set("deepseek", "replacement"),
1480 Err(SecretsError::ReadOnly)
1481 ),
1482 "the isolated diagnostic facade must refuse writes"
1483 );
1484 assert!(
1485 !primary.exists(),
1486 "a refused isolated diagnostic write must not create the primary store"
1487 );
1488 }
1489
1490 /// Cwd is process-global, so tests that move it serialise on `env_lock`
1491 /// like the env-mutating tests and restore on drop.
1492 struct CwdGuard {
1493 previous: PathBuf,
1494 }
1495
1496 impl CwdGuard {
1497 fn enter(path: &Path) -> Self {
1498 let previous = std::env::current_dir().unwrap();
1499 std::env::set_current_dir(path).unwrap();
1500 Self { previous }
1501 }
1502 }
1503
1504 impl Drop for CwdGuard {
1505 fn drop(&mut self) {
1506 std::env::set_current_dir(&self.previous).unwrap();
1507 }
1508 }
1509
1510 #[test]
1511 fn file_backed_read_only_never_reads_a_cwd_relative_store() {
1512 let _lock = env_lock();
1513 clear_known_envs();
1514 let tmp = tempfile::tempdir().unwrap();
1515 // A relative override fails home resolution deterministically, which
1516 // used to fall back to a planted `.codewhale-secrets.json` in the cwd.
1517 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", "relative-codewhale-home");
1518 let planted = tmp.path().join(".codewhale-secrets.json");
1519 std::fs::write(
1520 &planted,
1521 r#"{"entries":{"deepseek":"planted-cwd-credential"}}"#,
1522 )
1523 .unwrap();
1524 let _cwd = CwdGuard::enter(tmp.path());
1525
1526 let secrets = Secrets::file_backed_read_only();
1527
1528 assert_eq!(
1529 secrets.get("deepseek").unwrap(),
1530 None,
1531 "a failed home resolution must not turn a planted cwd file into the credential store"
1532 );
1533 assert!(
1534 matches!(
1535 secrets.set("deepseek", "replacement"),
1536 Err(SecretsError::ReadOnly)
1537 ),
1538 "the failed-resolution diagnostic facade must still refuse writes"
1539 );
1540 }
1541
1542 #[test]
1543 fn read_only_auto_detect_reads_the_explicit_primary_store() {
1544 let _lock = env_lock();
1545 clear_known_envs();
1546 let tmp = tempfile::tempdir().unwrap();
1547 let codewhale_home = tmp.path().join("isolated-codewhale-home");
1548 let _home = EnvVarGuard::set("HOME", tmp.path());
1549 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1550 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1551 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1552 let primary = codewhale_home.join("secrets").join("secrets.json");
1553 FileKeyringStore::new(&primary)
1554 .set("deepseek", "synthetic-isolated-primary-value")
1555 .unwrap();
1556
1557 let secrets = Secrets::auto_detect_read_only();
1558
1559 assert_eq!(
1560 secrets.get("deepseek").unwrap().as_deref(),
1561 Some("synthetic-isolated-primary-value")
1562 );
1563 }
1564
1565 #[test]
1566 fn auto_detect_honors_legacy_backend_env_alias() {
1567 let _lock = env_lock();
1568 clear_known_envs();
1569 let tmp = tempfile::tempdir().unwrap();
1570 let _home = EnvVarGuard::set("HOME", tmp.path());
1571 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1572 unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
1573
1574 let secrets = Secrets::auto_detect();
1575
1576 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1577 clear_known_envs();
1578 }
1579
1580 #[test]
1581 fn file_default_path_uses_codewhale_home() {
1582 let _lock = env_lock();
1583 clear_known_envs();
1584 let tmp = tempfile::tempdir().unwrap();
1585 let _home = EnvVarGuard::set("HOME", tmp.path());
1586 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1587
1588 let path = FileKeyringStore::default_path().unwrap();
1589
1590 assert_eq!(
1591 path,
1592 tmp.path()
1593 .join(".codewhale")
1594 .join("secrets")
1595 .join("secrets.json")
1596 );
1597 }
1598
1599 #[test]
1600 fn file_default_path_honors_codewhale_home() {
1601 let _lock = env_lock();
1602 clear_known_envs();
1603 let tmp = tempfile::tempdir().unwrap();
1604 let custom = tmp.path().join("custom-codewhale");
1605 let _home = EnvVarGuard::set("HOME", tmp.path());
1606 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1607 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
1608
1609 let path = FileKeyringStore::default_path().unwrap();
1610
1611 assert_eq!(path, custom.join("secrets").join("secrets.json"));
1612 }
1613
1614 #[test]
1615 fn file_default_path_migrates_legacy_entries_to_codewhale() {
1616 let _lock = env_lock();
1617 clear_known_envs();
1618 let tmp = tempfile::tempdir().unwrap();
1619 let _home = EnvVarGuard::set("HOME", tmp.path());
1620 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1621 let legacy = tmp
1622 .path()
1623 .join(".deepseek")
1624 .join("secrets")
1625 .join("secrets.json");
1626 FileKeyringStore::new(legacy.clone())
1627 .set("xiaomi-mimo", "legacy-mimo")
1628 .unwrap();
1629
1630 let primary = FileKeyringStore::default_path().unwrap();
1631 let primary_store = FileKeyringStore::new(primary.clone());
1632
1633 assert_eq!(
1634 primary,
1635 tmp.path()
1636 .join(".codewhale")
1637 .join("secrets")
1638 .join("secrets.json")
1639 );
1640 assert_eq!(
1641 primary_store.get("xiaomi-mimo").unwrap().as_deref(),
1642 Some("legacy-mimo")
1643 );
1644 assert!(
1645 legacy.exists(),
1646 "migration copies; it does not delete legacy data"
1647 );
1648 }
1649
1650 #[test]
1651 fn file_default_path_migration_preserves_primary_values() {
1652 let _lock = env_lock();
1653 clear_known_envs();
1654 let tmp = tempfile::tempdir().unwrap();
1655 let _home = EnvVarGuard::set("HOME", tmp.path());
1656 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1657 let legacy = tmp
1658 .path()
1659 .join(".deepseek")
1660 .join("secrets")
1661 .join("secrets.json");
1662 let primary = tmp
1663 .path()
1664 .join(".codewhale")
1665 .join("secrets")
1666 .join("secrets.json");
1667 FileKeyringStore::new(legacy)
1668 .set("openrouter", "legacy-openrouter")
1669 .unwrap();
1670 let primary_store = FileKeyringStore::new(primary.clone());
1671 primary_store
1672 .set("openrouter", "primary-openrouter")
1673 .unwrap();
1674
1675 let resolved = FileKeyringStore::default_path().unwrap();
1676
1677 assert_eq!(resolved, primary);
1678 assert_eq!(
1679 primary_store.get("openrouter").unwrap().as_deref(),
1680 Some("primary-openrouter")
1681 );
1682 }
1683
1684 #[test]
1685 fn in_memory_store_round_trips() {
1686 let store = InMemoryKeyringStore::new();
1687 assert_eq!(store.get("deepseek").unwrap(), None);
1688 store.set("deepseek", "sk-test").unwrap();
1689 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
1690 store.set("deepseek", "sk-replaced").unwrap();
1691 assert_eq!(
1692 store.get("deepseek").unwrap(),
1693 Some("sk-replaced".to_string())
1694 );
1695 store.delete("deepseek").unwrap();
1696 assert_eq!(store.get("deepseek").unwrap(), None);
1697 // Deleting an absent key is a no-op.
1698 store.delete("missing").unwrap();
1699 }
1700
1701 #[test]
1702 fn resolve_prefers_keyring_over_env() {
1703 let _lock = env_lock();
1704 clear_known_envs();
1705 // Safety: env mutation guarded by env_lock().
1706 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1707
1708 let store = Arc::new(InMemoryKeyringStore::new());
1709 store.set("deepseek", "ring-key").unwrap();
1710 let secrets = Secrets::new(store);
1711
1712 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1713 assert_eq!(
1714 secrets.resolve_with_source("deepseek"),
1715 Some(("ring-key".to_string(), SecretSource::Keyring))
1716 );
1717 // Safety: env mutation guarded by env_lock().
1718 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1719 }
1720
1721 #[test]
1722 fn resolve_falls_back_to_env_when_keyring_empty() {
1723 let _lock = env_lock();
1724 clear_known_envs();
1725 // Safety: env mutation guarded by env_lock().
1726 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1727
1728 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1729 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1730 assert_eq!(
1731 secrets.resolve_with_source("deepseek"),
1732 Some(("env-fallback".to_string(), SecretSource::Env))
1733 );
1734 // Safety: env mutation guarded by env_lock().
1735 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1736 }
1737
1738 #[test]
1739 fn resolve_returns_none_when_both_layers_empty() {
1740 let _lock = env_lock();
1741 clear_known_envs();
1742 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1743 assert_eq!(secrets.resolve("deepseek"), None);
1744 }
1745
1746 #[test]
1747 fn resolve_treats_blank_keyring_value_as_unset() {
1748 let _lock = env_lock();
1749 clear_known_envs();
1750 // Safety: env mutation guarded by env_lock().
1751 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1752
1753 let store = Arc::new(InMemoryKeyringStore::new());
1754 store.set("deepseek", " ").unwrap();
1755 let secrets = Secrets::new(store);
1756 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
1757 // Safety: env mutation guarded by env_lock().
1758 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1759 }
1760
1761 #[test]
1762 fn nvidia_env_aliases_resolve() {
1763 let _lock = env_lock();
1764 clear_known_envs();
1765 // Safety: env mutation guarded by env_lock().
1766 unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
1767 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1768 assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
1769 assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
1770 // Safety: env mutation guarded by env_lock().
1771 unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
1772 }
1773
1774 #[test]
1775 fn atlascloud_env_aliases_resolve() {
1776 let _guard = env_lock();
1777 clear_known_envs();
1778 unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
1779
1780 assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
1781 assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
1782 assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
1783
1784 clear_known_envs();
1785 }
1786
1787 #[test]
1788 fn sakana_env_aliases_resolve() {
1789 let _guard = env_lock();
1790 clear_known_envs();
1791 unsafe { std::env::set_var("FUGU_API_KEY", "fugu-key") };
1792
1793 assert_eq!(env_for("sakana").as_deref(), Some("fugu-key"));
1794 assert_eq!(env_for("sakana-ai").as_deref(), Some("fugu-key"));
1795 assert_eq!(env_for("sakana_ai").as_deref(), Some("fugu-key"));
1796 assert_eq!(env_for("fugu").as_deref(), Some("fugu-key"));
1797
1798 clear_known_envs();
1799 unsafe { std::env::set_var("SAKANA_API_KEY", "sakana-key") };
1800 assert_eq!(env_for("sakana").as_deref(), Some("sakana-key"));
1801
1802 clear_known_envs();
1803 }
1804
1805 #[test]
1806 fn wanjie_ark_env_aliases_resolve() {
1807 let _guard = env_lock();
1808 clear_known_envs();
1809 unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
1810
1811 assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
1812 assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
1813 assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
1814
1815 clear_known_envs();
1816 }
1817
1818 #[test]
1819 fn xai_env_aliases_resolve() {
1820 let _guard = env_lock();
1821 clear_known_envs();
1822 unsafe { std::env::set_var("XAI_API_KEY", "xai-key") };
1823
1824 assert_eq!(env_for("xai").as_deref(), Some("xai-key"));
1825 assert_eq!(env_for("x-ai").as_deref(), Some("xai-key"));
1826 assert_eq!(env_for("x_ai").as_deref(), Some("xai-key"));
1827 assert_eq!(env_for("grok").as_deref(), Some("xai-key"));
1828
1829 clear_known_envs();
1830 }
1831
1832 #[test]
1833 fn telecomjs_env_aliases_resolve() {
1834 let _guard = env_lock();
1835 clear_known_envs();
1836 unsafe { std::env::set_var("TELECOMJS_API_KEY", "telecom-key") };
1837
1838 for alias in [
1839 "telecomjs",
1840 "telecom-js",
1841 "telecom_js",
1842 "telecomjs-cn",
1843 "tokenhub",
1844 ] {
1845 assert_eq!(env_for(alias).as_deref(), Some("telecom-key"), "{alias}");
1846 }
1847
1848 clear_known_envs();
1849 }
1850
1851 #[test]
1852 fn opencode_go_env_aliases_resolve() {
1853 let _guard = env_lock();
1854 clear_known_envs();
1855 unsafe { std::env::set_var("OPENCODE_GO_API_KEY", "go-key") };
1856
1857 for alias in ["opencode-go", "opencode_go", "opencodego"] {
1858 assert_eq!(env_for(alias).as_deref(), Some("go-key"), "{alias}");
1859 }
1860
1861 clear_known_envs();
1862 }
1863
1864 #[test]
1865 fn modelstudio_variants_share_one_env_convention() {
1866 let _guard = env_lock();
1867 clear_known_envs();
1868 unsafe { std::env::set_var("MODELSTUDIO_API_KEY", "ms-key") };
1869
1870 for alias in [
1871 "modelstudio-token-plan",
1872 "modelstudio-token-plan-anthropic",
1873 "modelstudio-coding-plan",
1874 "modelstudio-coding-plan-anthropic",
1875 "modelstudio",
1876 "dashscope",
1877 "alibaba-token-plan",
1878 "alibaba-coding-plan",
1879 ] {
1880 assert_eq!(env_for(alias).as_deref(), Some("ms-key"), "{alias}");
1881 }
1882
1883 clear_known_envs();
1884 unsafe { std::env::set_var("DASHSCOPE_API_KEY", "dashscope-key") };
1885 assert_eq!(
1886 env_for("modelstudio-token-plan").as_deref(),
1887 Some("dashscope-key"),
1888 "DASHSCOPE_API_KEY is the fallback for the same account"
1889 );
1890
1891 clear_known_envs();
1892 }
1893
1894 #[test]
1895 fn opencode_zen_env_aliases_resolve() {
1896 let _guard = env_lock();
1897 clear_known_envs();
1898 unsafe { std::env::set_var("OPENCODE_ZEN_API_KEY", "zen-key") };
1899
1900 for alias in [
1901 "opencode-zen",
1902 "opencode_zen",
1903 "opencodezen",
1904 "zen",
1905 "opencode",
1906 ] {
1907 assert_eq!(env_for(alias).as_deref(), Some("zen-key"), "{alias}");
1908 }
1909
1910 clear_known_envs();
1911 }
1912
1913 #[test]
1914 fn meta_model_api_env_aliases_resolve() {
1915 let _guard = env_lock();
1916 clear_known_envs();
1917 unsafe { std::env::set_var("MODEL_API_KEY", "meta-key") };
1918
1919 for alias in [
1920 "meta",
1921 "meta-ai",
1922 "meta_ai",
1923 "meta-model-api",
1924 "meta_model_api",
1925 "muse",
1926 "muse-spark",
1927 ] {
1928 assert_eq!(env_for(alias).as_deref(), Some("meta-key"), "{alias}");
1929 }
1930
1931 clear_known_envs();
1932 unsafe { std::env::set_var("META_MODEL_API_KEY", "meta-prefixed-key") };
1933 assert_eq!(env_for("meta").as_deref(), Some("meta-prefixed-key"),);
1934
1935 clear_known_envs();
1936 }
1937
1938 #[test]
1939 fn xiaomi_mimo_env_aliases_resolve() {
1940 let _guard = env_lock();
1941 clear_known_envs();
1942 unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
1943
1944 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
1945 assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
1946 assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
1947 assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
1948
1949 clear_known_envs();
1950
1951 unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
1952 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
1953 clear_known_envs();
1954 }
1955
1956 #[test]
1957 fn fireworks_env_aliases_resolve() {
1958 let _lock = env_lock();
1959 clear_known_envs();
1960 // Safety: env mutation guarded by env_lock().
1961 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
1962
1963 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
1964 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
1965 // Safety: env mutation guarded by env_lock().
1966 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
1967 }
1968
1969 #[test]
1970 fn together_env_aliases_resolve() {
1971 let _lock = env_lock();
1972 clear_known_envs();
1973 // Safety: env mutation guarded by env_lock().
1974 unsafe { std::env::set_var("TOGETHER_API_KEY", "together-key") };
1975
1976 // Canonical id plus the legacy hyphen/underscore spellings AND the
1977 // separator-free `togetherai` id Models.dev publishes must all resolve.
1978 assert_eq!(env_for("together").as_deref(), Some("together-key"));
1979 assert_eq!(env_for("together-ai").as_deref(), Some("together-key"));
1980 assert_eq!(env_for("together_ai").as_deref(), Some("together-key"));
1981 assert_eq!(env_for("togetherai").as_deref(), Some("together-key"));
1982 // Safety: env mutation guarded by env_lock().
1983 unsafe { std::env::remove_var("TOGETHER_API_KEY") };
1984 }
1985
1986 #[test]
1987 fn deepinfra_env_aliases_resolve() {
1988 let _lock = env_lock();
1989 clear_known_envs();
1990 // Safety: env mutation guarded by env_lock().
1991 unsafe { std::env::set_var("DEEPINFRA_API_KEY", "di-key") };
1992
1993 assert_eq!(env_for("deepinfra").as_deref(), Some("di-key"));
1994 assert_eq!(env_for("deep-infra").as_deref(), Some("di-key"));
1995 assert_eq!(env_for("deep_infra").as_deref(), Some("di-key"));
1996 // Safety: env mutation guarded by env_lock().
1997 unsafe { std::env::remove_var("DEEPINFRA_API_KEY") };
1998
1999 // The DEEPINFRA_TOKEN fallback is honored when the primary key is unset.
2000 // Safety: env mutation guarded by env_lock().
2001 unsafe { std::env::set_var("DEEPINFRA_TOKEN", "di-token") };
2002 assert_eq!(env_for("deepinfra").as_deref(), Some("di-token"));
2003 // Safety: env mutation guarded by env_lock().
2004 unsafe { std::env::remove_var("DEEPINFRA_TOKEN") };
2005 }
2006
2007 #[test]
2008 fn novita_env_aliases_resolve() {
2009 let _lock = env_lock();
2010 clear_known_envs();
2011 // Safety: env mutation guarded by env_lock().
2012 unsafe { std::env::set_var("NOVITA_API_KEY", "novita-key") };
2013
2014 assert_eq!(env_for("novita").as_deref(), Some("novita-key"));
2015 // `novita-ai` is the Models.dev provider id (Refs #4186).
2016 assert_eq!(env_for("novita-ai").as_deref(), Some("novita-key"));
2017 assert_eq!(env_for("novita_ai").as_deref(), Some("novita-key"));
2018 // Safety: env mutation guarded by env_lock().
2019 unsafe { std::env::remove_var("NOVITA_API_KEY") };
2020 }
2021
2022 #[test]
2023 fn siliconflow_env_aliases_resolve() {
2024 let _lock = env_lock();
2025 clear_known_envs();
2026 // Safety: env mutation guarded by env_lock().
2027 unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
2028
2029 assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
2030 assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
2031 assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
2032 assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
2033 assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
2034 // Safety: env mutation guarded by env_lock().
2035 unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
2036 }
2037
2038 #[test]
2039 fn arcee_env_aliases_resolve() {
2040 let _lock = env_lock();
2041 clear_known_envs();
2042 // Safety: env mutation guarded by env_lock().
2043 unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
2044
2045 assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
2046 assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
2047 assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
2048 // Safety: env mutation guarded by env_lock().
2049 unsafe { std::env::remove_var("ARCEE_API_KEY") };
2050 }
2051
2052 #[test]
2053 fn moonshot_kimi_env_aliases_resolve() {
2054 let _lock = env_lock();
2055 clear_known_envs();
2056 // Safety: env mutation guarded by env_lock().
2057 unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
2058
2059 assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
2060 assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
2061 assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
2062 assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
2063 // Safety: env mutation guarded by env_lock().
2064 unsafe { std::env::remove_var("KIMI_API_KEY") };
2065 }
2066
2067 #[test]
2068 fn sglang_env_aliases_resolve() {
2069 let _lock = env_lock();
2070 clear_known_envs();
2071 // Safety: env mutation guarded by env_lock().
2072 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
2073
2074 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
2075 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
2076 // Safety: env mutation guarded by env_lock().
2077 unsafe { std::env::remove_var("SGLANG_API_KEY") };
2078 }
2079
2080 #[test]
2081 fn vllm_env_aliases_resolve() {
2082 let _lock = env_lock();
2083 clear_known_envs();
2084 // Safety: env mutation guarded by env_lock().
2085 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
2086
2087 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
2088 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
2089 // Safety: env mutation guarded by env_lock().
2090 unsafe { std::env::remove_var("VLLM_API_KEY") };
2091 }
2092
2093 #[test]
2094 fn ollama_env_aliases_resolve() {
2095 let _lock = env_lock();
2096 clear_known_envs();
2097 // Safety: env mutation guarded by env_lock().
2098 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
2099
2100 assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
2101 assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
2102 // Safety: env mutation guarded by env_lock().
2103 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
2104 }
2105
2106 #[cfg(unix)]
2107 #[test]
2108 fn file_store_round_trips_with_secure_perms() {
2109 use std::os::unix::fs::PermissionsExt;
2110
2111 let tmp = tempfile::tempdir().unwrap();
2112 let path = tmp.path().join("nested").join("secrets.json");
2113 let store = FileKeyringStore::new(path.clone());
2114 assert_eq!(store.get("deepseek").unwrap(), None);
2115 store.set("deepseek", "sk-disk").unwrap();
2116 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
2117
2118 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2119 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
2120
2121 store.set("openrouter", "or-disk").unwrap();
2122 assert_eq!(
2123 store.get("openrouter").unwrap(),
2124 Some("or-disk".to_string())
2125 );
2126 // First entry must still be intact.
2127 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
2128
2129 store.delete("deepseek").unwrap();
2130 assert_eq!(store.get("deepseek").unwrap(), None);
2131 }
2132
2133 #[cfg(unix)]
2134 #[test]
2135 fn file_store_rejects_world_readable_file() {
2136 use std::os::unix::fs::PermissionsExt;
2137 let tmp = tempfile::tempdir().unwrap();
2138 let path = tmp.path().join("secrets.json");
2139 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
2140 let mut perms = fs::metadata(&path).unwrap().permissions();
2141 perms.set_mode(0o644);
2142 fs::set_permissions(&path, perms).unwrap();
2143
2144 let store = FileKeyringStore::new(path);
2145 let err = store.get("deepseek").unwrap_err();
2146 assert!(
2147 matches!(err, SecretsError::InsecurePermissions { .. }),
2148 "unexpected error: {err}"
2149 );
2150 }
2151
2152 // Regression for #281: `set` and `delete` used to call
2153 // `load_unlocked().unwrap_or_default()`, which silently wiped every
2154 // existing secret whenever the read failed (insecure permissions,
2155 // corrupt JSON, or any other I/O error).
2156
2157 #[cfg(unix)]
2158 #[test]
2159 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
2160 use std::os::unix::fs::PermissionsExt;
2161 let tmp = tempfile::tempdir().unwrap();
2162 let path = tmp.path().join("secrets.json");
2163 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
2164 fs::write(&path, original).unwrap();
2165 let mut perms = fs::metadata(&path).unwrap().permissions();
2166 perms.set_mode(0o644);
2167 fs::set_permissions(&path, perms).unwrap();
2168
2169 let store = FileKeyringStore::new(path.clone());
2170 let err = store.set("openrouter", "or-new").unwrap_err();
2171 assert!(
2172 matches!(err, SecretsError::InsecurePermissions { .. }),
2173 "set must surface the read error rather than overwriting; got: {err}"
2174 );
2175
2176 let on_disk = fs::read_to_string(&path).unwrap();
2177 assert_eq!(
2178 on_disk, original,
2179 "set must not modify the file when load_unlocked errored"
2180 );
2181 }
2182
2183 #[cfg(unix)]
2184 #[test]
2185 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
2186 use std::os::unix::fs::PermissionsExt;
2187 let tmp = tempfile::tempdir().unwrap();
2188 let path = tmp.path().join("secrets.json");
2189 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
2190 fs::write(&path, original).unwrap();
2191 let mut perms = fs::metadata(&path).unwrap().permissions();
2192 perms.set_mode(0o644);
2193 fs::set_permissions(&path, perms).unwrap();
2194
2195 let store = FileKeyringStore::new(path.clone());
2196 let err = store.delete("nvidia").unwrap_err();
2197 assert!(
2198 matches!(err, SecretsError::InsecurePermissions { .. }),
2199 "delete must surface the read error rather than wiping the file; got: {err}"
2200 );
2201 let on_disk = fs::read_to_string(&path).unwrap();
2202 assert_eq!(on_disk, original);
2203 }
2204
2205 #[test]
2206 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
2207 let tmp = tempfile::tempdir().unwrap();
2208 let path = tmp.path().join("secrets.json");
2209 // Corrupt JSON. Permissions ok where unix; on Windows the perm-check
2210 // doesn't run so we exercise the json-error path directly.
2211 fs::write(&path, "{ this is not valid json").unwrap();
2212 #[cfg(unix)]
2213 {
2214 use std::os::unix::fs::PermissionsExt;
2215 let mut perms = fs::metadata(&path).unwrap().permissions();
2216 perms.set_mode(0o600);
2217 fs::set_permissions(&path, perms).unwrap();
2218 }
2219
2220 let store = FileKeyringStore::new(path.clone());
2221 let err = store.set("deepseek", "sk-new").unwrap_err();
2222 assert!(
2223 matches!(err, SecretsError::Json(_)),
2224 "set must surface the parse error rather than wiping the file; got: {err}"
2225 );
2226 let on_disk = fs::read_to_string(&path).unwrap();
2227 assert_eq!(on_disk, "{ this is not valid json");
2228 }
2229
2230 #[test]
2231 fn file_store_set_still_creates_file_when_missing() {
2232 // Regression guard: the #281 fix removed `unwrap_or_default()` from
2233 // the load call. Make sure the original first-write-creates-the-file
2234 // ergonomic still works — `load_unlocked` returns `Ok(default)` for
2235 // a missing file, so the `?` should pass through cleanly.
2236 let tmp = tempfile::tempdir().unwrap();
2237 let path = tmp.path().join("nested").join("secrets.json");
2238 let store = FileKeyringStore::new(path.clone());
2239
2240 store.set("deepseek", "sk-fresh").unwrap();
2241 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
2242 }
2243
2244 #[test]
2245 fn file_store_default_path_uses_home() {
2246 let _lock = env_lock();
2247 clear_known_envs();
2248 let tmp = tempfile::tempdir().unwrap();
2249 let _home = EnvVarGuard::set("HOME", tmp.path());
2250 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2251
2252 let path = FileKeyringStore::default_path().unwrap();
2253 assert_eq!(
2254 path,
2255 tmp.path()
2256 .join(".codewhale")
2257 .join("secrets")
2258 .join("secrets.json")
2259 );
2260 }
2261
2262 #[test]
2263 fn default_path_with_explicit_codewhale_home_does_not_migrate_ambient_legacy() {
2264 // FR003-C001: explicit CODEWHALE_HOME must not silently import ambient
2265 // `$HOME/.deepseek/secrets` credentials into the isolated home.
2266 let _lock = env_lock();
2267 clear_known_envs();
2268 let tmp = tempfile::tempdir().unwrap();
2269 let codewhale_home = tmp.path().join("isolated-codewhale-home");
2270 let _home = EnvVarGuard::set("HOME", tmp.path());
2271 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2272 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
2273 let legacy = tmp
2274 .path()
2275 .join(".deepseek")
2276 .join("secrets")
2277 .join("secrets.json");
2278 FileKeyringStore::new(&legacy)
2279 .set("deepseek", "synthetic-ambient-legacy-value")
2280 .unwrap();
2281
2282 let path = FileKeyringStore::default_path().unwrap();
2283 assert_eq!(path, codewhale_home.join("secrets").join("secrets.json"));
2284 assert!(
2285 !path.exists(),
2286 "explicit CODEWHALE_HOME must not create/migrate a primary store from ambient legacy"
2287 );
2288
2289 let secrets = Secrets::auto_detect();
2290 assert_eq!(
2291 secrets.get("deepseek").unwrap(),
2292 None,
2293 "explicit CODEWHALE_HOME must not surface ambient legacy credentials"
2294 );
2295 }
2296
2297 #[test]
2298 fn file_backed_default_refuses_relative_secret_path() {
2299 // FR003-C002: a relative fallback would resolve against the workspace
2300 // and risk committing credentials. It must be write-refusing instead.
2301 let secrets =
2302 Secrets::file_backed_from_default_path(Ok(PathBuf::from(".codewhale-secrets.json")));
2303 assert!(matches!(
2304 secrets.set("deepseek", "must-not-land-relative"),
2305 Err(SecretsError::ReadOnly)
2306 ));
2307 assert_eq!(
2308 secrets.get("deepseek").unwrap(),
2309 None,
2310 "unsafe relative fallback must not read a workspace secret file"
2311 );
2312 }
2313
2314 #[test]
2315 fn file_backed_default_refuses_writes_when_home_resolution_fails() {
2316 // Force the exact fallback branch instead of relying on the shared
2317 // platform-home resolver, which normally succeeds with HOME unset.
2318 let err = SecretsError::Io(std::io::Error::new(
2319 std::io::ErrorKind::NotFound,
2320 "synthetic unresolved home",
2321 ));
2322 let secrets = Secrets::file_backed_from_default_path(Err(err));
2323 assert!(matches!(
2324 secrets.set("deepseek", "must-not-persist"),
2325 Err(SecretsError::ReadOnly)
2326 ));
2327 assert_eq!(secrets.get("deepseek").unwrap(), None);
2328 }
2329
2330 #[path = "diagnostic_tests.rs"]
2331 mod diagnostic_tests;
2332 }
2333
2333 lines RUST