1#![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
93const SERVICE_NAME: &str = "devboy-tools";
95
96pub trait CredentialStore: Send + Sync {
101 fn store(&self, key: &str, value: &SecretString) -> Result<()>;
109
110 fn get(&self, key: &str) -> Result<Option<SecretString>>;
117
118 fn delete(&self, key: &str) -> Result<()>;
122
123 fn exists(&self, key: &str) -> bool {
125 matches!(self.get(key), Ok(Some(_)))
126 }
127
128 fn is_available(&self) -> bool {
133 true
134 }
135
136 fn is_writable(&self) -> bool {
140 true
141 }
142}
143
144#[derive(Debug)]
156pub struct KeychainStore {
157 service_name: String,
158}
159
160impl KeychainStore {
161 pub fn new() -> Self {
163 Self {
164 service_name: SERVICE_NAME.to_string(),
165 }
166 }
167
168 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 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 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#[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 pub fn new() -> Self {
302 Self::default()
303 }
304
305 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 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
351const DEFAULT_ENV_PREFIX: &str = "DEVBOY";
357
358type EnvReader = fn(&str) -> std::result::Result<String, std::env::VarError>;
388
389fn read_env_var(key: &str) -> std::result::Result<String, std::env::VarError> {
391 std::env::var(key)
392}
393
394pub struct EnvVarStore {
400 prefix: String,
402 fallback_without_prefix: bool,
404 env_reader: EnvReader,
406}
407
408impl EnvVarStore {
409 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 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 pub fn without_fallback(mut self) -> Self {
440 self.fallback_without_prefix = false;
441 self
442 }
443
444 #[cfg(test)]
446 fn with_env_reader(mut self, reader: EnvReader) -> Self {
447 self.env_reader = reader;
448 self
449 }
450
451 fn key_to_env_name(&self, key: &str) -> String {
456 key.to_uppercase().replace(['.', '/', '-'], "_")
457 }
458
459 fn prefixed_env_name(&self, key: &str) -> String {
461 format!("{}_{}", self.prefix, self.key_to_env_name(key))
462 }
463
464 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 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 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
525pub struct ChainStore {
556 stores: Vec<Box<dyn CredentialStore>>,
557}
558
559impl ChainStore {
560 pub fn new(stores: Vec<Box<dyn CredentialStore>>) -> Self {
565 Self { stores }
566 }
567
568 pub fn default_chain() -> Self {
578 Self::new(vec![
579 Box::new(EnvVarStore::new()),
580 Box::new(KeychainStore::new()),
581 ])
582 }
583
584 pub fn ci_chain() -> Self {
589 Self::new(vec![
590 Box::new(EnvVarStore::new()),
591 Box::new(MemoryStore::new()),
592 ])
593 }
594
595 pub fn len(&self) -> usize {
597 self.stores.len()
598 }
599
600 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 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 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 debug!(key = key, error = %e, "Store returned error, trying next");
644 last_error = Some(e);
645 }
646 }
647 }
648 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 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 Ok(())
677 }
678 }
679
680 fn is_available(&self) -> bool {
681 self.stores.iter().any(|s| s.is_available())
683 }
684
685 fn is_writable(&self) -> bool {
686 self.stores.iter().any(|s| s.is_writable())
688 }
689}
690
691pub fn token_key(provider: &str) -> String {
697 format!("{}/token", provider)
698}
699
700pub 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
718pub 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
734pub 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.store("test/key", &secret("test-value")).unwrap();
757
758 let value = store.get("test/key").unwrap();
760 assert_eq!(exposed(&value), Some("test-value"));
761
762 assert!(store.exists("test/key"));
764 assert!(!store.exists("nonexistent"));
765
766 store.delete("test/key").unwrap();
768 let value = store.get("test/key").unwrap();
769 assert!(value.is_none());
770
771 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 store.delete("nonexistent/key").unwrap();
809
810 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 let store = MemoryStore::new();
842
843 store.store("key1", &secret("val1")).unwrap();
844
845 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 #[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 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 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 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 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 #[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); 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); }
1028
1029 #[test]
1030 fn test_chain_store_get_first_match_wins() {
1031 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 assert_eq!(exposed(&chain.get("key1").unwrap()), Some("value1"));
1042
1043 assert_eq!(exposed(&chain.get("key2").unwrap()), Some("value2"));
1045
1046 assert!(chain.get("key3").unwrap().is_none());
1048 }
1049
1050 #[test]
1051 fn test_chain_store_store_to_first_writable() {
1052 let chain = ChainStore::new(vec![
1054 Box::new(EnvVarStore::new()),
1055 Box::new(MemoryStore::new()),
1056 ]);
1057
1058 chain.store("test.key", &secret("test-value")).unwrap();
1060
1061 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 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 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 chain.delete("key").unwrap();
1088
1089 assert!(chain.get("key").unwrap().is_none());
1091 }
1092
1093 #[test]
1094 fn test_chain_store_is_available() {
1095 let empty = ChainStore::new(vec![]);
1097 assert!(!empty.is_available());
1098
1099 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 let read_only = ChainStore::new(vec![Box::new(EnvVarStore::new())]);
1108 assert!(!read_only.is_writable());
1109
1110 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 let env_store = EnvVarStore::new().with_env_reader(mock_env_reader);
1121
1122 let memory = MemoryStore::with_credentials([(
1124 "chain.test.token".to_string(),
1125 "from-memory".to_string(),
1126 )]);
1127
1128 let chain = ChainStore::new(vec![Box::new(env_store), Box::new(memory)]);
1130
1131 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 let memory = MemoryStore::with_credentials([(
1142 "fallback.test.token".to_string(),
1143 "from-memory".to_string(),
1144 )]);
1145
1146 let chain = ChainStore::new(vec![Box::new(EnvVarStore::new()), Box::new(memory)]);
1148
1149 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 #[test]
1169 fn test_build_default_store_zero_ttl_returns_writable_chain() {
1170 let store = build_default_store(0);
1171 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 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 assert_eq!(exposed(&store.get("k").unwrap()), Some("v1"));
1198 }
1199}