1use async_trait::async_trait;
10use std::collections::HashMap;
11use tracing::instrument;
12
13use crate::{Result, RotationResult, Secret, SecretMetadata, SecretRef, SecretsError};
14
15#[async_trait]
35pub trait SecretsProvider: Send + Sync {
36 async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret>;
48
49 async fn get_secrets(&self, scope: &str, names: &[&str]) -> Result<HashMap<String, Secret>>;
65
66 async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>>;
75
76 async fn exists(&self, scope: &str, name: &str) -> Result<bool>;
86}
87
88#[async_trait]
104pub trait SecretsStore: SecretsProvider {
105 async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()>;
120
121 async fn delete_secret(&self, scope: &str, name: &str) -> Result<()>;
133
134 async fn rotate_secret(
150 &self,
151 scope: &str,
152 name: &str,
153 value: &Secret,
154 ) -> Result<RotationResult> {
155 let previous_version = self
156 .list_secrets(scope)
157 .await?
158 .into_iter()
159 .find(|m| m.name == name)
160 .map(|m| m.version);
161 if previous_version.is_none() {
162 return Err(SecretsError::NotFound {
163 name: name.to_string(),
164 });
165 }
166 self.set_secret(scope, name, value).await?;
167 let new_version = self
168 .list_secrets(scope)
169 .await?
170 .into_iter()
171 .find(|m| m.name == name)
172 .map(|m| m.version)
173 .ok_or_else(|| SecretsError::NotFound {
174 name: name.to_string(),
175 })?;
176 Ok(RotationResult {
177 previous_version,
178 new_version,
179 })
180 }
181}
182
183#[async_trait]
188impl<T: SecretsProvider + ?Sized> SecretsProvider for std::sync::Arc<T> {
189 async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
190 (**self).get_secret(scope, name).await
191 }
192
193 async fn get_secrets(&self, scope: &str, names: &[&str]) -> Result<HashMap<String, Secret>> {
194 (**self).get_secrets(scope, names).await
195 }
196
197 async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
198 (**self).list_secrets(scope).await
199 }
200
201 async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
202 (**self).exists(scope, name).await
203 }
204}
205
206#[async_trait]
207impl<T: SecretsStore + ?Sized> SecretsStore for std::sync::Arc<T> {
208 async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()> {
209 (**self).set_secret(scope, name, value).await
210 }
211
212 async fn delete_secret(&self, scope: &str, name: &str) -> Result<()> {
213 (**self).delete_secret(scope, name).await
214 }
215
216 async fn rotate_secret(
217 &self,
218 scope: &str,
219 name: &str,
220 value: &Secret,
221 ) -> Result<RotationResult> {
222 (**self).rotate_secret(scope, name, value).await
223 }
224}
225
226#[async_trait]
236pub trait EnvScopeProvider: Send + Sync {
237 async fn resolve_env_scope(&self, name_or_id: &str) -> Result<String>;
243}
244
245pub struct SecretsResolver<P: SecretsProvider> {
278 provider: P,
279 scope: String,
280 env_resolver: Option<std::sync::Arc<dyn EnvScopeProvider>>,
281}
282
283impl<P: SecretsProvider> SecretsResolver<P> {
284 pub fn new(provider: P, scope: impl Into<String>) -> Self {
291 Self {
292 provider,
293 scope: scope.into(),
294 env_resolver: None,
295 }
296 }
297
298 #[must_use]
303 pub fn with_env_resolver(mut self, env_resolver: std::sync::Arc<dyn EnvScopeProvider>) -> Self {
304 self.env_resolver = Some(env_resolver);
305 self
306 }
307
308 pub fn provider(&self) -> &P {
310 &self.provider
311 }
312
313 pub fn scope(&self) -> &str {
315 &self.scope
316 }
317
318 #[instrument(skip(self), fields(scope = %self.scope))]
334 pub async fn resolve_value(&self, value: &str) -> Result<String> {
335 if let Some(rest) = value.strip_prefix("$secret://") {
337 return self.resolve_secret_url(rest).await;
338 }
339
340 if SecretRef::is_secret_ref(value) {
342 return self.resolve_s_ref(value).await;
343 }
344
345 Ok(value.to_string())
347 }
348
349 async fn resolve_s_ref(&self, value: &str) -> Result<String> {
352 let secret_ref = SecretRef::parse(value).ok_or_else(|| SecretsError::InvalidName {
354 name: value.to_string(),
355 })?;
356
357 let scope = match &secret_ref.service {
359 Some(service) => format!("{}/{}", self.scope, service),
360 None => self.scope.clone(),
361 };
362
363 let secret = self.provider.get_secret(&scope, &secret_ref.name).await?;
365 let secret_value = secret.expose();
366
367 match &secret_ref.field {
369 Some(field) => Self::extract_field(secret_value, field),
370 None => Ok(secret_value.to_string()),
371 }
372 }
373
374 async fn resolve_secret_url(&self, rest: &str) -> Result<String> {
378 let (env_name, after_env) =
380 rest.split_once('/')
381 .ok_or_else(|| SecretsError::InvalidName {
382 name: format!("$secret://{rest}"),
383 })?;
384
385 if env_name.is_empty() {
386 return Err(SecretsError::InvalidName {
387 name: format!("$secret://{rest}"),
388 });
389 }
390
391 let (key, field) = match after_env.split_once('/') {
393 Some((k, f)) => (k, Some(f.to_string())),
394 None => (after_env, None),
395 };
396
397 if key.is_empty() {
398 return Err(SecretsError::InvalidName {
399 name: format!("$secret://{rest}"),
400 });
401 }
402
403 let env_resolver = self.env_resolver.as_ref().ok_or_else(|| {
404 SecretsError::Provider(
405 "SecretsResolver has no env resolver; `$secret://` not supported".to_string(),
406 )
407 })?;
408
409 let scope = env_resolver.resolve_env_scope(env_name).await?;
410 let secret = self.provider.get_secret(&scope, key).await?;
411 let secret_value = secret.expose();
412
413 match field {
414 Some(f) => Self::extract_field(secret_value, &f),
415 None => Ok(secret_value.to_string()),
416 }
417 }
418
419 #[instrument(skip(self, env), fields(scope = %self.scope, env_count = env.len()))]
441 pub async fn resolve_env(
442 &self,
443 env: &HashMap<String, String>,
444 ) -> Result<HashMap<String, String>> {
445 let mut refs_by_scope: HashMap<String, Vec<(String, SecretRef)>> = HashMap::new();
447 let mut non_secret_entries: Vec<(String, String)> = Vec::new();
448
449 for (key, value) in env {
450 if SecretRef::is_secret_ref(value) {
451 if let Some(secret_ref) = SecretRef::parse(value) {
452 let scope = match &secret_ref.service {
453 Some(service) => format!("{}/{}", self.scope, service),
454 None => self.scope.clone(),
455 };
456 refs_by_scope
457 .entry(scope)
458 .or_default()
459 .push((key.clone(), secret_ref));
460 } else {
461 return Err(SecretsError::InvalidName {
462 name: value.clone(),
463 });
464 }
465 } else {
466 non_secret_entries.push((key.clone(), value.clone()));
467 }
468 }
469
470 let mut secrets_by_scope: HashMap<String, HashMap<String, Secret>> = HashMap::new();
472
473 for (scope, refs) in &refs_by_scope {
474 let names: Vec<&str> = refs
475 .iter()
476 .map(|(_, secret_ref)| secret_ref.name.as_str())
477 .collect();
478
479 let unique_names: Vec<&str> = names
481 .iter()
482 .copied()
483 .collect::<std::collections::HashSet<_>>()
484 .into_iter()
485 .collect();
486
487 let secrets = self.provider.get_secrets(scope, &unique_names).await?;
488 secrets_by_scope.insert(scope.clone(), secrets);
489 }
490
491 let mut resolved = HashMap::with_capacity(env.len());
493
494 for (key, value) in non_secret_entries {
496 resolved.insert(key, value);
497 }
498
499 for (scope, refs) in refs_by_scope {
501 let scope_secrets = secrets_by_scope.get(&scope).ok_or_else(|| {
502 SecretsError::Provider(format!("missing secrets for scope: {scope}"))
503 })?;
504
505 for (env_key, secret_ref) in refs {
506 let secret =
507 scope_secrets
508 .get(&secret_ref.name)
509 .ok_or_else(|| SecretsError::NotFound {
510 name: secret_ref.name.clone(),
511 })?;
512
513 let value = match &secret_ref.field {
514 Some(field) => Self::extract_field(secret.expose(), field)?,
515 None => secret.expose().to_string(),
516 };
517
518 resolved.insert(env_key, value);
519 }
520 }
521
522 Ok(resolved)
523 }
524
525 fn extract_field(secret_value: &str, field: &str) -> Result<String> {
527 let json: serde_json::Value = serde_json::from_str(secret_value)
528 .map_err(|e| SecretsError::Decryption(e.to_string()))?;
529
530 match json.get(field) {
531 Some(serde_json::Value::String(s)) => Ok(s.clone()),
532 Some(serde_json::Value::Number(n)) => Ok(n.to_string()),
533 Some(serde_json::Value::Bool(b)) => Ok(b.to_string()),
534 Some(serde_json::Value::Null) => Ok(String::new()),
535 Some(v) => Ok(v.to_string()), None => Err(SecretsError::NotFound {
537 name: format!("field '{field}' in secret"),
538 }),
539 }
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use std::collections::HashMap;
547 use std::sync::Mutex;
548
549 struct MockProvider {
551 secrets: Mutex<HashMap<String, HashMap<String, Secret>>>,
552 }
553
554 impl MockProvider {
555 fn new() -> Self {
556 Self {
557 secrets: Mutex::new(HashMap::new()),
558 }
559 }
560
561 fn add_secret(&self, scope: &str, name: &str, value: &str) {
562 let mut secrets = self.secrets.lock().unwrap();
563 secrets
564 .entry(scope.to_string())
565 .or_default()
566 .insert(name.to_string(), Secret::new(value));
567 }
568 }
569
570 #[async_trait]
571 impl SecretsProvider for MockProvider {
572 async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
573 let secrets = self.secrets.lock().unwrap();
574 secrets
575 .get(scope)
576 .and_then(|s| s.get(name))
577 .cloned()
578 .ok_or_else(|| SecretsError::NotFound {
579 name: name.to_string(),
580 })
581 }
582
583 async fn get_secrets(
584 &self,
585 scope: &str,
586 names: &[&str],
587 ) -> Result<HashMap<String, Secret>> {
588 let secrets = self.secrets.lock().unwrap();
589 let scope_secrets = secrets.get(scope);
590
591 let mut result = HashMap::new();
592 if let Some(scope_secrets) = scope_secrets {
593 for name in names {
594 if let Some(secret) = scope_secrets.get(*name) {
595 result.insert((*name).to_string(), secret.clone());
596 }
597 }
598 }
599 Ok(result)
600 }
601
602 async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
603 let secrets = self.secrets.lock().unwrap();
604 Ok(secrets
605 .get(scope)
606 .map(|s| s.keys().map(SecretMetadata::new).collect())
607 .unwrap_or_default())
608 }
609
610 async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
611 let secrets = self.secrets.lock().unwrap();
612 Ok(secrets.get(scope).is_some_and(|s| s.contains_key(name)))
613 }
614 }
615
616 #[tokio::test]
617 async fn test_resolve_non_secret_value() {
618 let provider = MockProvider::new();
619 let resolver = SecretsResolver::new(provider, "test-deployment");
620
621 let result = resolver.resolve_value("plain-value").await.unwrap();
622 assert_eq!(result, "plain-value");
623 }
624
625 #[tokio::test]
626 async fn test_resolve_secret_value() {
627 let provider = MockProvider::new();
628 provider.add_secret("test-deployment", "api-key", "secret-api-key-123");
629
630 let resolver = SecretsResolver::new(provider, "test-deployment");
631
632 let result = resolver.resolve_value("$S:api-key").await.unwrap();
633 assert_eq!(result, "secret-api-key-123");
634 }
635
636 #[tokio::test]
637 async fn test_resolve_service_scoped_secret() {
638 let provider = MockProvider::new();
639 provider.add_secret("test-deployment/api", "db-password", "service-specific-pwd");
640
641 let resolver = SecretsResolver::new(provider, "test-deployment");
642
643 let result = resolver.resolve_value("$S:@api/db-password").await.unwrap();
644 assert_eq!(result, "service-specific-pwd");
645 }
646
647 #[tokio::test]
648 async fn test_resolve_secret_with_field() {
649 let provider = MockProvider::new();
650 provider.add_secret(
651 "test-deployment",
652 "database",
653 r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
654 );
655
656 let resolver = SecretsResolver::new(provider, "test-deployment");
657
658 let result = resolver
659 .resolve_value("$S:database/password")
660 .await
661 .unwrap();
662 assert_eq!(result, "db-secret");
663
664 let provider = MockProvider::new();
666 provider.add_secret(
667 "test-deployment",
668 "database",
669 r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
670 );
671 let resolver = SecretsResolver::new(provider, "test-deployment");
672
673 let result = resolver.resolve_value("$S:database/port").await.unwrap();
674 assert_eq!(result, "5432");
675 }
676
677 #[tokio::test]
678 async fn test_resolve_missing_secret() {
679 let provider = MockProvider::new();
680 let resolver = SecretsResolver::new(provider, "test-deployment");
681
682 let result = resolver.resolve_value("$S:nonexistent").await;
683 assert!(result.is_err());
684 assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
685 }
686
687 #[tokio::test]
688 async fn test_resolve_env() {
689 let provider = MockProvider::new();
690 provider.add_secret("test-deployment", "api-key", "secret-key");
691 provider.add_secret("test-deployment", "db-password", "secret-pwd");
692 provider.add_secret("test-deployment/worker", "worker-token", "worker-secret");
693
694 let resolver = SecretsResolver::new(provider, "test-deployment");
695
696 let mut env = HashMap::new();
697 env.insert("API_KEY".to_string(), "$S:api-key".to_string());
698 env.insert("DB_PASSWORD".to_string(), "$S:db-password".to_string());
699 env.insert(
700 "WORKER_TOKEN".to_string(),
701 "$S:@worker/worker-token".to_string(),
702 );
703 env.insert("PLAIN_VAR".to_string(), "plain-value".to_string());
704
705 let resolved_env = resolver.resolve_env(&env).await.unwrap();
706
707 assert_eq!(resolved_env.get("API_KEY").unwrap(), "secret-key");
708 assert_eq!(resolved_env.get("DB_PASSWORD").unwrap(), "secret-pwd");
709 assert_eq!(resolved_env.get("WORKER_TOKEN").unwrap(), "worker-secret");
710 assert_eq!(resolved_env.get("PLAIN_VAR").unwrap(), "plain-value");
711 }
712
713 #[tokio::test]
714 async fn test_resolve_env_with_missing_secret() {
715 let provider = MockProvider::new();
716 provider.add_secret("test-deployment", "exists", "value");
717
718 let resolver = SecretsResolver::new(provider, "test-deployment");
719
720 let mut env = HashMap::new();
721 env.insert("EXISTS".to_string(), "$S:exists".to_string());
722 env.insert("MISSING".to_string(), "$S:does-not-exist".to_string());
723
724 let result = resolver.resolve_env(&env).await;
725 assert!(result.is_err());
726 }
727
728 #[tokio::test]
729 async fn test_provider_exists() {
730 let provider = MockProvider::new();
731 provider.add_secret("scope", "exists", "value");
732
733 assert!(provider.exists("scope", "exists").await.unwrap());
734 assert!(!provider.exists("scope", "missing").await.unwrap());
735 assert!(!provider.exists("other-scope", "exists").await.unwrap());
736 }
737
738 #[tokio::test]
739 async fn test_provider_list_secrets() {
740 let provider = MockProvider::new();
741 provider.add_secret("scope", "secret1", "value1");
742 provider.add_secret("scope", "secret2", "value2");
743 provider.add_secret("other", "secret3", "value3");
744
745 let list = provider.list_secrets("scope").await.unwrap();
746 assert_eq!(list.len(), 2);
747
748 let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
749 assert!(names.contains(&"secret1"));
750 assert!(names.contains(&"secret2"));
751 }
752
753 #[tokio::test]
754 async fn test_resolver_accessors() {
755 let provider = MockProvider::new();
756 let resolver = SecretsResolver::new(provider, "my-scope");
757
758 assert_eq!(resolver.scope(), "my-scope");
759 let _ = resolver.provider();
761 }
762
763 type MockStoreData = Mutex<HashMap<String, HashMap<String, (Secret, u32)>>>;
766
767 struct MockStore {
768 data: MockStoreData,
770 }
771
772 impl MockStore {
773 fn new() -> Self {
774 Self {
775 data: Mutex::new(HashMap::new()),
776 }
777 }
778 }
779
780 #[async_trait]
781 impl SecretsProvider for MockStore {
782 async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
783 let data = self.data.lock().unwrap();
784 data.get(scope)
785 .and_then(|s| s.get(name))
786 .map(|(secret, _)| secret.clone())
787 .ok_or_else(|| SecretsError::NotFound {
788 name: name.to_string(),
789 })
790 }
791
792 async fn get_secrets(
793 &self,
794 scope: &str,
795 names: &[&str],
796 ) -> Result<HashMap<String, Secret>> {
797 let data = self.data.lock().unwrap();
798 let mut result = HashMap::new();
799 if let Some(scope_data) = data.get(scope) {
800 for name in names {
801 if let Some((secret, _)) = scope_data.get(*name) {
802 result.insert((*name).to_string(), secret.clone());
803 }
804 }
805 }
806 Ok(result)
807 }
808
809 async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
810 let data = self.data.lock().unwrap();
811 Ok(data
812 .get(scope)
813 .map(|s| {
814 s.iter()
815 .map(|(name, (_, version))| {
816 let mut meta = SecretMetadata::new(name);
817 meta.version = *version;
818 meta
819 })
820 .collect()
821 })
822 .unwrap_or_default())
823 }
824
825 async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
826 let data = self.data.lock().unwrap();
827 Ok(data.get(scope).is_some_and(|s| s.contains_key(name)))
828 }
829 }
830
831 #[async_trait]
832 impl SecretsStore for MockStore {
833 async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()> {
834 let mut data = self.data.lock().unwrap();
835 let scope_data = data.entry(scope.to_string()).or_default();
836 let next_version = scope_data
837 .get(name)
838 .map_or(1, |(_, version)| version.saturating_add(1));
839 scope_data.insert(name.to_string(), (value.clone(), next_version));
840 Ok(())
841 }
842
843 async fn delete_secret(&self, scope: &str, name: &str) -> Result<()> {
844 let mut data = self.data.lock().unwrap();
845 let scope_data = data.get_mut(scope).ok_or_else(|| SecretsError::NotFound {
846 name: name.to_string(),
847 })?;
848 scope_data
849 .remove(name)
850 .ok_or_else(|| SecretsError::NotFound {
851 name: name.to_string(),
852 })?;
853 Ok(())
854 }
855 }
856
857 #[tokio::test]
858 async fn test_rotate_secret_default_impl() {
859 let store = MockStore::new();
860 let scope = "test-scope";
861 let name = "test-key";
862
863 store
865 .set_secret(scope, name, &Secret::new("v1"))
866 .await
867 .unwrap();
868
869 let result = store
871 .rotate_secret(scope, name, &Secret::new("v2"))
872 .await
873 .unwrap();
874
875 assert_eq!(result.previous_version, Some(1));
876 assert_eq!(result.new_version, 2);
877
878 let current = store.get_secret(scope, name).await.unwrap();
880 assert_eq!(current.expose(), "v2");
881 }
882
883 #[tokio::test]
884 async fn test_rotate_secret_missing_returns_not_found() {
885 let store = MockStore::new();
886 let result = store
887 .rotate_secret("scope", "does-not-exist", &Secret::new("v1"))
888 .await;
889 assert!(matches!(result, Err(SecretsError::NotFound { .. })));
890 }
891
892 struct MockEnvScope {
901 map: HashMap<String, String>,
902 }
903
904 impl MockEnvScope {
905 fn new(pairs: &[(&str, &str)]) -> Self {
906 Self {
907 map: pairs
908 .iter()
909 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
910 .collect(),
911 }
912 }
913 }
914
915 #[async_trait]
916 impl EnvScopeProvider for MockEnvScope {
917 async fn resolve_env_scope(&self, name_or_id: &str) -> Result<String> {
918 self.map
919 .get(name_or_id)
920 .cloned()
921 .ok_or_else(|| SecretsError::NotFound {
922 name: format!("env:{name_or_id}"),
923 })
924 }
925 }
926
927 #[tokio::test]
928 async fn test_secret_url_resolves_via_env_resolver() {
929 let provider = MockProvider::new();
930 provider.add_secret("env:abc", "PWD", "xyz");
931
932 let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
933
934 let resolver =
935 SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
936
937 let result = resolver
938 .resolve_value("$secret://bootstrap/PWD")
939 .await
940 .unwrap();
941 assert_eq!(result, "xyz");
942 }
943
944 #[tokio::test]
945 async fn test_secret_url_without_env_resolver_errors() {
946 let provider = MockProvider::new();
947 provider.add_secret("env:abc", "PWD", "xyz");
948
949 let resolver = SecretsResolver::new(provider, "ignored-scope");
951
952 let err = resolver
953 .resolve_value("$secret://bootstrap/PWD")
954 .await
955 .unwrap_err();
956
957 match err {
958 SecretsError::Provider(msg) => {
959 assert!(
960 msg.contains("$secret://"),
961 "expected error to mention `$secret://`, got: {msg}"
962 );
963 }
964 other => panic!("expected SecretsError::Provider, got {other:?}"),
965 }
966 }
967
968 #[tokio::test]
969 async fn test_secret_url_with_json_field_extraction() {
970 let provider = MockProvider::new();
971 provider.add_secret(
972 "env:abc",
973 "database",
974 r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
975 );
976
977 let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
978
979 let resolver =
980 SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
981
982 let pwd = resolver
984 .resolve_value("$secret://bootstrap/database/password")
985 .await
986 .unwrap();
987 assert_eq!(pwd, "db-secret");
988
989 let port = resolver
991 .resolve_value("$secret://bootstrap/database/port")
992 .await
993 .unwrap();
994 assert_eq!(port, "5432");
995 }
996
997 #[tokio::test]
998 async fn test_secret_url_malformed_missing_key_errors() {
999 let provider = MockProvider::new();
1000 let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
1001 let resolver =
1002 SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
1003
1004 let err = resolver
1006 .resolve_value("$secret://bootstrap")
1007 .await
1008 .unwrap_err();
1009 assert!(matches!(err, SecretsError::InvalidName { .. }));
1010
1011 let err = resolver
1013 .resolve_value("$secret://bootstrap/")
1014 .await
1015 .unwrap_err();
1016 assert!(matches!(err, SecretsError::InvalidName { .. }));
1017 }
1018
1019 #[tokio::test]
1020 async fn test_secret_url_unknown_env_propagates_not_found() {
1021 let provider = MockProvider::new();
1022 let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
1023 let resolver =
1024 SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
1025
1026 let err = resolver
1027 .resolve_value("$secret://unknown-env/PWD")
1028 .await
1029 .unwrap_err();
1030 assert!(matches!(err, SecretsError::NotFound { .. }));
1031 }
1032}