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