Skip to main content

devboy_storage/
lib.rs

1//! Secure credential storage with multiple backends.
2//!
3//! This crate provides credential storage with support for:
4//!
5//! - **OS Keychain**: macOS Keychain, Windows Credential Manager, Linux Secret Service
6//! - **Environment Variables**: For CI/CD and containerized environments
7//! - **Chain Store**: Composable fallback between multiple backends
8//!
9//! # Credential Resolution Order
10//!
11//! When using `ChainStore::default_chain()`, credentials are resolved in this order:
12//!
13//! 1. **Environment variables** (highest priority, for CI/CD)
14//!    - `DEVBOY_{PROVIDER}_TOKEN` (e.g., `DEVBOY_GITHUB_TOKEN`)
15//!    - `{PROVIDER}_TOKEN` (fallback, e.g., `GITHUB_TOKEN`)
16//! 2. **OS Keychain** (for local development)
17//!
18//! # Example
19//!
20//! ```ignore
21//! use devboy_storage::{ChainStore, CredentialStore};
22//!
23//! // Use the default chain (env vars -> keychain)
24//! let store = ChainStore::default_chain();
25//!
26//! // This will check DEVBOY_GITHUB_TOKEN, then GITHUB_TOKEN,
27//! // then keychain for "github.token"
28//! let token = store.get("github.token")?;
29//!
30//! // Or use keychain directly for local development
31//! use devboy_storage::KeychainStore;
32//! let keychain = KeychainStore::new();
33//! keychain.store("gitlab.token", "glpat-xxx")?;
34//! ```
35
36#![deny(rustdoc::broken_intra_doc_links)]
37#![deny(rustdoc::private_intra_doc_links)]
38#![deny(rustdoc::invalid_html_tags)]
39use devboy_core::{Error, Result};
40use keyring::Entry;
41use secrecy::{ExposeSecret, SecretString};
42use tracing::{debug, warn};
43
44pub mod cache;
45pub mod ci;
46pub mod expiry;
47pub mod index;
48pub mod manifest;
49pub mod merge;
50pub mod pattern_resolution;
51pub mod plugin_client;
52pub mod plugin_manifest;
53pub mod plugin_protocol;
54pub mod router_cache;
55pub mod router_config;
56pub mod router_credentials;
57pub mod router_resolve;
58pub mod secret_path;
59pub mod source;
60pub mod validation;
61
62pub use cache::CachedStore;
63pub use ci::{
64    CI_HEURISTIC_VARS, CiActivation, CiDetection, CiPolicy, DEVBOY_CI_ENV, detect_ci_mode,
65};
66pub use expiry::{ExpiryWarning, ExpiryWarningKind, WARNING_WINDOW_DAYS, check_rotation_reminders};
67pub use index::{ApproveOnUse, Gate, GlobalIndex, IndexEntry, IndexError, RotationMethod};
68pub use manifest::{
69    MANIFEST_RELATIVE_PATH, ManifestError, OverrideEntry, PathRole, ProjectManifest,
70};
71pub use merge::{
72    MergeError, MergeOutput, MergeWarning, MergeWarningKind, OverrideField, ResolvedSecret,
73    SecretOrigin, merge_manifest,
74};
75pub use pattern_resolution::{
76    InheritanceWarning, InheritanceWarningKind, apply_pattern_inheritance,
77};
78pub use router_cache::{AdaptiveCache, CacheClock, DEFAULT_BASE_TTL, ManualClock, SystemClock};
79pub use router_config::{
80    DefaultRoute, RouteRule, RouterConfig, RouterConfigError, SOURCES_FILENAME, SecretOverride,
81    SourceAccess, SourceDefinition,
82};
83pub use router_credentials::{
84    CredentialGraphError, SOURCE_CREDENTIALS_PREFIX, validate_source_credentials,
85};
86pub use router_resolve::{PathResolver, ResolveError, RouteDecision};
87pub use secret_path::{PathError, SecretPath};
88pub use source::{
89    Capabilities, CredentialRef, GetOutcome, RemoteRef, SecretSource, SourceError, SourceStatus,
90};
91pub use validation::{FormatCheck, FormatRuleSource, validate_format};
92
93/// Service name used in OS keychain.
94const SERVICE_NAME: &str = "devboy-tools";
95
96/// Credential storage trait.
97///
98/// Implementations can use OS keychain, environment variables, in-memory storage,
99/// or other backends.
100pub trait CredentialStore: Send + Sync {
101    /// Store a credential securely.
102    ///
103    /// The key should follow the convention: `{provider}.{credential_name}`
104    /// For example: `gitlab.token`, `github.token`, `jira.email`.
105    ///
106    /// The value is taken as `&SecretString` so callers cannot accidentally
107    /// log or otherwise leak the plaintext on its way into storage.
108    fn store(&self, key: &str, value: &SecretString) -> Result<()>;
109
110    /// Retrieve a stored credential.
111    ///
112    /// Returns `Ok(None)` if the credential doesn't exist. The returned
113    /// `SecretString` redacts itself in `Debug` output and zeroizes the
114    /// buffer on drop — call `.expose_secret()` only at the boundary that
115    /// actually consumes the secret (HTTP header, etc.).
116    fn get(&self, key: &str) -> Result<Option<SecretString>>;
117
118    /// Delete a stored credential.
119    ///
120    /// Returns `Ok(())` even if the credential didn't exist.
121    fn delete(&self, key: &str) -> Result<()>;
122
123    /// Check if a credential exists.
124    fn exists(&self, key: &str) -> bool {
125        matches!(self.get(key), Ok(Some(_)))
126    }
127
128    /// Check if this credential store is available and functional.
129    ///
130    /// Returns `true` if the store can be used for credential operations.
131    /// This is useful for checking keychain availability in CI/container environments.
132    fn is_available(&self) -> bool {
133        true
134    }
135
136    /// Check if this store supports write operations.
137    ///
138    /// Some stores (like `EnvVarStore`) are read-only.
139    fn is_writable(&self) -> bool {
140        true
141    }
142}
143
144// =============================================================================
145// KeychainStore - OS Keychain implementation
146// =============================================================================
147
148/// Credential store using the OS keychain.
149///
150/// This is the recommended store for production use. It securely stores
151/// credentials in:
152/// - macOS: Keychain Services
153/// - Windows: Credential Manager
154/// - Linux: Secret Service (GNOME Keyring / KWallet)
155#[derive(Debug)]
156pub struct KeychainStore {
157    service_name: String,
158}
159
160impl KeychainStore {
161    /// Create a new keychain store with the default service name.
162    pub fn new() -> Self {
163        Self {
164            service_name: SERVICE_NAME.to_string(),
165        }
166    }
167
168    /// Create a keychain store with a custom service name.
169    ///
170    /// Useful for testing to avoid conflicts with real credentials.
171    pub fn with_service_name(service_name: impl Into<String>) -> Self {
172        Self {
173            service_name: service_name.into(),
174        }
175    }
176
177    fn make_entry(&self, key: &str) -> std::result::Result<Entry, keyring::Error> {
178        Entry::new(&self.service_name, key)
179    }
180}
181
182impl Default for KeychainStore {
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188impl CredentialStore for KeychainStore {
189    fn store(&self, key: &str, value: &SecretString) -> Result<()> {
190        debug!(key = key, "Storing credential in keychain");
191
192        let entry = self.make_entry(key).map_err(|e| {
193            Error::Storage(format!(
194                "Failed to create keychain entry for '{}': {}",
195                key, e
196            ))
197        })?;
198
199        entry
200            .set_password(value.expose_secret())
201            .map_err(|e| Error::Storage(format!("Failed to store credential '{}': {}", key, e)))?;
202
203        Ok(())
204    }
205
206    fn get(&self, key: &str) -> Result<Option<SecretString>> {
207        debug!(key = key, "Retrieving credential from keychain");
208
209        let entry = self.make_entry(key).map_err(|e| {
210            Error::Storage(format!(
211                "Failed to create keychain entry for '{}': {}",
212                key, e
213            ))
214        })?;
215
216        match entry.get_password() {
217            Ok(password) => Ok(Some(SecretString::from(password))),
218            Err(keyring::Error::NoEntry) => {
219                debug!(key = key, "Credential not found");
220                Ok(None)
221            }
222            Err(e) => {
223                warn!(key = key, error = %e, "Failed to retrieve credential");
224                Err(Error::Storage(format!(
225                    "Failed to retrieve credential '{}': {}",
226                    key, e
227                )))
228            }
229        }
230    }
231
232    fn delete(&self, key: &str) -> Result<()> {
233        debug!(key = key, "Deleting credential from keychain");
234
235        let entry = self.make_entry(key).map_err(|e| {
236            Error::Storage(format!(
237                "Failed to create keychain entry for '{}': {}",
238                key, e
239            ))
240        })?;
241
242        match entry.delete_credential() {
243            Ok(()) => Ok(()),
244            Err(keyring::Error::NoEntry) => {
245                // Already deleted, that's fine
246                debug!(key = key, "Credential was already deleted");
247                Ok(())
248            }
249            Err(e) => Err(Error::Storage(format!(
250                "Failed to delete credential '{}': {}",
251                key, e
252            ))),
253        }
254    }
255
256    fn is_available(&self) -> bool {
257        // Try to create an entry - if this fails, keychain is not available
258        // We don't actually write anything, just check if the backend is functional
259        match self.make_entry("__devboy_availability_check__") {
260            Ok(_) => true,
261            Err(e) => {
262                debug!(error = %e, "Keychain not available");
263                false
264            }
265        }
266    }
267}
268
269// =============================================================================
270// MemoryStore - In-memory implementation for testing
271// =============================================================================
272
273/// In-memory credential store for testing.
274///
275/// This store keeps credentials in memory wrapped in [`SecretString`]
276/// (zeroize-on-drop, redacted `Debug`) for unit tests that don't want
277/// to interact with the real OS keychain. The `Debug` impl shows the
278/// key set and a count, never the values, so accidentally logging a
279/// `MemoryStore` cannot leak plaintext.
280#[derive(Default)]
281pub struct MemoryStore {
282    credentials: std::sync::RwLock<std::collections::HashMap<String, SecretString>>,
283}
284
285impl std::fmt::Debug for MemoryStore {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        let creds = self.credentials.read();
288        let (count, keys) = match &creds {
289            Ok(map) => (map.len(), map.keys().cloned().collect::<Vec<_>>()),
290            Err(_) => (0, vec!["<lock-poisoned>".to_string()]),
291        };
292        f.debug_struct("MemoryStore")
293            .field("credentials", &format!("<{count} redacted secret(s)>"))
294            .field("keys", &keys)
295            .finish()
296    }
297}
298
299impl MemoryStore {
300    /// Create a new in-memory store.
301    pub fn new() -> Self {
302        Self::default()
303    }
304
305    /// Create a store pre-populated with credentials. Accepts plaintext
306    /// `(key, value)` pairs for test ergonomics; the values are wrapped
307    /// in [`SecretString`] before storage.
308    pub fn with_credentials(credentials: impl IntoIterator<Item = (String, String)>) -> Self {
309        let store = Self::new();
310        {
311            let mut creds = store.credentials.write().unwrap();
312            for (k, v) in credentials {
313                creds.insert(k, SecretString::from(v));
314            }
315        }
316        store
317    }
318}
319
320impl CredentialStore for MemoryStore {
321    fn store(&self, key: &str, value: &SecretString) -> Result<()> {
322        let mut creds = self
323            .credentials
324            .write()
325            .map_err(|e| Error::Storage(format!("Lock poisoned: {}", e)))?;
326        // Clone the SecretString directly — no `expose_secret()` call,
327        // no extra plaintext String allocation, and the cached value
328        // keeps the same zeroize-on-drop discipline as the input.
329        creds.insert(key.to_string(), value.clone());
330        Ok(())
331    }
332
333    fn get(&self, key: &str) -> Result<Option<SecretString>> {
334        let creds = self
335            .credentials
336            .read()
337            .map_err(|e| Error::Storage(format!("Lock poisoned: {}", e)))?;
338        Ok(creds.get(key).cloned())
339    }
340
341    fn delete(&self, key: &str) -> Result<()> {
342        let mut creds = self
343            .credentials
344            .write()
345            .map_err(|e| Error::Storage(format!("Lock poisoned: {}", e)))?;
346        creds.remove(key);
347        Ok(())
348    }
349}
350
351// =============================================================================
352// EnvVarStore - Environment variable implementation for CI/CD
353// =============================================================================
354
355/// Default prefix for environment variables.
356const DEFAULT_ENV_PREFIX: &str = "DEVBOY";
357
358/// Credential store using environment variables.
359///
360/// This is a read-only store that reads credentials from environment variables.
361/// It's designed for CI/CD pipelines and containerized environments where
362/// OS keychain may not be available.
363///
364/// # Key to Environment Variable Mapping
365///
366/// The key is converted to an environment variable name:
367/// - Converted to uppercase
368/// - Dots (`.`), slashes (`/`), and dashes (`-`) replaced with underscores (`_`)
369/// - Prefixed with `DEVBOY_` by default
370///
371/// Examples:
372/// - `github.token` → `DEVBOY_GITHUB_TOKEN` (then `GITHUB_TOKEN` as fallback)
373/// - `contexts.dashboard.github.token` → `DEVBOY_CONTEXTS_DASHBOARD_GITHUB_TOKEN`
374/// - `devboy-cloud.token` → `DEVBOY_DEVBOY_CLOUD_TOKEN`
375///
376/// # Example
377///
378/// ```ignore
379/// use devboy_storage::{EnvVarStore, CredentialStore};
380///
381/// // Reads from DEVBOY_GITHUB_TOKEN env var (if set)
382/// let store = EnvVarStore::new();
383/// let token = store.get("github.token")?;
384/// ```
385/// Function type for reading environment variables.
386/// Defaults to `std::env::var`, but can be replaced in tests.
387type EnvReader = fn(&str) -> std::result::Result<String, std::env::VarError>;
388
389/// Wrapper around `std::env::var` matching the `EnvReader` signature.
390fn read_env_var(key: &str) -> std::result::Result<String, std::env::VarError> {
391    std::env::var(key)
392}
393
394/// Environment-variable-backed credential store.
395///
396/// Resolves secrets by name through `std::env::var` (or an injected
397/// reader, for testing). Used as the CI / Docker fallback when the OS
398/// keychain is unavailable.
399pub struct EnvVarStore {
400    /// Prefix for environment variables (e.g., "DEVBOY").
401    prefix: String,
402    /// Whether to fall back to unprefixed variable names.
403    fallback_without_prefix: bool,
404    /// Function to read environment variables (injectable for testing).
405    env_reader: EnvReader,
406}
407
408impl EnvVarStore {
409    /// Create a new environment variable store with default settings.
410    ///
411    /// Uses `DEVBOY_` prefix and enables fallback to unprefixed variables.
412    pub fn new() -> Self {
413        Self {
414            prefix: DEFAULT_ENV_PREFIX.to_string(),
415            fallback_without_prefix: true,
416            env_reader: read_env_var,
417        }
418    }
419
420    /// Create with a custom prefix.
421    ///
422    /// # Example
423    ///
424    /// ```ignore
425    /// let store = EnvVarStore::with_prefix("MYAPP");
426    /// // Will check MYAPP_GITHUB_TOKEN, then GITHUB_TOKEN
427    /// ```
428    pub fn with_prefix(prefix: impl Into<String>) -> Self {
429        Self {
430            prefix: prefix.into(),
431            fallback_without_prefix: true,
432            env_reader: read_env_var,
433        }
434    }
435
436    /// Disable fallback to unprefixed environment variables.
437    ///
438    /// When disabled, only `{PREFIX}_{KEY}` format is checked.
439    pub fn without_fallback(mut self) -> Self {
440        self.fallback_without_prefix = false;
441        self
442    }
443
444    /// Replace the environment variable reader (for testing).
445    #[cfg(test)]
446    fn with_env_reader(mut self, reader: EnvReader) -> Self {
447        self.env_reader = reader;
448        self
449    }
450
451    /// Convert a credential key to environment variable name.
452    ///
453    /// - Uppercase the key
454    /// - Replace `.`, `/`, and `-` with `_`
455    fn key_to_env_name(&self, key: &str) -> String {
456        key.to_uppercase().replace(['.', '/', '-'], "_")
457    }
458
459    /// Get the prefixed environment variable name.
460    fn prefixed_env_name(&self, key: &str) -> String {
461        format!("{}_{}", self.prefix, self.key_to_env_name(key))
462    }
463
464    /// Get the unprefixed environment variable name.
465    fn unprefixed_env_name(&self, key: &str) -> String {
466        self.key_to_env_name(key)
467    }
468}
469
470impl std::fmt::Debug for EnvVarStore {
471    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
472        f.debug_struct("EnvVarStore")
473            .field("prefix", &self.prefix)
474            .field("fallback_without_prefix", &self.fallback_without_prefix)
475            .finish()
476    }
477}
478
479impl Default for EnvVarStore {
480    fn default() -> Self {
481        Self::new()
482    }
483}
484
485impl CredentialStore for EnvVarStore {
486    fn store(&self, _key: &str, _value: &SecretString) -> Result<()> {
487        Err(Error::Storage(
488            "EnvVarStore is read-only. Use OS keychain or set environment variables directly."
489                .to_string(),
490        ))
491    }
492
493    fn get(&self, key: &str) -> Result<Option<SecretString>> {
494        // Try prefixed first (e.g., DEVBOY_GITHUB_TOKEN)
495        let prefixed = self.prefixed_env_name(key);
496        if let Ok(value) = (self.env_reader)(&prefixed) {
497            debug!(key = key, env_var = %prefixed, "Found credential in environment variable");
498            return Ok(Some(SecretString::from(value)));
499        }
500
501        // Fallback to unprefixed (e.g., GITHUB_TOKEN)
502        if self.fallback_without_prefix {
503            let unprefixed = self.unprefixed_env_name(key);
504            if let Ok(value) = (self.env_reader)(&unprefixed) {
505                debug!(key = key, env_var = %unprefixed, "Found credential in environment variable (unprefixed)");
506                return Ok(Some(SecretString::from(value)));
507            }
508        }
509
510        debug!(key = key, "Credential not found in environment variables");
511        Ok(None)
512    }
513
514    fn delete(&self, _key: &str) -> Result<()> {
515        Err(Error::Storage(
516            "EnvVarStore is read-only. Environment variables cannot be deleted.".to_string(),
517        ))
518    }
519
520    fn is_writable(&self) -> bool {
521        false
522    }
523}
524
525// =============================================================================
526// ChainStore - Composable credential store with fallback
527// =============================================================================
528
529/// Composable credential store that chains multiple backends.
530///
531/// Attempts to retrieve credentials from each store in order until one succeeds.
532/// Write operations go to the first writable store.
533///
534/// # Default Chain
535///
536/// Use `ChainStore::default_chain()` for the recommended configuration:
537/// 1. Environment variables (highest priority, for CI/CD)
538/// 2. OS Keychain (for local development)
539///
540/// # Example
541///
542/// ```ignore
543/// use devboy_storage::{ChainStore, CredentialStore};
544///
545/// // Use default chain
546/// let store = ChainStore::default_chain();
547///
548/// // Or create custom chain
549/// use devboy_storage::{EnvVarStore, MemoryStore};
550/// let store = ChainStore::new(vec![
551///     Box::new(EnvVarStore::new()),
552///     Box::new(MemoryStore::new()),
553/// ]);
554/// ```
555pub struct ChainStore {
556    stores: Vec<Box<dyn CredentialStore>>,
557}
558
559impl ChainStore {
560    /// Create a chain store from a list of stores.
561    ///
562    /// Stores are tried in order for read operations.
563    /// The first writable store is used for write operations.
564    pub fn new(stores: Vec<Box<dyn CredentialStore>>) -> Self {
565        Self { stores }
566    }
567
568    /// Create the default credential chain.
569    ///
570    /// Order:
571    /// 1. Environment variables (`EnvVarStore`)
572    /// 2. OS Keychain (`KeychainStore`)
573    ///
574    /// This is the recommended configuration for most use cases:
575    /// - CI/CD can set `DEVBOY_*` or provider-specific env vars
576    /// - Local development uses keychain transparently
577    pub fn default_chain() -> Self {
578        Self::new(vec![
579            Box::new(EnvVarStore::new()),
580            Box::new(KeychainStore::new()),
581        ])
582    }
583
584    /// Create a chain for CI/CD environments (no keychain).
585    ///
586    /// Only uses environment variables and memory store.
587    /// Useful when keychain is not available.
588    pub fn ci_chain() -> Self {
589        Self::new(vec![
590            Box::new(EnvVarStore::new()),
591            Box::new(MemoryStore::new()),
592        ])
593    }
594
595    /// Get the number of stores in the chain.
596    pub fn len(&self) -> usize {
597        self.stores.len()
598    }
599
600    /// Check if the chain is empty.
601    pub fn is_empty(&self) -> bool {
602        self.stores.is_empty()
603    }
604}
605
606impl std::fmt::Debug for ChainStore {
607    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608        f.debug_struct("ChainStore")
609            .field("stores_count", &self.stores.len())
610            .finish()
611    }
612}
613
614impl CredentialStore for ChainStore {
615    fn store(&self, key: &str, value: &SecretString) -> Result<()> {
616        // Try each writable and available store in order
617        let mut last_error: Option<Error> = None;
618        for store in &self.stores {
619            if store.is_writable() && store.is_available() {
620                match store.store(key, value) {
621                    Ok(()) => return Ok(()),
622                    Err(e) => {
623                        debug!(key = key, error = %e, "Store write failed, trying next");
624                        last_error = Some(e);
625                    }
626                }
627            }
628        }
629        Err(last_error.unwrap_or_else(|| {
630            Error::Storage("No writable credential store available in chain".to_string())
631        }))
632    }
633
634    fn get(&self, key: &str) -> Result<Option<SecretString>> {
635        // Try each store in order, tracking errors
636        let mut last_error: Option<Error> = None;
637        for store in &self.stores {
638            match store.get(key) {
639                Ok(Some(value)) => return Ok(Some(value)),
640                Ok(None) => continue,
641                Err(e) => {
642                    // Log error but continue to next store
643                    debug!(key = key, error = %e, "Store returned error, trying next");
644                    last_error = Some(e);
645                }
646            }
647        }
648        // If all stores returned errors, propagate the last one
649        if let Some(e) = last_error {
650            Err(e)
651        } else {
652            Ok(None)
653        }
654    }
655
656    fn delete(&self, key: &str) -> Result<()> {
657        // Delete from all writable stores
658        let mut deleted_any = false;
659        let mut last_error: Option<Error> = None;
660
661        for store in &self.stores {
662            if store.is_writable() {
663                match store.delete(key) {
664                    Ok(()) => deleted_any = true,
665                    Err(e) => last_error = Some(e),
666                }
667            }
668        }
669
670        if deleted_any {
671            Ok(())
672        } else if let Some(e) = last_error {
673            Err(e)
674        } else {
675            // No writable stores, but that's ok for delete
676            Ok(())
677        }
678    }
679
680    fn is_available(&self) -> bool {
681        // Available if at least one store is available
682        self.stores.iter().any(|s| s.is_available())
683    }
684
685    fn is_writable(&self) -> bool {
686        // Writable if at least one store is writable
687        self.stores.iter().any(|s| s.is_writable())
688    }
689}
690
691// =============================================================================
692// Helper functions
693// =============================================================================
694
695/// Standard credential key for a provider's API token.
696pub fn token_key(provider: &str) -> String {
697    format!("{}/token", provider)
698}
699
700/// Build the default credential chain, optionally wrapping the whole thing in a TTL
701/// cache. Call this from host binaries (CLI, MCP server entrypoint) so the cache
702/// configuration stays consistent.
703///
704/// - `cache_ttl_secs == 0` → no cache, returns the raw [`ChainStore`].
705/// - `cache_ttl_secs > 0` → wraps in [`CachedStore`] with the requested TTL.
706pub fn build_default_store(cache_ttl_secs: u64) -> Box<dyn CredentialStore> {
707    let chain = ChainStore::default_chain();
708    if cache_ttl_secs == 0 {
709        Box::new(chain)
710    } else {
711        Box::new(CachedStore::new(
712            chain,
713            std::time::Duration::from_secs(cache_ttl_secs),
714        ))
715    }
716}
717
718/// Build a store on top of a user-provided backend (mainly useful for CI variants or
719/// custom test harnesses). Same cache semantics as [`build_default_store`].
720pub fn wrap_with_cache<S: CredentialStore + 'static>(
721    inner: S,
722    cache_ttl_secs: u64,
723) -> Box<dyn CredentialStore> {
724    if cache_ttl_secs == 0 {
725        Box::new(inner)
726    } else {
727        Box::new(CachedStore::new(
728            inner,
729            std::time::Duration::from_secs(cache_ttl_secs),
730        ))
731    }
732}
733
734/// Standard credential key for a provider's email (used by Jira).
735pub fn email_key(provider: &str) -> String {
736    format!("{}/email", provider)
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    fn secret(s: &str) -> SecretString {
744        SecretString::from(s.to_string())
745    }
746
747    fn exposed(s: &Option<SecretString>) -> Option<&str> {
748        s.as_ref().map(|v| v.expose_secret())
749    }
750
751    #[test]
752    fn test_memory_store_basic() {
753        let store = MemoryStore::new();
754
755        // Store
756        store.store("test/key", &secret("test-value")).unwrap();
757
758        // Get
759        let value = store.get("test/key").unwrap();
760        assert_eq!(exposed(&value), Some("test-value"));
761
762        // Exists
763        assert!(store.exists("test/key"));
764        assert!(!store.exists("nonexistent"));
765
766        // Delete
767        store.delete("test/key").unwrap();
768        let value = store.get("test/key").unwrap();
769        assert!(value.is_none());
770
771        // Delete non-existent (should not error)
772        store.delete("nonexistent").unwrap();
773    }
774
775    #[test]
776    fn test_memory_store_with_credentials() {
777        let store = MemoryStore::with_credentials([
778            ("gitlab/token".to_string(), "glpat-xxx".to_string()),
779            ("github/token".to_string(), "ghp-yyy".to_string()),
780        ]);
781
782        assert_eq!(
783            exposed(&store.get("gitlab/token").unwrap()),
784            Some("glpat-xxx")
785        );
786        assert_eq!(
787            exposed(&store.get("github/token").unwrap()),
788            Some("ghp-yyy")
789        );
790    }
791
792    #[test]
793    fn test_token_key() {
794        assert_eq!(token_key("gitlab"), "gitlab/token");
795        assert_eq!(token_key("github"), "github/token");
796    }
797
798    #[test]
799    fn test_email_key() {
800        assert_eq!(email_key("jira"), "jira/email");
801    }
802
803    #[test]
804    fn test_memory_store_delete_nonexistent() {
805        let store = MemoryStore::new();
806
807        // Delete non-existent key should succeed
808        store.delete("nonexistent/key").unwrap();
809
810        // Verify it's still not there
811        assert!(store.get("nonexistent/key").unwrap().is_none());
812    }
813
814    #[test]
815    fn test_memory_store_exists() {
816        let store = MemoryStore::new();
817
818        assert!(!store.exists("test/key"));
819
820        store.store("test/key", &secret("value")).unwrap();
821        assert!(store.exists("test/key"));
822
823        store.delete("test/key").unwrap();
824        assert!(!store.exists("test/key"));
825    }
826
827    #[test]
828    fn test_memory_store_overwrite() {
829        let store = MemoryStore::new();
830
831        store.store("test/key", &secret("value1")).unwrap();
832        assert_eq!(exposed(&store.get("test/key").unwrap()), Some("value1"));
833
834        store.store("test/key", &secret("value2")).unwrap();
835        assert_eq!(exposed(&store.get("test/key").unwrap()), Some("value2"));
836    }
837
838    #[test]
839    fn test_credential_store_exists_default_impl() {
840        // Test the default exists() impl from the trait
841        let store = MemoryStore::new();
842
843        store.store("key1", &secret("val1")).unwrap();
844
845        // CredentialStore::exists uses the default impl calling get()
846        assert!(CredentialStore::exists(&store, "key1"));
847        assert!(!CredentialStore::exists(&store, "key2"));
848    }
849
850    #[test]
851    fn test_keychain_store_new() {
852        let store = KeychainStore::new();
853        assert_eq!(store.service_name, "devboy-tools");
854    }
855
856    #[test]
857    fn test_keychain_store_with_service_name() {
858        let store = KeychainStore::with_service_name("test-service");
859        assert_eq!(store.service_name, "test-service");
860    }
861
862    #[test]
863    fn test_keychain_store_default() {
864        let store = KeychainStore::default();
865        assert_eq!(store.service_name, "devboy-tools");
866    }
867
868    // Note: KeychainStore tests are not included here because they would
869    // interact with the real OS keychain. Integration tests for KeychainStore
870    // should be run separately with appropriate cleanup.
871
872    // =========================================================================
873    // EnvVarStore tests
874    // =========================================================================
875
876    #[test]
877    fn test_env_var_store_new() {
878        let store = EnvVarStore::new();
879        assert_eq!(store.prefix, "DEVBOY");
880        assert!(store.fallback_without_prefix);
881    }
882
883    #[test]
884    fn test_env_var_store_with_prefix() {
885        let store = EnvVarStore::with_prefix("CUSTOM");
886        assert_eq!(store.prefix, "CUSTOM");
887        assert!(store.fallback_without_prefix);
888    }
889
890    #[test]
891    fn test_env_var_store_without_fallback() {
892        let store = EnvVarStore::new().without_fallback();
893        assert!(!store.fallback_without_prefix);
894    }
895
896    #[test]
897    fn test_env_var_store_key_to_env_name() {
898        let store = EnvVarStore::new();
899
900        // Test various key formats
901        assert_eq!(store.key_to_env_name("github.token"), "GITHUB_TOKEN");
902        assert_eq!(store.key_to_env_name("gitlab/token"), "GITLAB_TOKEN");
903        assert_eq!(
904            store.key_to_env_name("contexts.dashboard.github.token"),
905            "CONTEXTS_DASHBOARD_GITHUB_TOKEN"
906        );
907        // Dashes should also be converted
908        assert_eq!(
909            store.key_to_env_name("devboy-cloud.token"),
910            "DEVBOY_CLOUD_TOKEN"
911        );
912    }
913
914    #[test]
915    fn test_env_var_store_prefixed_env_name() {
916        let store = EnvVarStore::new();
917        assert_eq!(
918            store.prefixed_env_name("github.token"),
919            "DEVBOY_GITHUB_TOKEN"
920        );
921
922        let custom = EnvVarStore::with_prefix("MYAPP");
923        assert_eq!(
924            custom.prefixed_env_name("github.token"),
925            "MYAPP_GITHUB_TOKEN"
926        );
927    }
928
929    /// Mock env reader that returns values from a static map.
930    fn mock_env_reader(key: &str) -> std::result::Result<String, std::env::VarError> {
931        match key {
932            "DEVBOY_TEST_TOKEN" => Ok("prefixed-value".into()),
933            "TEST_FALLBACK_TOKEN" => Ok("unprefixed-value".into()),
934            "DEVBOY_TEST_PRIORITY_TOKEN" => Ok("prefixed".into()),
935            "TEST_PRIORITY_TOKEN" => Ok("unprefixed".into()),
936            "TEST_NO_FALLBACK_TOKEN" => Ok("unprefixed-value".into()),
937            "DEVBOY_CHAIN_TEST_TOKEN" => Ok("from-env".into()),
938            _ => Err(std::env::VarError::NotPresent),
939        }
940    }
941
942    #[test]
943    fn test_env_var_store_get_prefixed() {
944        let store = EnvVarStore::new().with_env_reader(mock_env_reader);
945
946        let result = store.get("test.token").unwrap();
947        assert_eq!(exposed(&result), Some("prefixed-value"));
948    }
949
950    #[test]
951    fn test_env_var_store_get_unprefixed_fallback() {
952        let store = EnvVarStore::new().with_env_reader(mock_env_reader);
953
954        let result = store.get("test.fallback.token").unwrap();
955        assert_eq!(exposed(&result), Some("unprefixed-value"));
956    }
957
958    #[test]
959    fn test_env_var_store_prefixed_takes_priority() {
960        let store = EnvVarStore::new().with_env_reader(mock_env_reader);
961
962        let result = store.get("test.priority.token").unwrap();
963        assert_eq!(exposed(&result), Some("prefixed"));
964    }
965
966    #[test]
967    fn test_env_var_store_no_fallback() {
968        let store = EnvVarStore::new()
969            .without_fallback()
970            .with_env_reader(mock_env_reader);
971
972        // Should NOT find it because fallback is disabled
973        // (TEST_NO_FALLBACK_TOKEN exists but only as unprefixed)
974        let result = store.get("test.no.fallback.token").unwrap();
975        assert!(result.is_none());
976    }
977
978    #[test]
979    fn test_env_var_store_not_found() {
980        let store = EnvVarStore::new().with_env_reader(mock_env_reader);
981
982        let result = store.get("nonexistent.key.that.does.not.exist").unwrap();
983        assert!(result.is_none());
984    }
985
986    #[test]
987    fn test_env_var_store_is_read_only() {
988        let store = EnvVarStore::new();
989
990        assert!(!store.is_writable());
991
992        let store_result = store.store("test.key", &secret("value"));
993        assert!(store_result.is_err());
994
995        let delete_result = store.delete("test.key");
996        assert!(delete_result.is_err());
997    }
998
999    #[test]
1000    fn test_env_var_store_default() {
1001        let store = EnvVarStore::default();
1002        assert_eq!(store.prefix, "DEVBOY");
1003    }
1004
1005    // =========================================================================
1006    // ChainStore tests
1007    // =========================================================================
1008
1009    #[test]
1010    fn test_chain_store_new() {
1011        let store = ChainStore::new(vec![]);
1012        assert!(store.is_empty());
1013        assert_eq!(store.len(), 0);
1014    }
1015
1016    #[test]
1017    fn test_chain_store_default_chain() {
1018        let store = ChainStore::default_chain();
1019        assert_eq!(store.len(), 2); // EnvVarStore + KeychainStore
1020        assert!(!store.is_empty());
1021    }
1022
1023    #[test]
1024    fn test_chain_store_ci_chain() {
1025        let store = ChainStore::ci_chain();
1026        assert_eq!(store.len(), 2); // EnvVarStore + MemoryStore
1027    }
1028
1029    #[test]
1030    fn test_chain_store_get_first_match_wins() {
1031        // Create chain with two memory stores
1032        let store1 = MemoryStore::with_credentials([("key1".to_string(), "value1".to_string())]);
1033        let store2 = MemoryStore::with_credentials([
1034            ("key1".to_string(), "value2".to_string()),
1035            ("key2".to_string(), "value2".to_string()),
1036        ]);
1037
1038        let chain = ChainStore::new(vec![Box::new(store1), Box::new(store2)]);
1039
1040        // key1 should come from first store
1041        assert_eq!(exposed(&chain.get("key1").unwrap()), Some("value1"));
1042
1043        // key2 should come from second store (not in first)
1044        assert_eq!(exposed(&chain.get("key2").unwrap()), Some("value2"));
1045
1046        // key3 not found in either
1047        assert!(chain.get("key3").unwrap().is_none());
1048    }
1049
1050    #[test]
1051    fn test_chain_store_store_to_first_writable() {
1052        // EnvVarStore (read-only) + MemoryStore (writable)
1053        let chain = ChainStore::new(vec![
1054            Box::new(EnvVarStore::new()),
1055            Box::new(MemoryStore::new()),
1056        ]);
1057
1058        // Should store to MemoryStore (first writable)
1059        chain.store("test.key", &secret("test-value")).unwrap();
1060
1061        // Should retrieve from chain
1062        assert_eq!(exposed(&chain.get("test.key").unwrap()), Some("test-value"));
1063    }
1064
1065    #[test]
1066    fn test_chain_store_no_writable_store_error() {
1067        // Chain with only read-only stores
1068        let chain = ChainStore::new(vec![Box::new(EnvVarStore::new())]);
1069
1070        let result = chain.store("test.key", &secret("value"));
1071        assert!(result.is_err());
1072        assert!(result.unwrap_err().to_string().contains("No writable"));
1073    }
1074
1075    #[test]
1076    fn test_chain_store_delete_from_all_writable() {
1077        let store1 = MemoryStore::new();
1078        let store2 = MemoryStore::new();
1079
1080        // Store in both
1081        store1.store("key", &secret("val1")).unwrap();
1082        store2.store("key", &secret("val2")).unwrap();
1083
1084        let chain = ChainStore::new(vec![Box::new(store1), Box::new(store2)]);
1085
1086        // Delete should remove from both
1087        chain.delete("key").unwrap();
1088
1089        // Neither should have the key now
1090        assert!(chain.get("key").unwrap().is_none());
1091    }
1092
1093    #[test]
1094    fn test_chain_store_is_available() {
1095        // Empty chain
1096        let empty = ChainStore::new(vec![]);
1097        assert!(!empty.is_available());
1098
1099        // Chain with available store
1100        let with_memory = ChainStore::new(vec![Box::new(MemoryStore::new())]);
1101        assert!(with_memory.is_available());
1102    }
1103
1104    #[test]
1105    fn test_chain_store_is_writable() {
1106        // Read-only chain
1107        let read_only = ChainStore::new(vec![Box::new(EnvVarStore::new())]);
1108        assert!(!read_only.is_writable());
1109
1110        // Writable chain
1111        let writable = ChainStore::new(vec![Box::new(MemoryStore::new())]);
1112        assert!(writable.is_writable());
1113    }
1114
1115    #[test]
1116    fn test_chain_store_env_var_priority() {
1117        // This tests the real use case: env var takes priority over memory
1118
1119        // Set up env var store with mock reader
1120        let env_store = EnvVarStore::new().with_env_reader(mock_env_reader);
1121
1122        // Set up memory store with different value
1123        let memory = MemoryStore::with_credentials([(
1124            "chain.test.token".to_string(),
1125            "from-memory".to_string(),
1126        )]);
1127
1128        // Chain: env -> memory
1129        let chain = ChainStore::new(vec![Box::new(env_store), Box::new(memory)]);
1130
1131        // Env var should win
1132        assert_eq!(
1133            exposed(&chain.get("chain.test.token").unwrap()),
1134            Some("from-env")
1135        );
1136    }
1137
1138    #[test]
1139    fn test_chain_store_fallback_to_memory_when_env_empty() {
1140        // Memory store with value
1141        let memory = MemoryStore::with_credentials([(
1142            "fallback.test.token".to_string(),
1143            "from-memory".to_string(),
1144        )]);
1145
1146        // Chain: env (empty) -> memory
1147        let chain = ChainStore::new(vec![Box::new(EnvVarStore::new()), Box::new(memory)]);
1148
1149        // Should fall back to memory
1150        assert_eq!(
1151            exposed(&chain.get("fallback.test.token").unwrap()),
1152            Some("from-memory")
1153        );
1154    }
1155
1156    #[test]
1157    fn test_chain_store_debug_impl() {
1158        let chain = ChainStore::default_chain();
1159        let debug_str = format!("{:?}", chain);
1160        assert!(debug_str.contains("ChainStore"));
1161        assert!(debug_str.contains("stores_count"));
1162    }
1163
1164    // =========================================================================
1165    // build_default_store / wrap_with_cache factories (Wire-up #2)
1166    // =========================================================================
1167
1168    #[test]
1169    fn test_build_default_store_zero_ttl_returns_writable_chain() {
1170        let store = build_default_store(0);
1171        // Default chain: env vars (read-only) + keychain (writable) → overall writable.
1172        assert!(store.is_writable());
1173    }
1174
1175    #[test]
1176    fn test_build_default_store_positive_ttl_delegates_writable() {
1177        let store = build_default_store(60);
1178        // Cache must not break write-capability delegation.
1179        assert!(store.is_writable());
1180    }
1181
1182    #[test]
1183    fn test_wrap_with_cache_zero_ttl_is_passthrough() {
1184        let inner = MemoryStore::with_credentials([("k".to_string(), "v".to_string())]);
1185        let store = wrap_with_cache(inner, 0);
1186        assert_eq!(exposed(&store.get("k").unwrap()), Some("v"));
1187    }
1188
1189    #[test]
1190    fn test_wrap_with_cache_populated_ttl_caches_lookups() {
1191        let inner = MemoryStore::with_credentials([("k".to_string(), "v1".to_string())]);
1192        let store = wrap_with_cache(inner, 60);
1193
1194        assert_eq!(exposed(&store.get("k").unwrap()), Some("v1"));
1195
1196        // Second call returns the same value — cached or not, semantics are identical.
1197        assert_eq!(exposed(&store.get("k").unwrap()), Some("v1"));
1198    }
1199}