返回 CodeWhale
lib.rs
根目录 / crates / secrets / src / lib.rs
1 //! Secret storage for CodeWhale API keys, plus the shared output-sanitization
2 //! primitives that keep secrets out of diagnostics and command output.
3 //!
4 //! Secret storage: provides a small abstraction (`KeyringStore`) plus a default
5 //! file-based implementation (`FileKeyringStore`), an opt-in OS keyring
6 //! implementation (`DefaultKeyringStore`), and an in-memory store for tests
7 //! (`InMemoryKeyringStore`).
8 //!
9 //! Higher-level lookup through [`Secrets::resolve`] checks the secret store first
10 //! and falls back to environment variables. Config-file precedence lives in the
11 //! config crate so user-facing commands can keep `config -> secret store -> env`
12 //! explicit at the call site.
13 //!
14 //! Sanitization: [`redact`] and [`sanitize`] are pure and carry no host types.
15 //! They live here (FEAT-025 D4) because this is the lowest crate that both the
16 //! config diagnostics path and the TUI already reach, so `/export`,
17 //! `/structcopy`, client URL masking, and OSC8 stripping share exactly one
18 //! implementation instead of drifting copies. `config::persistence` and
19 //! `tui::client` / `tui::osc8` re-export or delegate to these functions.
20 //!
21 //! Note for the command extraction (EPIC-006): this crate is already reachable
22 //! from `codewhale-command-contract` transitively via
23 //! `core -> config -> secrets`, so consuming the sanitizer from the future
24 //! `codewhale-commands` crate adds no new dependency edge. It does mean the
25 //! sanitizer inherits this crate's OS keyring dependencies; if the surface grows
26 //! beyond redaction, split a dedicated `codewhale-sanitize` crate rather than
27 //! widening this one.
28 #![deny(missing_docs)]
29
30 /// Shared secure-storage contract for the Codewhale account session.
31 pub mod account;
32 mod file_lock;
33 #[cfg(test)]
34 mod file_transactions_tests;
35 /// Pure secret-redaction primitives shared by config diagnostics and the
36 /// portable command sanitizer (FEAT-025 D4).
37 pub mod redact;
38 /// Pure text/URL/ANSI output sanitization shared by the portable command
39 /// helpers (FEAT-025 D4).
40 pub mod sanitize;
41
42 use std::collections::HashMap;
43 use std::fs;
44 use std::path::{Path, PathBuf};
45 use std::sync::{Arc, Mutex};
46
47 use codewhale_paths::codewhale_home_is_explicit;
48 use serde::{Deserialize, Serialize};
49 use thiserror::Error;
50
51 /// Default OS keychain service name. Kept as `deepseek` for compatibility
52 /// with credentials saved before the CodeWhale rename. macOS users can verify
53 /// entries with `security find-generic-password -s deepseek -a <provider>`.
54 pub const DEFAULT_SERVICE: &str = "deepseek";
55 /// Secret-store slot consumed by Daytona cloud dispatch (`codewhale dispatch`).
56 ///
57 /// Login writes here; dispatch looks this slot up after `DAYTONA_API_KEY` and
58 /// `CWC_DAYTONA_TOKEN`. Do not invent a second Daytona credential name.
59 pub const DAYTONA_TOKEN_SLOT: &str = "daytona";
60 /// First-class Daytona process env that dispatch also accepts.
61 pub const DAYTONA_API_KEY_ENV: &str = "DAYTONA_API_KEY";
62 /// CWC alias that Daytona dispatch also accepts.
63 pub const CWC_DAYTONA_TOKEN_ENV: &str = "CWC_DAYTONA_TOKEN";
64 /// Select the secret storage backend. Supported values are `file` (default)
65 /// and `system`/`keyring` for the OS credential store.
66 pub const SECRET_BACKEND_ENV: &str = "CODEWHALE_SECRET_BACKEND";
67 /// Legacy alias for [`SECRET_BACKEND_ENV`].
68 pub const LEGACY_SECRET_BACKEND_ENV: &str = "DEEPSEEK_SECRET_BACKEND";
69 const FILE_BACKEND_LABEL: &str = "file-based (~/.codewhale/secrets/)";
70
71 /// Errors that may arise from a [`KeyringStore`] backend.
72 #[derive(Debug, Error)]
73 pub enum SecretsError {
74 /// Underlying OS keyring backend reported an error.
75 #[error("keyring backend error: {0}")]
76 Keyring(String),
77 /// File-backed fallback I/O error.
78 #[error("file-backed secret store I/O error: {0}")]
79 Io(#[from] std::io::Error),
80 /// File-backed fallback JSON (de)serialisation error.
81 #[error("file-backed secret store JSON error: {0}")]
82 Json(#[from] serde_json::Error),
83 /// Caught when a stored secret on disk has unsafe permissions.
84 #[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
85 InsecurePermissions {
86 /// Absolute path to the secrets file.
87 path: PathBuf,
88 /// Observed unix permission mode.
89 mode: u32,
90 },
91 /// A caller attempted to modify a diagnostic-only secret store.
92 #[error("secret store is read-only")]
93 ReadOnly,
94 }
95
96 /// Abstract secret store trait.
97 ///
98 /// Concrete implementations may use the OS keyring ([`DefaultKeyringStore`]),
99 /// a JSON file under `~/.codewhale/secrets/` ([`FileKeyringStore`]), or an
100 /// in-memory map for tests ([`InMemoryKeyringStore`]).
101 ///
102 /// All implementations must be [`Send`] + [`Sync`] so they can be shared
103 /// across threads via [`Arc`].
104 pub trait KeyringStore: Send + Sync {
105 /// Read a secret by key.
106 ///
107 /// Returns `Ok(None)` if no entry exists for the given key. Returns
108 /// `Err` only on backend failures (I/O errors, keyring access issues).
109 fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
110
111 /// Write a secret, replacing any existing value for the same key.
112 ///
113 /// Creates the backing store (e.g. the JSON file) on first write if
114 /// it does not yet exist.
115 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
116
117 /// Remove a secret by key.
118 ///
119 /// Implementations should succeed (no-op) if the entry is already absent
120 /// rather than returning an error.
121 fn delete(&self, key: &str) -> Result<(), SecretsError>;
122
123 /// Run a non-reentrant entry mutation while holding the backend's authority
124 /// lock. Errors must leave the stored entry unchanged.
125 fn with_entry_transaction(
126 &self,
127 _key: &str,
128 _operation: &mut dyn FnMut(&mut Option<String>) -> Result<(), SecretsError>,
129 ) -> Result<(), SecretsError> {
130 Err(SecretsError::Keyring(
131 "This secret backend does not support atomic updates".into(),
132 ))
133 }
134
135 /// Replace an entry only while its exact bytes still match a snapshot.
136 fn compare_exchange(
137 &self,
138 key: &str,
139 expected: Option<&str>,
140 replacement: Option<&str>,
141 ) -> Result<bool, SecretsError> {
142 let mut changed = false;
143 self.with_entry_transaction(key, &mut |current| {
144 if current.as_deref() == expected {
145 *current = replacement.map(str::to_owned);
146 changed = true;
147 }
148 Ok(())
149 })?;
150 Ok(changed)
151 }
152
153 /// Short, human-readable label for this backend.
154 ///
155 /// Used by diagnostic output (e.g. `doctor` command) to indicate which
156 /// storage backend is active. Examples: `"file-based (~/.codewhale/secrets/)"`,
157 /// `"system keyring"`, `"in-memory (test)"`.
158 fn backend_name(&self) -> &'static str;
159 }
160
161 /// OS-native keyring backend.
162 ///
163 /// Wraps the platform credential store:
164 /// - **macOS**: Keychain (via `security` framework)
165 /// - **Windows**: Credential Manager
166 /// - **Linux**: Secret Service (GNOME Keyring / kwallet via dbus), excluding OHOS
167 ///
168 /// This backend is opt-in -- set the [`SECRET_BACKEND_ENV`] environment
169 /// variable to `system` or `keyring` to activate it. On platforms without
170 /// a configured native keyring dependency, [`probe`](DefaultKeyringStore::probe)
171 /// returns an unsupported error so [`Secrets::auto_detect`] can transparently
172 /// fall back to [`FileKeyringStore`].
173 #[derive(Debug, Clone)]
174 pub struct DefaultKeyringStore {
175 /// Keyring service name used to namespace stored credentials.
176 /// Defaults to [`DEFAULT_SERVICE`].
177 service: String,
178 }
179
180 impl Default for DefaultKeyringStore {
181 fn default() -> Self {
182 Self::new(DEFAULT_SERVICE)
183 }
184 }
185
186 impl DefaultKeyringStore {
187 /// Build a new store with the given service name.
188 #[must_use]
189 pub fn new(service: impl Into<String>) -> Self {
190 Self {
191 service: service.into(),
192 }
193 }
194
195 /// Probe the OS keyring without writing anything. Returns `Ok(())` if
196 /// a backend is reachable, otherwise an error describing why not.
197 ///
198 /// The probe reads a deliberately-nonexistent entry: reaching the
199 /// backend and learning the entry is absent *is* the reachability
200 /// signal. This does not prompt on any supported platform — macOS only
201 /// surfaces Keychain UI when accessing an *existing* item owned by
202 /// another application, and Windows Credential Manager never prompts
203 /// for a missing target — so `__probe__` under our own service name is
204 /// safe to read. `Entry::new` alone validates only argument shapes,
205 /// which left this probe a no-op on macOS/Windows and the documented
206 /// file-store fallback unreachable there (#5172).
207 pub fn probe(&self) -> Result<(), SecretsError> {
208 #[cfg(any(
209 target_os = "macos",
210 target_os = "windows",
211 all(
212 target_os = "linux",
213 not(target_env = "ohos"),
214 not(target_env = "musl")
215 )
216 ))]
217 {
218 let entry = keyring::Entry::new(&self.service, "__probe__")
219 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
220 match entry.get_password() {
221 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
222 Err(keyring::Error::PlatformFailure(err)) => {
223 Err(SecretsError::Keyring(format!("platform failure: {err}")))
224 }
225 Err(keyring::Error::NoStorageAccess(err)) => {
226 Err(SecretsError::Keyring(format!("no storage access: {err}")))
227 }
228 Err(other) => Err(SecretsError::Keyring(other.to_string())),
229 }
230 }
231 #[cfg(not(any(
232 target_os = "macos",
233 target_os = "windows",
234 all(
235 target_os = "linux",
236 not(target_env = "ohos"),
237 not(target_env = "musl")
238 )
239 )))]
240 {
241 let _ = &self.service;
242 Err(SecretsError::Keyring(unsupported_keyring_message()))
243 }
244 }
245 }
246
247 impl DefaultKeyringStore {
248 fn get_unlocked(&self, key: &str) -> Result<Option<String>, SecretsError> {
249 #[cfg(any(
250 target_os = "macos",
251 target_os = "windows",
252 all(
253 target_os = "linux",
254 not(target_env = "ohos"),
255 not(target_env = "musl")
256 )
257 ))]
258 {
259 let entry = keyring::Entry::new(&self.service, key)
260 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
261 match entry.get_password() {
262 Ok(value) => Ok(Some(value)),
263 Err(keyring::Error::NoEntry) => Ok(None),
264 Err(err) => Err(SecretsError::Keyring(err.to_string())),
265 }
266 }
267 #[cfg(not(any(
268 target_os = "macos",
269 target_os = "windows",
270 all(
271 target_os = "linux",
272 not(target_env = "ohos"),
273 not(target_env = "musl")
274 )
275 )))]
276 {
277 let _ = key;
278 Err(SecretsError::Keyring(unsupported_keyring_message()))
279 }
280 }
281
282 fn set_unlocked(&self, key: &str, value: &str) -> Result<(), SecretsError> {
283 #[cfg(any(
284 target_os = "macos",
285 target_os = "windows",
286 all(
287 target_os = "linux",
288 not(target_env = "ohos"),
289 not(target_env = "musl")
290 )
291 ))]
292 {
293 let entry = keyring::Entry::new(&self.service, key)
294 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
295 entry
296 .set_password(value)
297 .map_err(|err| SecretsError::Keyring(err.to_string()))
298 }
299 #[cfg(not(any(
300 target_os = "macos",
301 target_os = "windows",
302 all(
303 target_os = "linux",
304 not(target_env = "ohos"),
305 not(target_env = "musl")
306 )
307 )))]
308 {
309 let _ = (key, value);
310 Err(SecretsError::Keyring(unsupported_keyring_message()))
311 }
312 }
313
314 fn delete_unlocked(&self, key: &str) -> Result<(), SecretsError> {
315 #[cfg(any(
316 target_os = "macos",
317 target_os = "windows",
318 all(
319 target_os = "linux",
320 not(target_env = "ohos"),
321 not(target_env = "musl")
322 )
323 ))]
324 {
325 let entry = keyring::Entry::new(&self.service, key)
326 .map_err(|err| SecretsError::Keyring(err.to_string()))?;
327 match entry.delete_credential() {
328 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
329 Err(err) => Err(SecretsError::Keyring(err.to_string())),
330 }
331 }
332 #[cfg(not(any(
333 target_os = "macos",
334 target_os = "windows",
335 all(
336 target_os = "linux",
337 not(target_env = "ohos"),
338 not(target_env = "musl")
339 )
340 )))]
341 {
342 let _ = key;
343 Err(SecretsError::Keyring(unsupported_keyring_message()))
344 }
345 }
346 }
347
348 impl DefaultKeyringStore {
349 fn with_key_lock<T>(
350 &self,
351 key: &str,
352 operation: impl FnOnce() -> Result<T, SecretsError>,
353 ) -> Result<T, SecretsError> {
354 use sha2::{Digest, Sha256};
355 // OS keyring authority is per user, not per CODEWHALE_HOME/profile.
356 let home = codewhale_paths::user_home()
357 .filter(|p| p.is_absolute())
358 .ok_or_else(home_resolution_error)?;
359 let mut digest = Sha256::new();
360 digest.update(self.service.as_bytes());
361 digest.update([0]);
362 digest.update(key.as_bytes());
363 let path = home.join(".codewhale").join("keyring-locks").join(
364 digest
365 .finalize()
366 .iter()
367 .map(|byte| format!("{byte:02x}"))
368 .collect::<String>(),
369 );
370 file_lock::with_write_lock(&path, |_| operation())
371 }
372 }
373
374 impl KeyringStore for DefaultKeyringStore {
375 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
376 self.get_unlocked(key)
377 }
378 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
379 self.with_key_lock(key, || self.set_unlocked(key, value))
380 }
381 fn delete(&self, key: &str) -> Result<(), SecretsError> {
382 self.with_key_lock(key, || self.delete_unlocked(key))
383 }
384 fn with_entry_transaction(
385 &self,
386 key: &str,
387 operation: &mut dyn FnMut(&mut Option<String>) -> Result<(), SecretsError>,
388 ) -> Result<(), SecretsError> {
389 self.with_key_lock(key, || {
390 let before = self.get_unlocked(key)?;
391 let mut current = before.clone();
392 operation(&mut current)?;
393 if current != before {
394 match current {
395 Some(value) => self.set_unlocked(key, &value)?,
396 None => self.delete_unlocked(key)?,
397 }
398 }
399 Ok(())
400 })
401 }
402
403 fn backend_name(&self) -> &'static str {
404 "system keyring"
405 }
406 }
407
408 #[cfg(not(any(
409 target_os = "macos",
410 target_os = "windows",
411 all(
412 target_os = "linux",
413 not(target_env = "ohos"),
414 not(target_env = "musl")
415 )
416 )))]
417 fn unsupported_keyring_message() -> String {
418 "system keyring backend is unsupported on this platform".to_string()
419 }
420
421 /// In-memory keyring store for tests.
422 ///
423 /// Stores secrets in a [`HashMap`] protected by a [`Mutex`]. Not persisted
424 /// to disk -- all entries are lost when the process exits. This is the
425 /// preferred store for unit tests because it requires no filesystem setup
426 /// and is safe to use in parallel test threads.
427 #[derive(Debug, Default)]
428 pub struct InMemoryKeyringStore {
429 /// Thread-safe map of key-value pairs.
430 entries: Mutex<HashMap<String, String>>,
431 }
432
433 impl InMemoryKeyringStore {
434 /// Create an empty store.
435 #[must_use]
436 pub fn new() -> Self {
437 Self::default()
438 }
439 }
440
441 impl KeyringStore for InMemoryKeyringStore {
442 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
443 let guard = self.entries.lock().map_err(|e| {
444 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
445 })?;
446 Ok(guard.get(key).cloned())
447 }
448
449 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
450 let mut guard = self.entries.lock().map_err(|e| {
451 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
452 })?;
453 guard.insert(key.to_string(), value.to_string());
454 Ok(())
455 }
456
457 fn delete(&self, key: &str) -> Result<(), SecretsError> {
458 let mut guard = self.entries.lock().map_err(|e| {
459 SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
460 })?;
461 guard.remove(key);
462 Ok(())
463 }
464
465 fn with_entry_transaction(
466 &self,
467 key: &str,
468 operation: &mut dyn FnMut(&mut Option<String>) -> Result<(), SecretsError>,
469 ) -> Result<(), SecretsError> {
470 let mut entries = self
471 .entries
472 .lock()
473 .map_err(|_| SecretsError::Keyring("Secret store lock poisoned".into()))?;
474 let mut current = entries.get(key).cloned();
475 operation(&mut current)?;
476 match current {
477 Some(value) => {
478 entries.insert(key.into(), value);
479 }
480 None => {
481 entries.remove(key);
482 }
483 }
484 Ok(())
485 }
486
487 fn backend_name(&self) -> &'static str {
488 "in-memory (test)"
489 }
490 }
491
492 /// JSON-on-disk secret store for headless environments.
493 ///
494 /// This is the default backend. Secrets are serialised as a JSON object
495 /// at `<home>/.codewhale/secrets/secrets.json` with Unix file mode `0600`
496 /// (owner read/write only). The parent directory is created with mode `0700`
497 /// if it does not exist.
498 ///
499 /// On Unix, the store rejects files whose permissions are more permissive
500 /// than `0600` (i.e. group or world bits are set). This prevents other
501 /// users on the system from reading stored credentials. On Windows, the
502 /// ACL model is too different to enforce programmatically; callers are
503 /// responsible for placing the file in a per-user directory.
504 #[derive(Debug, Clone)]
505 pub struct FileKeyringStore {
506 /// Absolute path to the JSON secrets file.
507 path: PathBuf,
508 }
509
510 /// File-backed secret lookup that never migrates or changes either store.
511 ///
512 /// Normal runtime credential resolution keeps its additive legacy migration:
513 /// older entries under `~/.deepseek/secrets/` are copied into the Codewhale
514 /// location before use. Diagnostic commands need the same read precedence
515 /// without creating that destination, so this store reads the primary file
516 /// first and falls back to the legacy file only when the primary has no entry
517 /// and the Codewhale home is not explicitly isolated.
518 #[derive(Debug, Clone)]
519 struct ReadOnlyFileKeyringStore {
520 primary: FileKeyringStore,
521 /// The ambient legacy store is unavailable when `CODEWHALE_HOME` is an
522 /// explicit isolation boundary.
523 legacy: Option<FileKeyringStore>,
524 }
525
526 #[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
527 struct FileSecretsBlob {
528 #[serde(default)]
529 entries: HashMap<String, String>,
530 #[serde(flatten)]
531 extra: serde_json::Map<String, serde_json::Value>,
532 }
533
534 impl FileKeyringStore {
535 /// Build a store backed by the given JSON file path.
536 #[must_use]
537 pub fn new(path: impl Into<PathBuf>) -> Self {
538 Self { path: path.into() }
539 }
540
541 /// Default path: `<home>/.codewhale/secrets/secrets.json`. Honours
542 /// `CODEWHALE_HOME`, then `HOME`, `USERPROFILE`, and finally the platform
543 /// home directory from the `dirs` crate. On first use, non-conflicting
544 /// entries from the legacy `<home>/.deepseek/secrets/secrets.json` file are
545 /// copied into the CodeWhale store — unless `CODEWHALE_HOME` is explicit,
546 /// in which case ambient `$HOME/.deepseek` credentials are never imported.
547 pub fn default_path() -> Result<PathBuf, SecretsError> {
548 let primary = default_codewhale_secrets_path()?;
549 // Match the diagnostic isolation boundary: an explicit Codewhale home
550 // must not silently pull ambient legacy DeepSeek credentials.
551 if !codewhale_home_is_explicit() {
552 match legacy_deepseek_secrets_path() {
553 Ok(legacy) => {
554 if let Err(err) = Self::migrate_legacy_file_if_needed(&primary, &legacy) {
555 tracing::warn!(
556 "could not migrate legacy secret store from {} to {}: {err}",
557 legacy.display(),
558 primary.display()
559 );
560 }
561 }
562 Err(err) => {
563 tracing::warn!("could not resolve legacy secret store path: {err}");
564 }
565 }
566 }
567 Ok(primary)
568 }
569
570 /// Resolve the primary and legacy secret paths without performing legacy
571 /// migration.
572 ///
573 /// This is intended for diagnostic-only lookup. Runtime and authentication
574 /// flows must keep using [`Self::default_path`] so their existing additive
575 /// migration behavior remains unchanged.
576 pub fn default_paths_read_only() -> Result<(PathBuf, Option<PathBuf>), SecretsError> {
577 let primary = default_codewhale_secrets_path()?;
578 let legacy = (!codewhale_home_is_explicit())
579 .then(legacy_deepseek_secrets_path)
580 .transpose()?;
581 Ok((primary, legacy))
582 }
583
584 fn migrate_legacy_file_if_needed(primary: &Path, legacy: &Path) -> Result<(), SecretsError> {
585 if !legacy.exists() {
586 return Ok(());
587 }
588
589 let legacy_store = Self::new(legacy.to_path_buf());
590 let legacy_blob = legacy_store.load_unlocked()?;
591 if legacy_blob.entries.is_empty() {
592 return Ok(());
593 }
594
595 let primary_store = Self::new(primary.to_path_buf());
596 primary_store.mutate(|primary_blob| {
597 for (key, value) in legacy_blob.entries {
598 primary_blob.entries.entry(key).or_insert(value);
599 }
600 Ok(())
601 })
602 }
603
604 fn mutate<T>(
605 &self,
606 operation: impl FnOnce(&mut FileSecretsBlob) -> Result<T, SecretsError>,
607 ) -> Result<T, SecretsError> {
608 file_lock::with_write_lock(&self.path, |path| {
609 let store = Self::new(path);
610 let mut blob = store.load_unlocked()?;
611 let original = serde_json::to_vec(&blob)?;
612 let result = operation(&mut blob)?;
613 if serde_json::to_vec(&blob)? != original {
614 store.store_unlocked(&blob)?;
615 }
616 Ok(result)
617 })
618 }
619
620 /// Path used for storage.
621 #[must_use]
622 pub fn path(&self) -> &Path {
623 &self.path
624 }
625
626 fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
627 use std::io::Read as _;
628 let mut file = match file_lock::open_private(&self.path, false) {
629 Ok(file) => file,
630 Err(SecretsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
631 return Ok(FileSecretsBlob::default());
632 }
633 Err(error) => return Err(error),
634 };
635 let mut raw = String::new();
636 file.read_to_string(&mut raw)?;
637 if raw.trim().is_empty() {
638 return Ok(FileSecretsBlob::default());
639 }
640 let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
641 Ok(blob)
642 }
643
644 fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
645 if let Some(parent) = self.path.parent() {
646 fs::create_dir_all(parent)?;
647 #[cfg(unix)]
648 {
649 use std::os::unix::fs::PermissionsExt;
650 let mut perms = fs::metadata(parent)?.permissions();
651 perms.set_mode(0o700);
652 let _ = fs::set_permissions(parent, perms);
653 }
654 }
655 let body = serde_json::to_string_pretty(blob)?;
656 write_private_file(&self.path, body.as_bytes())?;
657 #[cfg(unix)]
658 {
659 use std::os::unix::fs::PermissionsExt;
660 // Best-effort 0o600 — matches the parent-dir chmod above which
661 // is also `let _ = ...`. Filesystems that don't support Unix
662 // chmod (Docker bind-mounts of NTFS, network shares — #897)
663 // would otherwise fail the whole save here even though the
664 // blob already wrote successfully. The host's native ACLs
665 // are doing access control in those environments.
666 if let Ok(meta) = fs::metadata(&self.path) {
667 let mut perms = meta.permissions();
668 perms.set_mode(0o600);
669 let _ = fs::set_permissions(&self.path, perms);
670 }
671 }
672 Ok(())
673 }
674 }
675
676 impl ReadOnlyFileKeyringStore {
677 fn default_for_diagnostics() -> Result<Self, SecretsError> {
678 let (primary, legacy) = FileKeyringStore::default_paths_read_only()?;
679 Ok(Self::new(primary, legacy))
680 }
681
682 fn new(primary: impl Into<PathBuf>, legacy: Option<PathBuf>) -> Self {
683 Self {
684 primary: FileKeyringStore::new(primary),
685 legacy: legacy.map(FileKeyringStore::new),
686 }
687 }
688 }
689
690 impl KeyringStore for ReadOnlyFileKeyringStore {
691 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
692 match self.primary.get(key)? {
693 Some(value) => Ok(Some(value)),
694 None => self
695 .legacy
696 .as_ref()
697 .map_or(Ok(None), |legacy| legacy.get(key)),
698 }
699 }
700
701 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
702 Err(SecretsError::ReadOnly)
703 }
704
705 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
706 Err(SecretsError::ReadOnly)
707 }
708
709 fn backend_name(&self) -> &'static str {
710 FILE_BACKEND_LABEL
711 }
712 }
713
714 #[derive(Clone)]
715 struct ReadOnlyKeyringStore {
716 inner: Arc<dyn KeyringStore>,
717 }
718
719 impl ReadOnlyKeyringStore {
720 fn new(inner: Arc<dyn KeyringStore>) -> Self {
721 Self { inner }
722 }
723 }
724
725 impl KeyringStore for ReadOnlyKeyringStore {
726 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
727 self.inner.get(key)
728 }
729
730 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
731 Err(SecretsError::ReadOnly)
732 }
733
734 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
735 Err(SecretsError::ReadOnly)
736 }
737
738 fn backend_name(&self) -> &'static str {
739 self.inner.backend_name()
740 }
741 }
742
743 fn write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
744 atomic_write_private_file(path, body)
745 }
746
747 fn atomic_write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsError> {
748 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
749 fs::create_dir_all(parent)?;
750 }
751 let dir = path
752 .parent()
753 .filter(|p| !p.as_os_str().is_empty())
754 .unwrap_or_else(|| Path::new("."));
755 let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(SecretsError::Io)?;
756 use std::io::Write as _;
757 tmp.write_all(body).map_err(SecretsError::Io)?;
758 tmp.flush().map_err(SecretsError::Io)?;
759 tmp.as_file().sync_all().map_err(SecretsError::Io)?;
760 #[cfg(unix)]
761 {
762 use std::os::unix::fs::PermissionsExt;
763 let perms = fs::Permissions::from_mode(0o600);
764 tmp.as_file()
765 .set_permissions(perms)
766 .map_err(SecretsError::Io)?;
767 }
768 tmp.persist(path).map_err(|e| SecretsError::Io(e.error))?;
769 Ok(())
770 }
771
772 impl KeyringStore for FileKeyringStore {
773 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
774 let blob = self.load_unlocked()?;
775 Ok(blob.entries.get(key).cloned())
776 }
777
778 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
779 self.mutate(|blob| {
780 account::invalidate_device_companion(&mut blob.entries, key, Some(value));
781 blob.entries.insert(key.to_string(), value.to_string());
782 Ok(())
783 })
784 }
785
786 fn delete(&self, key: &str) -> Result<(), SecretsError> {
787 self.mutate(|blob| {
788 account::invalidate_device_companion(&mut blob.entries, key, None);
789 blob.entries.remove(key);
790 Ok(())
791 })
792 }
793
794 fn with_entry_transaction(
795 &self,
796 key: &str,
797 operation: &mut dyn FnMut(&mut Option<String>) -> Result<(), SecretsError>,
798 ) -> Result<(), SecretsError> {
799 self.mutate(|blob| {
800 let before = blob.entries.get(key).cloned();
801 let mut current = before.clone();
802 operation(&mut current)?;
803 if current != before {
804 account::invalidate_device_companion(&mut blob.entries, key, current.as_deref());
805 match current {
806 Some(value) => {
807 blob.entries.insert(key.into(), value);
808 }
809 None => {
810 blob.entries.remove(key);
811 }
812 }
813 }
814 Ok(())
815 })
816 }
817
818 fn backend_name(&self) -> &'static str {
819 FILE_BACKEND_LABEL
820 }
821 }
822
823 fn default_codewhale_secrets_path() -> Result<PathBuf, SecretsError> {
824 Ok(codewhale_paths::codewhale_home()
825 .map_err(|error| {
826 SecretsError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, error))
827 })?
828 .ok_or_else(home_resolution_error)?
829 .join("secrets")
830 .join("secrets.json"))
831 }
832
833 fn legacy_deepseek_secrets_path() -> Result<PathBuf, SecretsError> {
834 Ok(codewhale_paths::legacy_deepseek_home()
835 .ok_or_else(home_resolution_error)?
836 .join("secrets")
837 .join("secrets.json"))
838 }
839
840 fn home_resolution_error() -> SecretsError {
841 SecretsError::Io(std::io::Error::new(
842 std::io::ErrorKind::NotFound,
843 "could not resolve home directory for FileKeyringStore",
844 ))
845 }
846
847 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
848 enum SecretBackendSelection {
849 File,
850 System,
851 Unknown,
852 }
853
854 /// Secret-store backend selected by configuration for a structural diagnostic.
855 ///
856 /// This type deliberately describes only configuration and filesystem shape.
857 /// It never implies that a provider credential exists.
858 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
859 #[serde(rename_all = "snake_case")]
860 pub enum SecretBackendDiagnosticKind {
861 /// The JSON file store is selected.
862 File,
863 /// The operating-system credential store is selected.
864 System,
865 /// The configured backend value is unsupported.
866 Unknown,
867 }
868
869 /// Whether a secret-store path is present according to metadata only.
870 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
871 #[serde(rename_all = "snake_case")]
872 pub enum SecretBackendPresence {
873 /// A regular file exists at the resolved path.
874 Present,
875 /// No filesystem entry exists at the resolved path.
876 Absent,
877 /// Presence is unavailable or the entry is not a regular file.
878 Unknown,
879 }
880
881 /// Scope of inspection performed for a structural secret-backend diagnostic.
882 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
883 #[serde(rename_all = "snake_case")]
884 pub enum SecretBackendInspection {
885 /// Only filesystem metadata was inspected; file contents were not opened.
886 MetadataOnly,
887 /// The backend was not constructed, probed, or read.
888 NotProbed,
889 }
890
891 /// Secret-safe structural description of the configured credential backend.
892 ///
893 /// File-backed diagnostics expose resolved paths and regular-file presence from
894 /// metadata without opening either store. System backends intentionally report
895 /// `unknown` / `not_probed`: constructing or probing an OS keyring can show a
896 /// user prompt even when no credential value is requested.
897 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
898 pub struct SecretBackendDiagnostic {
899 /// Configured backend family.
900 pub backend: SecretBackendDiagnosticKind,
901 /// Inspection performed to produce this report.
902 pub inspection: SecretBackendInspection,
903 /// Canonical file-store path, when the file backend is selected.
904 pub path: Option<PathBuf>,
905 /// Metadata-only presence of the canonical file-store path.
906 pub presence: SecretBackendPresence,
907 /// Ambient legacy file-store path, suppressed by explicit `CODEWHALE_HOME`.
908 pub legacy_path: Option<PathBuf>,
909 /// Metadata-only presence of the legacy file-store path.
910 pub legacy_presence: SecretBackendPresence,
911 }
912
913 /// Describe the configured credential backend without probing or reading it.
914 ///
915 /// This function never constructs [`DefaultKeyringStore`], calls
916 /// [`KeyringStore::get`], opens a secret file, performs legacy migration, or
917 /// creates filesystem state. It is suitable for ordinary status and doctor
918 /// commands.
919 #[must_use]
920 pub fn diagnose_secret_backend() -> SecretBackendDiagnostic {
921 match secret_backend_selection(configured_secret_backend().as_deref()) {
922 SecretBackendSelection::File => {
923 let (path, legacy_path) = FileKeyringStore::default_paths_read_only()
924 .map(|(path, legacy)| (Some(path), legacy))
925 .unwrap_or((None, None));
926 SecretBackendDiagnostic {
927 backend: SecretBackendDiagnosticKind::File,
928 inspection: SecretBackendInspection::MetadataOnly,
929 presence: metadata_presence(path.as_deref()),
930 legacy_presence: metadata_presence(legacy_path.as_deref()),
931 path,
932 legacy_path,
933 }
934 }
935 SecretBackendSelection::System => SecretBackendDiagnostic {
936 backend: SecretBackendDiagnosticKind::System,
937 inspection: SecretBackendInspection::NotProbed,
938 path: None,
939 presence: SecretBackendPresence::Unknown,
940 legacy_path: None,
941 legacy_presence: SecretBackendPresence::Unknown,
942 },
943 SecretBackendSelection::Unknown => SecretBackendDiagnostic {
944 backend: SecretBackendDiagnosticKind::Unknown,
945 inspection: SecretBackendInspection::NotProbed,
946 path: None,
947 presence: SecretBackendPresence::Unknown,
948 legacy_path: None,
949 legacy_presence: SecretBackendPresence::Unknown,
950 },
951 }
952 }
953
954 fn metadata_presence(path: Option<&Path>) -> SecretBackendPresence {
955 let Some(path) = path else {
956 return SecretBackendPresence::Unknown;
957 };
958 if let Some(parent) = path.parent() {
959 for ancestor in parent.ancestors() {
960 if ancestor.as_os_str().is_empty() {
961 continue;
962 }
963 match fs::symlink_metadata(ancestor) {
964 Ok(metadata)
965 if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() =>
966 {
967 return SecretBackendPresence::Unknown;
968 }
969 Ok(_) => {}
970 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
971 return SecretBackendPresence::Absent;
972 }
973 Err(_) => return SecretBackendPresence::Unknown,
974 }
975 }
976 }
977 match fs::symlink_metadata(path) {
978 Ok(metadata) if metadata.file_type().is_file() => SecretBackendPresence::Present,
979 Ok(_) => SecretBackendPresence::Unknown,
980 Err(error) if error.kind() == std::io::ErrorKind::NotFound => SecretBackendPresence::Absent,
981 Err(_) => SecretBackendPresence::Unknown,
982 }
983 }
984
985 fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
986 match value.map(str::trim).filter(|value| !value.is_empty()) {
987 None => SecretBackendSelection::File,
988 Some(value) => match value.to_ascii_lowercase().as_str() {
989 "file" | "local" | "json" => SecretBackendSelection::File,
990 "system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
991 _ => SecretBackendSelection::Unknown,
992 },
993 }
994 }
995
996 fn configured_secret_backend() -> Option<String> {
997 std::env::var(SECRET_BACKEND_ENV)
998 .ok()
999 .filter(|value| !value.trim().is_empty())
1000 .or_else(|| std::env::var(LEGACY_SECRET_BACKEND_ENV).ok())
1001 }
1002
1003 /// High-level facade combining a [`KeyringStore`] with environment variable fallbacks.
1004 ///
1005 /// Lookup precedence: **secret store -> env -> none**. Callers that also
1006 /// have a TOML config layer must wire that themselves at the very end
1007 /// of the chain (the config crate handles this).
1008 ///
1009 /// # Examples
1010 ///
1011 /// ```no_run
1012 /// use codewhale_secrets::Secrets;
1013 ///
1014 /// let secrets = Secrets::auto_detect();
1015 /// if let Some(key) = secrets.resolve("deepseek") {
1016 /// // use the API key
1017 /// }
1018 /// ```
1019 #[derive(Clone)]
1020 pub struct Secrets {
1021 /// Underlying secret store backend.
1022 pub store: Arc<dyn KeyringStore>,
1023 /// Owner identifier within the secret store (typically `"deepseek"`).
1024 /// The `key` parameter passed to [`resolve`](Secrets::resolve) is
1025 /// forwarded to the store as-is, while environment variables are
1026 /// looked up by canonical provider name via [`env_for`].
1027 service: String,
1028 }
1029
1030 /// Identifies which layer in the resolution chain supplied a secret.
1031 ///
1032 /// Returned by [`Secrets::resolve_with_source`] so callers can
1033 /// distinguish whether a value came from the configured store or from
1034 /// a process environment variable.
1035 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1036 pub enum SecretSource {
1037 /// The secret was returned by the configured [`KeyringStore`] backend.
1038 Keyring,
1039 /// The secret was found in a process environment variable.
1040 Env,
1041 }
1042
1043 impl std::fmt::Debug for Secrets {
1044 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1045 f.debug_struct("Secrets")
1046 .field("backend", &self.store.backend_name())
1047 .field("service", &self.service)
1048 .finish()
1049 }
1050 }
1051
1052 impl Secrets {
1053 /// Build a new facade around the given store, using the
1054 /// [`DEFAULT_SERVICE`] service name.
1055 #[must_use]
1056 pub fn new(store: Arc<dyn KeyringStore>) -> Self {
1057 Self {
1058 store,
1059 service: DEFAULT_SERVICE.to_string(),
1060 }
1061 }
1062
1063 /// Auto-detect the best available backend based on the environment.
1064 ///
1065 /// Selection logic:
1066 /// 1. If [`SECRET_BACKEND_ENV`] is set to `system`/`keyring`/`os`/`os-keyring`,
1067 /// probe the OS keyring. If the probe succeeds, use it; otherwise
1068 /// fall back to the file-based store with a warning.
1069 /// 2. If the env var is unset, empty, or `file`/`local`/`json`, use
1070 /// the file-based store directly.
1071 /// 3. If the env var is set to an unrecognised value, log a warning
1072 /// and use the file-based store.
1073 pub fn auto_detect() -> Self {
1074 match secret_backend_selection(configured_secret_backend().as_deref()) {
1075 SecretBackendSelection::File => Self::file_backed_default(),
1076 SecretBackendSelection::Unknown => {
1077 tracing::warn!(
1078 "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
1079 );
1080 Self::file_backed_default()
1081 }
1082 SecretBackendSelection::System => {
1083 let default_store = DefaultKeyringStore::default();
1084 match default_store.probe() {
1085 Ok(()) => Self::new(Arc::new(default_store)),
1086 Err(err) => {
1087 tracing::warn!(
1088 "OS keyring unavailable ({err}); falling back to file-backed secret store"
1089 );
1090 Self::file_backed_default()
1091 }
1092 }
1093 }
1094 }
1095 }
1096
1097 /// Auto-detect a secret backend for diagnostics without permitting writes
1098 /// or legacy migration.
1099 ///
1100 /// The selected backend and lookup precedence match [`Self::auto_detect`],
1101 /// but file-backed lookup reads the Codewhale location first and the legacy
1102 /// location second instead of copying legacy entries into a new file. This
1103 /// lets status and doctor reports label a saved credential without changing
1104 /// user state.
1105 #[must_use]
1106 pub fn auto_detect_read_only() -> Self {
1107 match secret_backend_selection(configured_secret_backend().as_deref()) {
1108 SecretBackendSelection::File => Self::file_backed_read_only(),
1109 SecretBackendSelection::Unknown => {
1110 tracing::warn!(
1111 "{SECRET_BACKEND_ENV}/{LEGACY_SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
1112 );
1113 Self::file_backed_read_only()
1114 }
1115 SecretBackendSelection::System => {
1116 let default_store = DefaultKeyringStore::default();
1117 match default_store.probe() {
1118 Ok(()) => {
1119 Self::new(Arc::new(ReadOnlyKeyringStore::new(Arc::new(default_store))))
1120 }
1121 Err(err) => {
1122 tracing::warn!(
1123 "OS keyring unavailable ({err}); falling back to file-backed secret store"
1124 );
1125 Self::file_backed_read_only()
1126 }
1127 }
1128 }
1129 }
1130 }
1131
1132 fn file_backed_default() -> Self {
1133 Self::file_backed_from_default_path(FileKeyringStore::default_path())
1134 }
1135
1136 /// Build the writable default store only when the resolved path is safe.
1137 ///
1138 /// Keeping the resolution result as an argument gives the no-home and
1139 /// relative-path branches direct regression coverage. Both must refuse
1140 /// writes rather than placing credentials in the caller's workspace.
1141 fn file_backed_from_default_path(path_result: Result<PathBuf, SecretsError>) -> Self {
1142 // Never fall back to a workspace-relative secrets path. Writing
1143 // credential material beside the cwd is readable by tools and easy to
1144 // commit. If home resolution fails, use a write-refusing store.
1145 match path_result {
1146 Ok(path) if path.is_absolute() => Self::new(Arc::new(FileKeyringStore::new(path))),
1147 Ok(path) => {
1148 tracing::error!(
1149 "refusing relative file-backed secret path {}; credentials will not be read or persisted",
1150 path.display()
1151 );
1152 Self::read_only_empty_store()
1153 }
1154 Err(err) => {
1155 tracing::error!(
1156 "could not resolve file-backed secret path ({err}); credentials will not be read or persisted"
1157 );
1158 Self::read_only_empty_store()
1159 }
1160 }
1161 }
1162
1163 /// An unavailable default path must be hermetic: neither inspect an
1164 /// accidental workspace file nor create one. The read-only wrapper keeps
1165 /// the public API's write failure explicit while reads safely report empty.
1166 fn read_only_empty_store() -> Self {
1167 Self::new(Arc::new(ReadOnlyKeyringStore::new(Arc::new(
1168 InMemoryKeyringStore::new(),
1169 ))))
1170 }
1171
1172 /// Construct a file-backed diagnostic store without migration or write
1173 /// capability.
1174 ///
1175 /// This reads the Codewhale file first and the legacy file second (unless
1176 /// `CODEWHALE_HOME` is explicit), but never copies legacy entries into a
1177 /// primary store. It intentionally bypasses an opted-in OS keyring so
1178 /// callers that only need non-secret diagnostics do not cause a platform
1179 /// credential prompt.
1180 #[must_use]
1181 pub fn file_backed_read_only() -> Self {
1182 // Fail closed like the writable path in `file_backed_from_default_path`:
1183 // never fall back to a cwd-relative credential path. A planted
1184 // `.codewhale-secrets.json` beside the working directory must not
1185 // become the credential store when home resolution fails.
1186 match ReadOnlyFileKeyringStore::default_for_diagnostics() {
1187 Ok(store) => Self::new(Arc::new(store)),
1188 Err(err) => {
1189 tracing::error!(
1190 "could not resolve the file-backed secret path ({err}); credentials will not be read. \
1191 Fix: set CODEWHALE_HOME to an absolute path or make HOME/USERPROFILE resolvable"
1192 );
1193 Self::read_only_empty_store()
1194 }
1195 }
1196 }
1197
1198 /// Construct the file-backed default backend directly.
1199 #[must_use]
1200 pub fn file_backed() -> Self {
1201 Self::file_backed_default()
1202 }
1203
1204 /// Construct the opt-in OS credential backend, falling back to the
1205 /// file-backed store when the platform backend is unavailable.
1206 #[must_use]
1207 pub fn system_keyring() -> Self {
1208 let default_store = DefaultKeyringStore::default();
1209 match default_store.probe() {
1210 Ok(()) => Self::new(Arc::new(default_store)),
1211 Err(err) => {
1212 tracing::warn!(
1213 "OS keyring unavailable ({err}); falling back to file-backed secret store"
1214 );
1215 Self::file_backed_default()
1216 }
1217 }
1218 }
1219
1220 /// Backend label, suitable for `doctor` output.
1221 #[must_use]
1222 pub fn backend_name(&self) -> &'static str {
1223 self.store.backend_name()
1224 }
1225
1226 /// Resolve a secret with `secret store → env → none` precedence.
1227 ///
1228 /// `name` is the canonical provider name or a supported provider alias.
1229 /// Empty strings on either layer are treated as "not set".
1230 #[must_use]
1231 pub fn resolve(&self, name: &str) -> Option<String> {
1232 self.resolve_with_source(name).map(|(value, _)| value)
1233 }
1234
1235 /// Resolve a secret and report which layer supplied it.
1236 #[must_use]
1237 pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
1238 if let Ok(Some(v)) = self.store.get(name)
1239 && !v.trim().is_empty()
1240 {
1241 return Some((v, SecretSource::Keyring));
1242 }
1243 env_for(name).map(|value| (value, SecretSource::Env))
1244 }
1245
1246 /// Convenience: write a secret through the underlying store.
1247 pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
1248 self.store.set(name, value)
1249 }
1250
1251 /// Convenience: delete a secret through the underlying store.
1252 pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
1253 self.store.delete(name)
1254 }
1255
1256 /// Convenience: read a secret directly (no env fallback).
1257 pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
1258 self.store.get(name)
1259 }
1260
1261 /// Run one non-reentrant callback under the backend's entry authority.
1262 pub fn with_entry_transaction<T>(
1263 &self,
1264 name: &str,
1265 operation: impl FnOnce(&mut Option<String>) -> Result<T, SecretsError>,
1266 ) -> Result<T, SecretsError> {
1267 let mut operation = Some(operation);
1268 let mut result = None;
1269 self.store.with_entry_transaction(name, &mut |value| {
1270 let call = operation.take().ok_or_else(|| {
1271 SecretsError::Keyring("Secret transaction invoked more than once".into())
1272 })?;
1273 result = Some(call(value)?);
1274 Ok(())
1275 })?;
1276 result.ok_or_else(|| SecretsError::Keyring("Secret transaction was not invoked".into()))
1277 }
1278
1279 /// Atomically update one secret only while its exact stored bytes match.
1280 pub fn compare_exchange(
1281 &self,
1282 name: &str,
1283 expected: Option<&str>,
1284 replacement: Option<&str>,
1285 ) -> Result<bool, SecretsError> {
1286 self.store.compare_exchange(name, expected, replacement)
1287 }
1288
1289 /// Resolve a secret by key name with an optional source constraint.
1290 ///
1291 /// This is the fleet-worker secret resolution path. Unlike
1292 /// [`resolve`](Secrets::resolve), this does NOT map provider names
1293 /// to their canonical env vars — the caller controls the exact key
1294 /// and resolution order.
1295 ///
1296 /// `source_hint` controls the resolution order:
1297 /// - `Some("env")` — only check environment variables
1298 /// - `Some("keyring")` — only check the keyring/file store
1299 /// - `None` — try the store first, then fall back to environment
1300 #[must_use]
1301 pub fn resolve_direct(&self, key: &str, source_hint: Option<&str>) -> Option<String> {
1302 match source_hint {
1303 Some("env") => {
1304 // Only check process environment — skip the store entirely.
1305 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
1306 }
1307 Some("keyring") | Some("file") => {
1308 // Only check the store backend.
1309 self.store
1310 .get(key)
1311 .ok()
1312 .flatten()
1313 .filter(|v| !v.trim().is_empty())
1314 }
1315 Some(_) | None => {
1316 // Default: store first, then env fallback.
1317 if let Ok(Some(v)) = self.store.get(key)
1318 && !v.trim().is_empty()
1319 {
1320 return Some(v);
1321 }
1322 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
1323 }
1324 }
1325 }
1326 }
1327
1328 /// Map a canonical provider name to its environment variable(s), returning
1329 /// the first non-empty value found.
1330 ///
1331 /// Provider names are case-insensitive. Supported providers and their
1332 /// environment variables:
1333 ///
1334 /// | Provider | Env var(s) |
1335 /// |---|---|
1336 /// | `deepseek` | `DEEPSEEK_API_KEY` |
1337 /// | `openrouter` | `OPENROUTER_API_KEY` |
1338 /// | `xiaomi-mimo` / `mimo` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |
1339 /// | `novita` / `novita-ai` | `NOVITA_API_KEY` |
1340 /// | `nvidia` / `nvidia-nim` / `nim` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` |
1341 /// | `fireworks` / `fireworks-ai` | `FIREWORKS_API_KEY` |
1342 /// | `together` / `togetherai` | `TOGETHER_API_KEY` |
1343 /// | `deepinfra` | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` |
1344 /// | `siliconflow` / `siliconflow-cn` | `SILICONFLOW_API_KEY` |
1345 /// | `arcee` / `arcee-ai` | `ARCEE_API_KEY` |
1346 /// | `moonshot` / `kimi` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |
1347 /// | `modelscope` / `modelscope-cn` | `MODELSCOPE_API_KEY` |
1348 /// | `sglang` | `SGLANG_API_KEY` |
1349 /// | `vllm` | `VLLM_API_KEY` |
1350 /// | `ollama` | `OLLAMA_API_KEY` |
1351 /// | `ollama-cloud` | `OLLAMA_CLOUD_API_KEY`, `OLLAMA_API_KEY` |
1352 /// | `openai` | `OPENAI_API_KEY` |
1353 /// | `atlascloud` / `atlas` | `ATLASCLOUD_API_KEY` |
1354 /// | `volcengine` / `ark` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |
1355 /// | `wanjie` / `wanjie-ark` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |
1356 /// | `meta` / `muse-spark` | `META_MODEL_API_KEY`, `MODEL_API_KEY` |
1357 /// | `xai` / `grok` | `XAI_API_KEY` |
1358 /// | `telecomjs` / `tokenhub` | `TELECOMJS_API_KEY` |
1359 /// | `edenai` / `eden-ai` | `EDENAI_API_KEY` |
1360 /// | `zenmux` / `zen-mux` | `ZENMUX_API_KEY` |
1361 /// | `csdn` / `csdn-ai` / `starmap` | `CSDN_API_KEY` |
1362 /// | `concentrate` / `concentrate-ai` | `CONCENTRATE_API_KEY` |
1363 /// | `codewhale` / `codewhale-api` | `CODEWHALE_API_KEY` |
1364 ///
1365 /// Returns `None` if the provider is not recognised or none of its
1366 /// candidate environment variables are set to a non-empty value.
1367 #[must_use]
1368 pub fn env_for(name: &str) -> Option<String> {
1369 let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
1370 "deepseek" => &["DEEPSEEK_API_KEY"],
1371 "openrouter" => &["OPENROUTER_API_KEY"],
1372 "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
1373 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]
1374 }
1375 "novita" | "novita-ai" | "novita_ai" => &["NOVITA_API_KEY"],
1376 "together" | "together-ai" | "together_ai" | "togetherai" => &["TOGETHER_API_KEY"],
1377 "deepinfra" | "deep-infra" | "deep_infra" => &["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
1378 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"],
1379 "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
1380 "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn"
1381 | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"],
1382 "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"],
1383 "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
1384 "modelscope" | "model-scope" | "model_scope" | "modelscope-cn" | "modelscope_cn" => {
1385 &["MODELSCOPE_API_KEY"]
1386 }
1387 "sglang" | "sg-lang" => &["SGLANG_API_KEY"],
1388 "vllm" | "v-llm" => &["VLLM_API_KEY"],
1389 "ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
1390 "ollama-cloud" | "ollama_cloud" => &["OLLAMA_CLOUD_API_KEY", "OLLAMA_API_KEY"],
1391 "openai" => &["OPENAI_API_KEY"],
1392 "anthropic" | "claude" => &["ANTHROPIC_API_KEY"],
1393 "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
1394 "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
1395 | "volcengineark" => &[
1396 "VOLCENGINE_API_KEY",
1397 "VOLCENGINE_ARK_API_KEY",
1398 "ARK_API_KEY",
1399 ],
1400 "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
1401 | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
1402 "WANJIE_ARK_API_KEY",
1403 "WANJIE_API_KEY",
1404 "WANJIE_MAAS_API_KEY",
1405 ],
1406 "sakana" | "sakana-ai" | "sakana_ai" | "fugu" => &["FUGU_API_KEY", "SAKANA_API_KEY"],
1407 "longcat" | "long-cat" | "meituan-longcat" | "meituan" => &["LONGCAT_API_KEY"],
1408 "opencode-go" | "opencode_go" | "opencodego" => &["OPENCODE_GO_API_KEY"],
1409 "opencode-zen" | "opencode_zen" | "opencodezen" | "zen" | "opencode" => {
1410 &["OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"]
1411 }
1412 "meta" | "meta-ai" | "meta_ai" | "meta-model-api" | "meta_model_api" | "muse"
1413 | "muse-spark" => &["META_MODEL_API_KEY", "MODEL_API_KEY"],
1414 "xai" | "x-ai" | "x_ai" | "grok" => &["XAI_API_KEY"],
1415 "telecomjs" | "telecom-js" | "telecom_js" | "telecomjs-cn" | "tokenhub" => {
1416 &["TELECOMJS_API_KEY"]
1417 }
1418 "edenai" | "eden-ai" | "eden_ai" => &["EDENAI_API_KEY"],
1419 "zenmux" | "zen-mux" | "zen_mux" => &["ZENMUX_API_KEY"],
1420 "csdn" | "csdn-ai" | "csdn_ai" | "csdn-coding-plan" | "csdn_coding_plan" | "starmap" => {
1421 &["CSDN_API_KEY"]
1422 }
1423 "concentrate" | "concentrate-ai" | "concentrate_ai" | "concentrateai" => {
1424 &["CONCENTRATE_API_KEY"]
1425 }
1426 // The Codewhale API route's credential *is* the account API key: one
1427 // `cwc_key_…` with the `models:infer` scope, not a second secret.
1428 "codewhale" | "codewhale-api" | "codewhale_api" | "cw-api" | "cw_api"
1429 | "codewhale-cloud" | "codewhale_cloud" => &["CODEWHALE_API_KEY"],
1430 "daytona" => &[DAYTONA_API_KEY_ENV, CWC_DAYTONA_TOKEN_ENV],
1431 // One Alibaba Cloud Model Studio account authenticates every plan /
1432 // dialect variant; all four names share one env convention.
1433 "modelstudio-token-plan"
1434 | "modelstudio_token_plan"
1435 | "modelstudio-token-plan-anthropic"
1436 | "modelstudio_token_plan_anthropic"
1437 | "modelstudio-coding-plan"
1438 | "modelstudio_coding_plan"
1439 | "modelstudio-coding-plan-anthropic"
1440 | "modelstudio_coding_plan_anthropic"
1441 | "modelstudio"
1442 | "dashscope"
1443 | "alibaba-token-plan"
1444 | "alibaba-coding-plan" => &["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"],
1445 _ => return None,
1446 };
1447 for var in candidates {
1448 if let Ok(value) = std::env::var(var)
1449 && !value.trim().is_empty()
1450 {
1451 return Some(value);
1452 }
1453 }
1454 None
1455 }
1456
1457 /// Report whether a Daytona token is present without revealing it.
1458 ///
1459 /// Order matches dispatch: secret-store slot `daytona`, then
1460 /// [`DAYTONA_API_KEY_ENV`], then [`CWC_DAYTONA_TOKEN_ENV`].
1461 #[must_use]
1462 pub fn daytona_credential_source(secrets: &Secrets) -> Option<&'static str> {
1463 if secrets
1464 .get(DAYTONA_TOKEN_SLOT)
1465 .ok()
1466 .flatten()
1467 .is_some_and(|value| !value.trim().is_empty())
1468 {
1469 return Some("secret-store");
1470 }
1471 for var in [DAYTONA_API_KEY_ENV, CWC_DAYTONA_TOKEN_ENV] {
1472 if std::env::var(var)
1473 .ok()
1474 .is_some_and(|value| !value.trim().is_empty())
1475 {
1476 return Some("env");
1477 }
1478 }
1479 None
1480 }
1481
1482 #[cfg(test)]
1483 mod tests {
1484 use super::*;
1485 use std::sync::{Mutex, OnceLock};
1486
1487 /// Serialise env-mutating tests: tests in this module poke
1488 /// `DEEPSEEK_API_KEY` etc., which is process-global.
1489 pub(crate) fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1490 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1491 LOCK.get_or_init(|| Mutex::new(()))
1492 .lock()
1493 .unwrap_or_else(|p| p.into_inner())
1494 }
1495
1496 fn clear_known_envs() {
1497 for var in [
1498 "CODEWHALE_HOME",
1499 "DEEPSEEK_API_KEY",
1500 "OPENROUTER_API_KEY",
1501 "NOVITA_API_KEY",
1502 "NVIDIA_API_KEY",
1503 "NVIDIA_NIM_API_KEY",
1504 "FIREWORKS_API_KEY",
1505 "TOGETHER_API_KEY",
1506 "DEEPINFRA_API_KEY",
1507 "DEEPINFRA_TOKEN",
1508 "SILICONFLOW_API_KEY",
1509 "ARCEE_API_KEY",
1510 "SGLANG_API_KEY",
1511 "VLLM_API_KEY",
1512 "OLLAMA_API_KEY",
1513 "OLLAMA_CLOUD_API_KEY",
1514 "OPENAI_API_KEY",
1515 "ATLASCLOUD_API_KEY",
1516 "WANJIE_ARK_API_KEY",
1517 "WANJIE_API_KEY",
1518 "WANJIE_MAAS_API_KEY",
1519 "XIAOMI_MIMO_API_KEY",
1520 "XIAOMI_API_KEY",
1521 "MIMO_API_KEY",
1522 "FUGU_API_KEY",
1523 "SAKANA_API_KEY",
1524 "LONGCAT_API_KEY",
1525 "OPENCODE_GO_API_KEY",
1526 "OPENCODE_ZEN_API_KEY",
1527 "OPENCODE_API_KEY",
1528 "META_MODEL_API_KEY",
1529 "MODEL_API_KEY",
1530 "XAI_API_KEY",
1531 "TELECOMJS_API_KEY",
1532 "EDENAI_API_KEY",
1533 "ZENMUX_API_KEY",
1534 "CSDN_API_KEY",
1535 "CONCENTRATE_API_KEY",
1536 "MODELSTUDIO_API_KEY",
1537 "DASHSCOPE_API_KEY",
1538 DAYTONA_API_KEY_ENV,
1539 CWC_DAYTONA_TOKEN_ENV,
1540 SECRET_BACKEND_ENV,
1541 LEGACY_SECRET_BACKEND_ENV,
1542 ] {
1543 // Safety: tests serialise on env_lock(); the broader
1544 // workspace has the same pattern in `crates/config`.
1545 unsafe { std::env::remove_var(var) };
1546 }
1547 }
1548
1549 struct EnvVarGuard {
1550 name: &'static str,
1551 previous: Option<std::ffi::OsString>,
1552 }
1553
1554 impl EnvVarGuard {
1555 fn set(name: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
1556 let previous = std::env::var_os(name);
1557 unsafe { std::env::set_var(name, value) };
1558 Self { name, previous }
1559 }
1560 }
1561
1562 impl Drop for EnvVarGuard {
1563 fn drop(&mut self) {
1564 match self.previous.take() {
1565 Some(value) => unsafe { std::env::set_var(self.name, value) },
1566 None => unsafe { std::env::remove_var(self.name) },
1567 }
1568 }
1569 }
1570
1571 /// Live check for #5172: on macOS/Windows the probe used to return Ok
1572 /// without touching the backend at all. Run explicitly with
1573 /// `cargo test -p codewhale-secrets -- --ignored` on a desktop machine:
1574 /// a healthy native keyring answers a read of the deliberately absent
1575 /// `__probe__` entry with NoEntry, silently, and the probe succeeds.
1576 #[test]
1577 #[ignore = "touches the real OS keyring; run on a desktop machine"]
1578 fn probe_performs_a_real_backend_read() {
1579 let store = DefaultKeyringStore::new("codewhale-probe-live-check");
1580 store
1581 .probe()
1582 .expect("the native keyring backend should be reachable on this machine");
1583 }
1584
1585 #[test]
1586 fn backend_selection_defaults_to_file() {
1587 assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
1588 assert_eq!(
1589 secret_backend_selection(Some("")),
1590 SecretBackendSelection::File
1591 );
1592 assert_eq!(
1593 secret_backend_selection(Some(" file ")),
1594 SecretBackendSelection::File
1595 );
1596 }
1597
1598 #[test]
1599 fn backend_selection_accepts_explicit_system_keyring() {
1600 assert_eq!(
1601 secret_backend_selection(Some("system")),
1602 SecretBackendSelection::System
1603 );
1604 assert_eq!(
1605 secret_backend_selection(Some("keyring")),
1606 SecretBackendSelection::System
1607 );
1608 assert_eq!(
1609 secret_backend_selection(Some("os-keyring")),
1610 SecretBackendSelection::System
1611 );
1612 }
1613
1614 #[test]
1615 fn auto_detect_is_file_backed_by_default() {
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
1622 let secrets = Secrets::auto_detect();
1623
1624 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1625 }
1626
1627 #[test]
1628 fn auto_detect_honors_explicit_file_backend() {
1629 let _lock = env_lock();
1630 clear_known_envs();
1631 let tmp = tempfile::tempdir().unwrap();
1632 let _home = EnvVarGuard::set("HOME", tmp.path());
1633 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1634 // Safety: env mutation guarded by env_lock().
1635 unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
1636
1637 let secrets = Secrets::auto_detect();
1638
1639 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1640 // Safety: env mutation guarded by env_lock().
1641 unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
1642 }
1643
1644 #[test]
1645 fn read_only_auto_detect_reads_legacy_without_migrating_or_allowing_writes() {
1646 let _lock = env_lock();
1647 clear_known_envs();
1648 let tmp = tempfile::tempdir().unwrap();
1649 let _home = EnvVarGuard::set("HOME", tmp.path());
1650 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1651 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1652 let legacy = tmp
1653 .path()
1654 .join(".deepseek")
1655 .join("secrets")
1656 .join("secrets.json");
1657 let primary = tmp
1658 .path()
1659 .join(".codewhale")
1660 .join("secrets")
1661 .join("secrets.json");
1662 FileKeyringStore::new(&legacy)
1663 .set("moonshot", "fixture-legacy-value")
1664 .unwrap();
1665
1666 let secrets = Secrets::auto_detect_read_only();
1667
1668 assert_eq!(
1669 secrets.get("moonshot").unwrap().as_deref(),
1670 Some("fixture-legacy-value")
1671 );
1672 assert!(
1673 !primary.exists(),
1674 "diagnostic lookup must not migrate the legacy store"
1675 );
1676 assert!(
1677 matches!(
1678 secrets.set("moonshot", "replacement"),
1679 Err(SecretsError::ReadOnly)
1680 ),
1681 "the diagnostic secret facade must refuse writes"
1682 );
1683 assert!(
1684 !primary.exists(),
1685 "a refused diagnostic write must not create the primary store"
1686 );
1687 }
1688
1689 #[test]
1690 fn read_only_auto_detect_respects_explicit_codewhale_home_isolation() {
1691 let _lock = env_lock();
1692 clear_known_envs();
1693 let tmp = tempfile::tempdir().unwrap();
1694 let codewhale_home = tmp.path().join("isolated-codewhale-home");
1695 let _home = EnvVarGuard::set("HOME", tmp.path());
1696 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1697 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1698 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1699 let legacy = tmp
1700 .path()
1701 .join(".deepseek")
1702 .join("secrets")
1703 .join("secrets.json");
1704 let primary = codewhale_home.join("secrets").join("secrets.json");
1705 FileKeyringStore::new(&legacy)
1706 .set("deepseek", "synthetic-ambient-legacy-value")
1707 .unwrap();
1708
1709 let secrets = Secrets::auto_detect_read_only();
1710
1711 assert_eq!(
1712 secrets.get("deepseek").unwrap(),
1713 None,
1714 "an explicit CODEWHALE_HOME must not read ambient legacy secrets"
1715 );
1716 assert!(
1717 !primary.exists(),
1718 "diagnostic lookup must not create an isolated primary store"
1719 );
1720 assert!(
1721 matches!(
1722 secrets.set("deepseek", "replacement"),
1723 Err(SecretsError::ReadOnly)
1724 ),
1725 "the isolated diagnostic facade must refuse writes"
1726 );
1727 assert!(
1728 !primary.exists(),
1729 "a refused isolated diagnostic write must not create the primary store"
1730 );
1731 }
1732
1733 /// Cwd is process-global, so tests that move it serialise on `env_lock`
1734 /// like the env-mutating tests and restore on drop.
1735 struct CwdGuard {
1736 previous: PathBuf,
1737 }
1738
1739 impl CwdGuard {
1740 fn enter(path: &Path) -> Self {
1741 let previous = std::env::current_dir().unwrap();
1742 std::env::set_current_dir(path).unwrap();
1743 Self { previous }
1744 }
1745 }
1746
1747 impl Drop for CwdGuard {
1748 fn drop(&mut self) {
1749 std::env::set_current_dir(&self.previous).unwrap();
1750 }
1751 }
1752
1753 #[test]
1754 fn file_backed_read_only_never_reads_a_cwd_relative_store() {
1755 let _lock = env_lock();
1756 clear_known_envs();
1757 let tmp = tempfile::tempdir().unwrap();
1758 // A relative override fails home resolution deterministically, which
1759 // used to fall back to a planted `.codewhale-secrets.json` in the cwd.
1760 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", "relative-codewhale-home");
1761 let planted = tmp.path().join(".codewhale-secrets.json");
1762 std::fs::write(
1763 &planted,
1764 r#"{"entries":{"deepseek":"planted-cwd-credential"}}"#,
1765 )
1766 .unwrap();
1767 let _cwd = CwdGuard::enter(tmp.path());
1768
1769 let secrets = Secrets::file_backed_read_only();
1770
1771 assert_eq!(
1772 secrets.get("deepseek").unwrap(),
1773 None,
1774 "a failed home resolution must not turn a planted cwd file into the credential store"
1775 );
1776 assert!(
1777 matches!(
1778 secrets.set("deepseek", "replacement"),
1779 Err(SecretsError::ReadOnly)
1780 ),
1781 "the failed-resolution diagnostic facade must still refuse writes"
1782 );
1783 }
1784
1785 #[test]
1786 fn read_only_auto_detect_reads_the_explicit_primary_store() {
1787 let _lock = env_lock();
1788 clear_known_envs();
1789 let tmp = tempfile::tempdir().unwrap();
1790 let codewhale_home = tmp.path().join("isolated-codewhale-home");
1791 let _home = EnvVarGuard::set("HOME", tmp.path());
1792 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1793 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1794 let _backend = EnvVarGuard::set(SECRET_BACKEND_ENV, "file");
1795 let primary = codewhale_home.join("secrets").join("secrets.json");
1796 FileKeyringStore::new(&primary)
1797 .set("deepseek", "synthetic-isolated-primary-value")
1798 .unwrap();
1799
1800 let secrets = Secrets::auto_detect_read_only();
1801
1802 assert_eq!(
1803 secrets.get("deepseek").unwrap().as_deref(),
1804 Some("synthetic-isolated-primary-value")
1805 );
1806 }
1807
1808 #[test]
1809 fn auto_detect_honors_legacy_backend_env_alias() {
1810 let _lock = env_lock();
1811 clear_known_envs();
1812 let tmp = tempfile::tempdir().unwrap();
1813 let _home = EnvVarGuard::set("HOME", tmp.path());
1814 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1815 unsafe { std::env::set_var(LEGACY_SECRET_BACKEND_ENV, "local") };
1816
1817 let secrets = Secrets::auto_detect();
1818
1819 assert_eq!(secrets.backend_name(), FILE_BACKEND_LABEL);
1820 clear_known_envs();
1821 }
1822
1823 #[test]
1824 fn file_default_path_uses_codewhale_home() {
1825 let _lock = env_lock();
1826 clear_known_envs();
1827 let tmp = tempfile::tempdir().unwrap();
1828 let _home = EnvVarGuard::set("HOME", tmp.path());
1829 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1830
1831 let path = FileKeyringStore::default_path().unwrap();
1832
1833 assert_eq!(
1834 path,
1835 tmp.path()
1836 .join(".codewhale")
1837 .join("secrets")
1838 .join("secrets.json")
1839 );
1840 }
1841
1842 #[test]
1843 fn file_default_path_honors_codewhale_home() {
1844 let _lock = env_lock();
1845 clear_known_envs();
1846 let tmp = tempfile::tempdir().unwrap();
1847 let custom = tmp.path().join("custom-codewhale");
1848 let _home = EnvVarGuard::set("HOME", tmp.path());
1849 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1850 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &custom);
1851
1852 let path = FileKeyringStore::default_path().unwrap();
1853
1854 assert_eq!(path, custom.join("secrets").join("secrets.json"));
1855 }
1856
1857 #[test]
1858 fn file_default_path_migrates_legacy_entries_to_codewhale() {
1859 let _lock = env_lock();
1860 clear_known_envs();
1861 let tmp = tempfile::tempdir().unwrap();
1862 let _home = EnvVarGuard::set("HOME", tmp.path());
1863 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1864 let legacy = tmp
1865 .path()
1866 .join(".deepseek")
1867 .join("secrets")
1868 .join("secrets.json");
1869 FileKeyringStore::new(legacy.clone())
1870 .set("xiaomi-mimo", "legacy-mimo")
1871 .unwrap();
1872
1873 let primary = FileKeyringStore::default_path().unwrap();
1874 let primary_store = FileKeyringStore::new(primary.clone());
1875
1876 assert_eq!(
1877 primary,
1878 tmp.path()
1879 .join(".codewhale")
1880 .join("secrets")
1881 .join("secrets.json")
1882 );
1883 assert_eq!(
1884 primary_store.get("xiaomi-mimo").unwrap().as_deref(),
1885 Some("legacy-mimo")
1886 );
1887 assert!(
1888 legacy.exists(),
1889 "migration copies; it does not delete legacy data"
1890 );
1891 }
1892
1893 #[test]
1894 fn file_default_path_migration_preserves_primary_values() {
1895 let _lock = env_lock();
1896 clear_known_envs();
1897 let tmp = tempfile::tempdir().unwrap();
1898 let _home = EnvVarGuard::set("HOME", tmp.path());
1899 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
1900 let legacy = tmp
1901 .path()
1902 .join(".deepseek")
1903 .join("secrets")
1904 .join("secrets.json");
1905 let primary = tmp
1906 .path()
1907 .join(".codewhale")
1908 .join("secrets")
1909 .join("secrets.json");
1910 FileKeyringStore::new(legacy)
1911 .set("openrouter", "legacy-openrouter")
1912 .unwrap();
1913 let primary_store = FileKeyringStore::new(primary.clone());
1914 primary_store
1915 .set("openrouter", "primary-openrouter")
1916 .unwrap();
1917
1918 let resolved = FileKeyringStore::default_path().unwrap();
1919
1920 assert_eq!(resolved, primary);
1921 assert_eq!(
1922 primary_store.get("openrouter").unwrap().as_deref(),
1923 Some("primary-openrouter")
1924 );
1925 }
1926
1927 #[test]
1928 fn in_memory_store_round_trips() {
1929 let store = InMemoryKeyringStore::new();
1930 assert_eq!(store.get("deepseek").unwrap(), None);
1931 store.set("deepseek", "sk-test").unwrap();
1932 assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
1933 store.set("deepseek", "sk-replaced").unwrap();
1934 assert_eq!(
1935 store.get("deepseek").unwrap(),
1936 Some("sk-replaced".to_string())
1937 );
1938 store.delete("deepseek").unwrap();
1939 assert_eq!(store.get("deepseek").unwrap(), None);
1940 // Deleting an absent key is a no-op.
1941 store.delete("missing").unwrap();
1942 }
1943
1944 #[test]
1945 fn resolve_prefers_keyring_over_env() {
1946 let _lock = env_lock();
1947 clear_known_envs();
1948 // Safety: env mutation guarded by env_lock().
1949 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1950
1951 let store = Arc::new(InMemoryKeyringStore::new());
1952 store.set("deepseek", "ring-key").unwrap();
1953 let secrets = Secrets::new(store);
1954
1955 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
1956 assert_eq!(
1957 secrets.resolve_with_source("deepseek"),
1958 Some(("ring-key".to_string(), SecretSource::Keyring))
1959 );
1960 // Safety: env mutation guarded by env_lock().
1961 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1962 }
1963
1964 #[test]
1965 fn resolve_falls_back_to_env_when_keyring_empty() {
1966 let _lock = env_lock();
1967 clear_known_envs();
1968 // Safety: env mutation guarded by env_lock().
1969 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
1970
1971 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1972 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
1973 assert_eq!(
1974 secrets.resolve_with_source("deepseek"),
1975 Some(("env-fallback".to_string(), SecretSource::Env))
1976 );
1977 // Safety: env mutation guarded by env_lock().
1978 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1979 }
1980
1981 #[test]
1982 fn resolve_returns_none_when_both_layers_empty() {
1983 let _lock = env_lock();
1984 clear_known_envs();
1985 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
1986 assert_eq!(secrets.resolve("deepseek"), None);
1987 }
1988
1989 #[test]
1990 fn resolve_treats_blank_keyring_value_as_unset() {
1991 let _lock = env_lock();
1992 clear_known_envs();
1993 // Safety: env mutation guarded by env_lock().
1994 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
1995
1996 let store = Arc::new(InMemoryKeyringStore::new());
1997 store.set("deepseek", " ").unwrap();
1998 let secrets = Secrets::new(store);
1999 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
2000 // Safety: env mutation guarded by env_lock().
2001 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
2002 }
2003
2004 #[test]
2005 fn nvidia_env_aliases_resolve() {
2006 let _lock = env_lock();
2007 clear_known_envs();
2008 // Safety: env mutation guarded by env_lock().
2009 unsafe {
2010 std::env::set_var("NVIDIA_API_KEY", "nvidia-key");
2011 std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key");
2012 }
2013 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
2014 for alias in ["nvidia", "nvidia-nim", "nvidia_nim", "nim"] {
2015 assert_eq!(
2016 secrets.resolve(alias).as_deref(),
2017 Some("nvidia-key"),
2018 "NVIDIA_API_KEY should take precedence for {alias}"
2019 );
2020 }
2021
2022 // Safety: env mutation guarded by env_lock().
2023 unsafe { std::env::remove_var("NVIDIA_API_KEY") };
2024 for alias in ["nvidia", "nvidia-nim", "nvidia_nim", "nim"] {
2025 assert_eq!(
2026 secrets.resolve(alias).as_deref(),
2027 Some("nim-key"),
2028 "NVIDIA_NIM_API_KEY should resolve for {alias}"
2029 );
2030 }
2031 clear_known_envs();
2032 }
2033
2034 #[test]
2035 fn nvidia_env_aliases_do_not_consume_deepseek_credentials() {
2036 let _lock = env_lock();
2037 clear_known_envs();
2038 // Safety: env mutation guarded by env_lock().
2039 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "deepseek-key") };
2040 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
2041
2042 for alias in ["nvidia", "nvidia-nim", "nvidia_nim", "nim"] {
2043 assert_eq!(
2044 secrets.resolve(alias),
2045 None,
2046 "DeepSeek credentials must stay isolated from {alias}"
2047 );
2048 }
2049 assert_eq!(secrets.resolve("deepseek").as_deref(), Some("deepseek-key"));
2050 clear_known_envs();
2051 }
2052
2053 #[test]
2054 fn atlascloud_env_aliases_resolve() {
2055 let _guard = env_lock();
2056 clear_known_envs();
2057 unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
2058
2059 assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
2060 assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
2061 assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
2062
2063 clear_known_envs();
2064 }
2065
2066 #[test]
2067 fn sakana_env_aliases_resolve() {
2068 let _guard = env_lock();
2069 clear_known_envs();
2070 unsafe { std::env::set_var("FUGU_API_KEY", "fugu-key") };
2071
2072 assert_eq!(env_for("sakana").as_deref(), Some("fugu-key"));
2073 assert_eq!(env_for("sakana-ai").as_deref(), Some("fugu-key"));
2074 assert_eq!(env_for("sakana_ai").as_deref(), Some("fugu-key"));
2075 assert_eq!(env_for("fugu").as_deref(), Some("fugu-key"));
2076
2077 clear_known_envs();
2078 unsafe { std::env::set_var("SAKANA_API_KEY", "sakana-key") };
2079 assert_eq!(env_for("sakana").as_deref(), Some("sakana-key"));
2080
2081 clear_known_envs();
2082 }
2083
2084 #[test]
2085 fn wanjie_ark_env_aliases_resolve() {
2086 let _guard = env_lock();
2087 clear_known_envs();
2088 unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
2089
2090 assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
2091 assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
2092 assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
2093
2094 clear_known_envs();
2095 }
2096
2097 #[test]
2098 fn xai_env_aliases_resolve() {
2099 let _guard = env_lock();
2100 clear_known_envs();
2101 unsafe { std::env::set_var("XAI_API_KEY", "xai-key") };
2102
2103 assert_eq!(env_for("xai").as_deref(), Some("xai-key"));
2104 assert_eq!(env_for("x-ai").as_deref(), Some("xai-key"));
2105 assert_eq!(env_for("x_ai").as_deref(), Some("xai-key"));
2106 assert_eq!(env_for("grok").as_deref(), Some("xai-key"));
2107
2108 clear_known_envs();
2109 }
2110
2111 #[test]
2112 fn telecomjs_env_aliases_resolve() {
2113 let _guard = env_lock();
2114 clear_known_envs();
2115 unsafe { std::env::set_var("TELECOMJS_API_KEY", "telecom-key") };
2116
2117 for alias in [
2118 "telecomjs",
2119 "telecom-js",
2120 "telecom_js",
2121 "telecomjs-cn",
2122 "tokenhub",
2123 ] {
2124 assert_eq!(env_for(alias).as_deref(), Some("telecom-key"), "{alias}");
2125 }
2126
2127 clear_known_envs();
2128 }
2129
2130 #[test]
2131 fn edenai_env_aliases_resolve() {
2132 let _guard = env_lock();
2133 clear_known_envs();
2134 unsafe { std::env::set_var("EDENAI_API_KEY", "eden-key") };
2135
2136 for alias in ["edenai", "eden-ai", "eden_ai"] {
2137 assert_eq!(env_for(alias).as_deref(), Some("eden-key"), "{alias}");
2138 }
2139
2140 clear_known_envs();
2141 }
2142
2143 #[test]
2144 fn zenmux_env_aliases_resolve() {
2145 let _guard = env_lock();
2146 clear_known_envs();
2147 unsafe { std::env::set_var("ZENMUX_API_KEY", "zen-key") };
2148
2149 for alias in ["zenmux", "zen-mux", "zen_mux"] {
2150 assert_eq!(env_for(alias).as_deref(), Some("zen-key"), "{alias}");
2151 }
2152
2153 clear_known_envs();
2154 }
2155
2156 #[test]
2157 fn csdn_env_aliases_resolve() {
2158 let _guard = env_lock();
2159 clear_known_envs();
2160 unsafe { std::env::set_var("CSDN_API_KEY", "csdn-key") };
2161
2162 for alias in [
2163 "csdn",
2164 "csdn-ai",
2165 "csdn_ai",
2166 "csdn-coding-plan",
2167 "csdn_coding_plan",
2168 "starmap",
2169 ] {
2170 assert_eq!(env_for(alias).as_deref(), Some("csdn-key"), "{alias}");
2171 }
2172
2173 clear_known_envs();
2174 }
2175
2176 #[test]
2177 fn concentrate_env_aliases_resolve() {
2178 let _guard = env_lock();
2179 clear_known_envs();
2180 unsafe { std::env::set_var("CONCENTRATE_API_KEY", "concentrate-key") };
2181
2182 for alias in [
2183 "concentrate",
2184 "concentrate-ai",
2185 "concentrate_ai",
2186 "concentrateai",
2187 ] {
2188 assert_eq!(
2189 env_for(alias).as_deref(),
2190 Some("concentrate-key"),
2191 "{alias}"
2192 );
2193 }
2194 // The gateway key is its own slot: no other provider's env name feeds it
2195 // and it feeds no other provider.
2196 assert_eq!(env_for("edenai"), None);
2197 assert_eq!(env_for("openrouter"), None);
2198
2199 clear_known_envs();
2200 }
2201
2202 #[test]
2203 fn opencode_go_env_aliases_resolve() {
2204 let _guard = env_lock();
2205 clear_known_envs();
2206 unsafe { std::env::set_var("OPENCODE_GO_API_KEY", "go-key") };
2207
2208 for alias in ["opencode-go", "opencode_go", "opencodego"] {
2209 assert_eq!(env_for(alias).as_deref(), Some("go-key"), "{alias}");
2210 }
2211
2212 clear_known_envs();
2213 }
2214
2215 #[test]
2216 fn modelstudio_variants_share_one_env_convention() {
2217 let _guard = env_lock();
2218 clear_known_envs();
2219 unsafe { std::env::set_var("MODELSTUDIO_API_KEY", "ms-key") };
2220
2221 for alias in [
2222 "modelstudio-token-plan",
2223 "modelstudio-token-plan-anthropic",
2224 "modelstudio-coding-plan",
2225 "modelstudio-coding-plan-anthropic",
2226 "modelstudio",
2227 "dashscope",
2228 "alibaba-token-plan",
2229 "alibaba-coding-plan",
2230 ] {
2231 assert_eq!(env_for(alias).as_deref(), Some("ms-key"), "{alias}");
2232 }
2233
2234 clear_known_envs();
2235 unsafe { std::env::set_var("DASHSCOPE_API_KEY", "dashscope-key") };
2236 assert_eq!(
2237 env_for("modelstudio-token-plan").as_deref(),
2238 Some("dashscope-key"),
2239 "DASHSCOPE_API_KEY is the fallback for the same account"
2240 );
2241
2242 clear_known_envs();
2243 }
2244
2245 #[test]
2246 fn opencode_zen_env_aliases_resolve() {
2247 let _guard = env_lock();
2248 clear_known_envs();
2249 unsafe { std::env::set_var("OPENCODE_ZEN_API_KEY", "zen-key") };
2250
2251 for alias in [
2252 "opencode-zen",
2253 "opencode_zen",
2254 "opencodezen",
2255 "zen",
2256 "opencode",
2257 ] {
2258 assert_eq!(env_for(alias).as_deref(), Some("zen-key"), "{alias}");
2259 }
2260
2261 clear_known_envs();
2262 }
2263
2264 #[test]
2265 fn meta_model_api_env_aliases_resolve() {
2266 let _guard = env_lock();
2267 clear_known_envs();
2268 unsafe { std::env::set_var("MODEL_API_KEY", "meta-key") };
2269
2270 for alias in [
2271 "meta",
2272 "meta-ai",
2273 "meta_ai",
2274 "meta-model-api",
2275 "meta_model_api",
2276 "muse",
2277 "muse-spark",
2278 ] {
2279 assert_eq!(env_for(alias).as_deref(), Some("meta-key"), "{alias}");
2280 }
2281
2282 clear_known_envs();
2283 unsafe { std::env::set_var("META_MODEL_API_KEY", "meta-prefixed-key") };
2284 assert_eq!(env_for("meta").as_deref(), Some("meta-prefixed-key"),);
2285
2286 clear_known_envs();
2287 }
2288
2289 #[test]
2290 fn xiaomi_mimo_env_aliases_resolve() {
2291 let _guard = env_lock();
2292 clear_known_envs();
2293 unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
2294
2295 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
2296 assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
2297 assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
2298 assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
2299
2300 clear_known_envs();
2301
2302 unsafe { std::env::set_var("XIAOMI_API_KEY", "xiaomi-key") };
2303 assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("xiaomi-key"));
2304 clear_known_envs();
2305 }
2306
2307 #[test]
2308 fn fireworks_env_aliases_resolve() {
2309 let _lock = env_lock();
2310 clear_known_envs();
2311 // Safety: env mutation guarded by env_lock().
2312 unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
2313
2314 assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
2315 assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
2316 // Safety: env mutation guarded by env_lock().
2317 unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
2318 }
2319
2320 #[test]
2321 fn together_env_aliases_resolve() {
2322 let _lock = env_lock();
2323 clear_known_envs();
2324 // Safety: env mutation guarded by env_lock().
2325 unsafe { std::env::set_var("TOGETHER_API_KEY", "together-key") };
2326
2327 // Canonical id plus the legacy hyphen/underscore spellings AND the
2328 // separator-free `togetherai` id Models.dev publishes must all resolve.
2329 assert_eq!(env_for("together").as_deref(), Some("together-key"));
2330 assert_eq!(env_for("together-ai").as_deref(), Some("together-key"));
2331 assert_eq!(env_for("together_ai").as_deref(), Some("together-key"));
2332 assert_eq!(env_for("togetherai").as_deref(), Some("together-key"));
2333 // Safety: env mutation guarded by env_lock().
2334 unsafe { std::env::remove_var("TOGETHER_API_KEY") };
2335 }
2336
2337 #[test]
2338 fn deepinfra_env_aliases_resolve() {
2339 let _lock = env_lock();
2340 clear_known_envs();
2341 // Safety: env mutation guarded by env_lock().
2342 unsafe { std::env::set_var("DEEPINFRA_API_KEY", "di-key") };
2343
2344 assert_eq!(env_for("deepinfra").as_deref(), Some("di-key"));
2345 assert_eq!(env_for("deep-infra").as_deref(), Some("di-key"));
2346 assert_eq!(env_for("deep_infra").as_deref(), Some("di-key"));
2347 // Safety: env mutation guarded by env_lock().
2348 unsafe { std::env::remove_var("DEEPINFRA_API_KEY") };
2349
2350 // The DEEPINFRA_TOKEN fallback is honored when the primary key is unset.
2351 // Safety: env mutation guarded by env_lock().
2352 unsafe { std::env::set_var("DEEPINFRA_TOKEN", "di-token") };
2353 assert_eq!(env_for("deepinfra").as_deref(), Some("di-token"));
2354 // Safety: env mutation guarded by env_lock().
2355 unsafe { std::env::remove_var("DEEPINFRA_TOKEN") };
2356 }
2357
2358 #[test]
2359 fn novita_env_aliases_resolve() {
2360 let _lock = env_lock();
2361 clear_known_envs();
2362 // Safety: env mutation guarded by env_lock().
2363 unsafe { std::env::set_var("NOVITA_API_KEY", "novita-key") };
2364
2365 assert_eq!(env_for("novita").as_deref(), Some("novita-key"));
2366 // `novita-ai` is the Models.dev provider id (Refs #4186).
2367 assert_eq!(env_for("novita-ai").as_deref(), Some("novita-key"));
2368 assert_eq!(env_for("novita_ai").as_deref(), Some("novita-key"));
2369 // Safety: env mutation guarded by env_lock().
2370 unsafe { std::env::remove_var("NOVITA_API_KEY") };
2371 }
2372
2373 #[test]
2374 fn siliconflow_env_aliases_resolve() {
2375 let _lock = env_lock();
2376 clear_known_envs();
2377 // Safety: env mutation guarded by env_lock().
2378 unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") };
2379
2380 assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key"));
2381 assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key"));
2382 assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key"));
2383 assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key"));
2384 assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key"));
2385 // Safety: env mutation guarded by env_lock().
2386 unsafe { std::env::remove_var("SILICONFLOW_API_KEY") };
2387 }
2388
2389 #[test]
2390 fn arcee_env_aliases_resolve() {
2391 let _lock = env_lock();
2392 clear_known_envs();
2393 // Safety: env mutation guarded by env_lock().
2394 unsafe { std::env::set_var("ARCEE_API_KEY", "arcee-key") };
2395
2396 assert_eq!(env_for("arcee").as_deref(), Some("arcee-key"));
2397 assert_eq!(env_for("arcee-ai").as_deref(), Some("arcee-key"));
2398 assert_eq!(env_for("arcee_ai").as_deref(), Some("arcee-key"));
2399 // Safety: env mutation guarded by env_lock().
2400 unsafe { std::env::remove_var("ARCEE_API_KEY") };
2401 }
2402
2403 #[test]
2404 fn moonshot_kimi_env_aliases_resolve() {
2405 let _lock = env_lock();
2406 clear_known_envs();
2407 // Safety: env mutation guarded by env_lock().
2408 unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") };
2409
2410 assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key"));
2411 assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key"));
2412 assert_eq!(env_for("kimi").as_deref(), Some("kimi-key"));
2413 assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key"));
2414 // Safety: env mutation guarded by env_lock().
2415 unsafe { std::env::remove_var("KIMI_API_KEY") };
2416 }
2417
2418 #[test]
2419 fn sglang_env_aliases_resolve() {
2420 let _lock = env_lock();
2421 clear_known_envs();
2422 // Safety: env mutation guarded by env_lock().
2423 unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
2424
2425 assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
2426 assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
2427 // Safety: env mutation guarded by env_lock().
2428 unsafe { std::env::remove_var("SGLANG_API_KEY") };
2429 }
2430
2431 #[test]
2432 fn vllm_env_aliases_resolve() {
2433 let _lock = env_lock();
2434 clear_known_envs();
2435 // Safety: env mutation guarded by env_lock().
2436 unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
2437
2438 assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
2439 assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
2440 // Safety: env mutation guarded by env_lock().
2441 unsafe { std::env::remove_var("VLLM_API_KEY") };
2442 }
2443
2444 #[test]
2445 fn ollama_env_aliases_resolve() {
2446 let _lock = env_lock();
2447 clear_known_envs();
2448 // Safety: env mutation guarded by env_lock().
2449 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
2450
2451 assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
2452 assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
2453 // Safety: env mutation guarded by env_lock().
2454 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
2455 }
2456
2457 #[test]
2458 fn ollama_cloud_env_prefers_pi_name_then_official_name() {
2459 let _lock = env_lock();
2460 clear_known_envs();
2461 // Safety: env mutation guarded by env_lock().
2462 unsafe {
2463 std::env::set_var("OLLAMA_CLOUD_API_KEY", "cloud-specific-key");
2464 std::env::set_var("OLLAMA_API_KEY", "official-fallback-key");
2465 }
2466
2467 assert_eq!(
2468 env_for("ollama-cloud").as_deref(),
2469 Some("cloud-specific-key")
2470 );
2471 assert_eq!(
2472 env_for("ollama_cloud").as_deref(),
2473 Some("cloud-specific-key")
2474 );
2475 // The local identity stays on its original, keyless-provider env
2476 // contract and never consumes the cloud-specific compatibility name.
2477 assert_eq!(env_for("ollama").as_deref(), Some("official-fallback-key"));
2478
2479 // Safety: env mutation guarded by env_lock().
2480 unsafe { std::env::remove_var("OLLAMA_CLOUD_API_KEY") };
2481 assert_eq!(
2482 env_for("ollama-cloud").as_deref(),
2483 Some("official-fallback-key")
2484 );
2485 clear_known_envs();
2486 }
2487
2488 #[cfg(unix)]
2489 #[test]
2490 fn file_store_round_trips_with_secure_perms() {
2491 use std::os::unix::fs::PermissionsExt;
2492
2493 let tmp = tempfile::tempdir().unwrap();
2494 let path = tmp.path().join("nested").join("secrets.json");
2495 let store = FileKeyringStore::new(path.clone());
2496 assert_eq!(store.get("deepseek").unwrap(), None);
2497 store.set("deepseek", "sk-disk").unwrap();
2498 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
2499
2500 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2501 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
2502
2503 store.set("openrouter", "or-disk").unwrap();
2504 assert_eq!(
2505 store.get("openrouter").unwrap(),
2506 Some("or-disk".to_string())
2507 );
2508 // First entry must still be intact.
2509 assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
2510
2511 store.delete("deepseek").unwrap();
2512 assert_eq!(store.get("deepseek").unwrap(), None);
2513 }
2514
2515 #[cfg(unix)]
2516 #[test]
2517 fn file_store_rejects_world_readable_file() {
2518 use std::os::unix::fs::PermissionsExt;
2519 let tmp = tempfile::tempdir().unwrap();
2520 let path = tmp.path().join("secrets.json");
2521 fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
2522 let mut perms = fs::metadata(&path).unwrap().permissions();
2523 perms.set_mode(0o644);
2524 fs::set_permissions(&path, perms).unwrap();
2525
2526 let store = FileKeyringStore::new(path);
2527 let err = store.get("deepseek").unwrap_err();
2528 assert!(
2529 matches!(err, SecretsError::InsecurePermissions { .. }),
2530 "unexpected error: {err}"
2531 );
2532 }
2533
2534 // Regression for #281: `set` and `delete` used to call
2535 // `load_unlocked().unwrap_or_default()`, which silently wiped every
2536 // existing secret whenever the read failed (insecure permissions,
2537 // corrupt JSON, or any other I/O error).
2538
2539 #[cfg(unix)]
2540 #[test]
2541 fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
2542 use std::os::unix::fs::PermissionsExt;
2543 let tmp = tempfile::tempdir().unwrap();
2544 let path = tmp.path().join("secrets.json");
2545 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
2546 fs::write(&path, original).unwrap();
2547 let mut perms = fs::metadata(&path).unwrap().permissions();
2548 perms.set_mode(0o644);
2549 fs::set_permissions(&path, perms).unwrap();
2550
2551 let store = FileKeyringStore::new(path.clone());
2552 let err = store.set("openrouter", "or-new").unwrap_err();
2553 assert!(
2554 matches!(err, SecretsError::InsecurePermissions { .. }),
2555 "set must surface the read error rather than overwriting; got: {err}"
2556 );
2557
2558 let on_disk = fs::read_to_string(&path).unwrap();
2559 assert_eq!(
2560 on_disk, original,
2561 "set must not modify the file when load_unlocked errored"
2562 );
2563 }
2564
2565 #[cfg(unix)]
2566 #[test]
2567 fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
2568 use std::os::unix::fs::PermissionsExt;
2569 let tmp = tempfile::tempdir().unwrap();
2570 let path = tmp.path().join("secrets.json");
2571 let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
2572 fs::write(&path, original).unwrap();
2573 let mut perms = fs::metadata(&path).unwrap().permissions();
2574 perms.set_mode(0o644);
2575 fs::set_permissions(&path, perms).unwrap();
2576
2577 let store = FileKeyringStore::new(path.clone());
2578 let err = store.delete("nvidia").unwrap_err();
2579 assert!(
2580 matches!(err, SecretsError::InsecurePermissions { .. }),
2581 "delete must surface the read error rather than wiping the file; got: {err}"
2582 );
2583 let on_disk = fs::read_to_string(&path).unwrap();
2584 assert_eq!(on_disk, original);
2585 }
2586
2587 #[test]
2588 fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
2589 let tmp = tempfile::tempdir().unwrap();
2590 let path = tmp.path().join("secrets.json");
2591 // Corrupt JSON. Permissions ok where unix; on Windows the perm-check
2592 // doesn't run so we exercise the json-error path directly.
2593 fs::write(&path, "{ this is not valid json").unwrap();
2594 #[cfg(unix)]
2595 {
2596 use std::os::unix::fs::PermissionsExt;
2597 let mut perms = fs::metadata(&path).unwrap().permissions();
2598 perms.set_mode(0o600);
2599 fs::set_permissions(&path, perms).unwrap();
2600 }
2601
2602 let store = FileKeyringStore::new(path.clone());
2603 let err = store.set("deepseek", "sk-new").unwrap_err();
2604 assert!(
2605 matches!(err, SecretsError::Json(_)),
2606 "set must surface the parse error rather than wiping the file; got: {err}"
2607 );
2608 let on_disk = fs::read_to_string(&path).unwrap();
2609 assert_eq!(on_disk, "{ this is not valid json");
2610 }
2611
2612 #[test]
2613 fn file_store_set_still_creates_file_when_missing() {
2614 // Regression guard: the #281 fix removed `unwrap_or_default()` from
2615 // the load call. Make sure the original first-write-creates-the-file
2616 // ergonomic still works — `load_unlocked` returns `Ok(default)` for
2617 // a missing file, so the `?` should pass through cleanly.
2618 let tmp = tempfile::tempdir().unwrap();
2619 let path = tmp.path().join("nested").join("secrets.json");
2620 let store = FileKeyringStore::new(path.clone());
2621
2622 store.set("deepseek", "sk-fresh").unwrap();
2623 assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
2624 }
2625
2626 #[test]
2627 fn file_store_default_path_uses_home() {
2628 let _lock = env_lock();
2629 clear_known_envs();
2630 let tmp = tempfile::tempdir().unwrap();
2631 let _home = EnvVarGuard::set("HOME", tmp.path());
2632 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2633
2634 let path = FileKeyringStore::default_path().unwrap();
2635 assert_eq!(
2636 path,
2637 tmp.path()
2638 .join(".codewhale")
2639 .join("secrets")
2640 .join("secrets.json")
2641 );
2642 }
2643
2644 #[test]
2645 fn default_path_with_explicit_codewhale_home_does_not_migrate_ambient_legacy() {
2646 // FR003-C001: explicit CODEWHALE_HOME must not silently import ambient
2647 // `$HOME/.deepseek/secrets` credentials into the isolated home.
2648 let _lock = env_lock();
2649 clear_known_envs();
2650 let tmp = tempfile::tempdir().unwrap();
2651 let codewhale_home = tmp.path().join("isolated-codewhale-home");
2652 let _home = EnvVarGuard::set("HOME", tmp.path());
2653 let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path());
2654 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
2655 let legacy = tmp
2656 .path()
2657 .join(".deepseek")
2658 .join("secrets")
2659 .join("secrets.json");
2660 FileKeyringStore::new(&legacy)
2661 .set("deepseek", "synthetic-ambient-legacy-value")
2662 .unwrap();
2663
2664 let path = FileKeyringStore::default_path().unwrap();
2665 assert_eq!(path, codewhale_home.join("secrets").join("secrets.json"));
2666 assert!(
2667 !path.exists(),
2668 "explicit CODEWHALE_HOME must not create/migrate a primary store from ambient legacy"
2669 );
2670
2671 let secrets = Secrets::auto_detect();
2672 assert_eq!(
2673 secrets.get("deepseek").unwrap(),
2674 None,
2675 "explicit CODEWHALE_HOME must not surface ambient legacy credentials"
2676 );
2677 }
2678
2679 #[test]
2680 fn file_backed_default_refuses_relative_secret_path() {
2681 // FR003-C002: a relative fallback would resolve against the workspace
2682 // and risk committing credentials. It must be write-refusing instead.
2683 let secrets =
2684 Secrets::file_backed_from_default_path(Ok(PathBuf::from(".codewhale-secrets.json")));
2685 assert!(matches!(
2686 secrets.set("deepseek", "must-not-land-relative"),
2687 Err(SecretsError::ReadOnly)
2688 ));
2689 assert_eq!(
2690 secrets.get("deepseek").unwrap(),
2691 None,
2692 "unsafe relative fallback must not read a workspace secret file"
2693 );
2694 }
2695
2696 #[test]
2697 fn file_backed_default_refuses_writes_when_home_resolution_fails() {
2698 // Force the exact fallback branch instead of relying on the shared
2699 // platform-home resolver, which normally succeeds with HOME unset.
2700 let err = SecretsError::Io(std::io::Error::new(
2701 std::io::ErrorKind::NotFound,
2702 "synthetic unresolved home",
2703 ));
2704 let secrets = Secrets::file_backed_from_default_path(Err(err));
2705 assert!(matches!(
2706 secrets.set("deepseek", "must-not-persist"),
2707 Err(SecretsError::ReadOnly)
2708 ));
2709 assert_eq!(secrets.get("deepseek").unwrap(), None);
2710 }
2711
2712 #[test]
2713 fn daytona_slot_resolves_secret_store_then_dispatch_envs() {
2714 let _lock = env_lock();
2715 clear_known_envs();
2716 let secrets = Secrets::new(std::sync::Arc::new(InMemoryKeyringStore::new()));
2717 assert_eq!(daytona_credential_source(&secrets), None);
2718 assert_eq!(DAYTONA_TOKEN_SLOT, "daytona");
2719
2720 secrets.set(DAYTONA_TOKEN_SLOT, "dtn_store").unwrap();
2721 assert_eq!(daytona_credential_source(&secrets), Some("secret-store"));
2722 secrets.delete(DAYTONA_TOKEN_SLOT).unwrap();
2723
2724 let _key = EnvVarGuard::set(DAYTONA_API_KEY_ENV, "dtn_env");
2725 assert_eq!(daytona_credential_source(&secrets), Some("env"));
2726 assert_eq!(env_for("daytona").as_deref(), Some("dtn_env"));
2727 }
2728
2729 #[path = "diagnostic_tests.rs"]
2730 mod diagnostic_tests;
2731 }
2732
2732 lines RUST