1use async_trait::async_trait;
10use std::collections::HashMap;
11use tracing::instrument;
12
13use zlayer_types::storage::NodeAffinity;
14
15use crate::{Result, RotationResult, Secret, SecretMetadata, SecretRef, SecretsError};
16
17#[async_trait]
37pub trait SecretsProvider: Send + Sync {
38 async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret>;
50
51 async fn get_secrets(&self, scope: &str, names: &[&str]) -> Result<HashMap<String, Secret>>;
67
68 async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>>;
77
78 async fn exists(&self, scope: &str, name: &str) -> Result<bool>;
88}
89
90#[async_trait]
106pub trait SecretsStore: SecretsProvider {
107 async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()>;
122
123 async fn delete_secret(&self, scope: &str, name: &str) -> Result<()>;
135
136 async fn rotate_secret(
152 &self,
153 scope: &str,
154 name: &str,
155 value: &Secret,
156 ) -> Result<RotationResult> {
157 let previous_version = self
158 .list_secrets(scope)
159 .await?
160 .into_iter()
161 .find(|m| m.name == name)
162 .map(|m| m.version);
163 if previous_version.is_none() {
164 return Err(SecretsError::NotFound {
165 name: name.to_string(),
166 });
167 }
168 self.set_secret(scope, name, value).await?;
169 let new_version = self
170 .list_secrets(scope)
171 .await?
172 .into_iter()
173 .find(|m| m.name == name)
174 .map(|m| m.version)
175 .ok_or_else(|| SecretsError::NotFound {
176 name: name.to_string(),
177 })?;
178 Ok(RotationResult {
179 previous_version,
180 new_version,
181 })
182 }
183
184 async fn set_secret_with_affinity(
199 &self,
200 scope: &str,
201 name: &str,
202 value: &Secret,
203 _node_affinity: Option<&NodeAffinity>,
204 ) -> Result<()> {
205 self.set_secret(scope, name, value).await
206 }
207
208 async fn rotate_secret_with_affinity(
218 &self,
219 scope: &str,
220 name: &str,
221 value: &Secret,
222 _node_affinity: Option<&NodeAffinity>,
223 ) -> Result<RotationResult> {
224 self.rotate_secret(scope, name, value).await
225 }
226}
227
228#[async_trait]
233impl<T: SecretsProvider + ?Sized> SecretsProvider for std::sync::Arc<T> {
234 async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
235 (**self).get_secret(scope, name).await
236 }
237
238 async fn get_secrets(&self, scope: &str, names: &[&str]) -> Result<HashMap<String, Secret>> {
239 (**self).get_secrets(scope, names).await
240 }
241
242 async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
243 (**self).list_secrets(scope).await
244 }
245
246 async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
247 (**self).exists(scope, name).await
248 }
249}
250
251#[async_trait]
252impl<T: SecretsStore + ?Sized> SecretsStore for std::sync::Arc<T> {
253 async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()> {
254 (**self).set_secret(scope, name, value).await
255 }
256
257 async fn delete_secret(&self, scope: &str, name: &str) -> Result<()> {
258 (**self).delete_secret(scope, name).await
259 }
260
261 async fn rotate_secret(
262 &self,
263 scope: &str,
264 name: &str,
265 value: &Secret,
266 ) -> Result<RotationResult> {
267 (**self).rotate_secret(scope, name, value).await
268 }
269
270 async fn set_secret_with_affinity(
271 &self,
272 scope: &str,
273 name: &str,
274 value: &Secret,
275 node_affinity: Option<&NodeAffinity>,
276 ) -> Result<()> {
277 (**self)
278 .set_secret_with_affinity(scope, name, value, node_affinity)
279 .await
280 }
281
282 async fn rotate_secret_with_affinity(
283 &self,
284 scope: &str,
285 name: &str,
286 value: &Secret,
287 node_affinity: Option<&NodeAffinity>,
288 ) -> Result<RotationResult> {
289 (**self)
290 .rotate_secret_with_affinity(scope, name, value, node_affinity)
291 .await
292 }
293}
294
295#[async_trait]
305pub trait EnvScopeProvider: Send + Sync {
306 async fn resolve_env_scope(&self, name_or_id: &str) -> Result<String>;
312}
313
314pub struct SecretsResolver<P: SecretsProvider> {
347 provider: P,
348 scope: String,
349 env_resolver: Option<std::sync::Arc<dyn EnvScopeProvider>>,
350}
351
352impl<P: SecretsProvider> SecretsResolver<P> {
353 pub fn new(provider: P, scope: impl Into<String>) -> Self {
360 Self {
361 provider,
362 scope: scope.into(),
363 env_resolver: None,
364 }
365 }
366
367 #[must_use]
372 pub fn with_env_resolver(mut self, env_resolver: std::sync::Arc<dyn EnvScopeProvider>) -> Self {
373 self.env_resolver = Some(env_resolver);
374 self
375 }
376
377 pub fn provider(&self) -> &P {
379 &self.provider
380 }
381
382 pub fn scope(&self) -> &str {
384 &self.scope
385 }
386
387 #[instrument(skip(self), fields(scope = %self.scope))]
403 pub async fn resolve_value(&self, value: &str) -> Result<String> {
404 if let Some(rest) = value.strip_prefix("$secret://") {
406 return self.resolve_secret_url(rest).await;
407 }
408
409 if SecretRef::is_secret_ref(value) {
411 return self.resolve_s_ref(value).await;
412 }
413
414 Ok(value.to_string())
416 }
417
418 async fn resolve_s_ref(&self, value: &str) -> Result<String> {
421 let secret_ref = SecretRef::parse(value).ok_or_else(|| SecretsError::InvalidName {
423 name: value.to_string(),
424 })?;
425
426 let scope = match &secret_ref.service {
428 Some(service) => format!("{}/{}", self.scope, service),
429 None => self.scope.clone(),
430 };
431
432 let secret = self.provider.get_secret(&scope, &secret_ref.name).await?;
434 let secret_value = secret.expose();
435
436 match &secret_ref.field {
438 Some(field) => Self::extract_field(secret_value, field),
439 None => Ok(secret_value.to_string()),
440 }
441 }
442
443 async fn resolve_secret_url(&self, rest: &str) -> Result<String> {
447 let (env_name, after_env) =
449 rest.split_once('/')
450 .ok_or_else(|| SecretsError::InvalidName {
451 name: format!("$secret://{rest}"),
452 })?;
453
454 if env_name.is_empty() {
455 return Err(SecretsError::InvalidName {
456 name: format!("$secret://{rest}"),
457 });
458 }
459
460 let (key, field) = match after_env.split_once('/') {
462 Some((k, f)) => (k, Some(f.to_string())),
463 None => (after_env, None),
464 };
465
466 if key.is_empty() {
467 return Err(SecretsError::InvalidName {
468 name: format!("$secret://{rest}"),
469 });
470 }
471
472 let env_resolver = self.env_resolver.as_ref().ok_or_else(|| {
473 SecretsError::Provider(
474 "SecretsResolver has no env resolver; `$secret://` not supported".to_string(),
475 )
476 })?;
477
478 let scope = env_resolver.resolve_env_scope(env_name).await?;
479 let secret = self.provider.get_secret(&scope, key).await?;
480 let secret_value = secret.expose();
481
482 match field {
483 Some(f) => Self::extract_field(secret_value, &f),
484 None => Ok(secret_value.to_string()),
485 }
486 }
487
488 #[instrument(skip(self, env), fields(scope = %self.scope, env_count = env.len()))]
510 pub async fn resolve_env(
511 &self,
512 env: &HashMap<String, String>,
513 ) -> Result<HashMap<String, String>> {
514 let mut refs_by_scope: HashMap<String, Vec<(String, SecretRef)>> = HashMap::new();
516 let mut non_secret_entries: Vec<(String, String)> = Vec::new();
517
518 for (key, value) in env {
519 if SecretRef::is_secret_ref(value) {
520 if let Some(secret_ref) = SecretRef::parse(value) {
521 let scope = match &secret_ref.service {
522 Some(service) => format!("{}/{}", self.scope, service),
523 None => self.scope.clone(),
524 };
525 refs_by_scope
526 .entry(scope)
527 .or_default()
528 .push((key.clone(), secret_ref));
529 } else {
530 return Err(SecretsError::InvalidName {
531 name: value.clone(),
532 });
533 }
534 } else {
535 non_secret_entries.push((key.clone(), value.clone()));
536 }
537 }
538
539 let mut secrets_by_scope: HashMap<String, HashMap<String, Secret>> = HashMap::new();
541
542 for (scope, refs) in &refs_by_scope {
543 let names: Vec<&str> = refs
544 .iter()
545 .map(|(_, secret_ref)| secret_ref.name.as_str())
546 .collect();
547
548 let unique_names: Vec<&str> = names
550 .iter()
551 .copied()
552 .collect::<std::collections::HashSet<_>>()
553 .into_iter()
554 .collect();
555
556 let secrets = self.provider.get_secrets(scope, &unique_names).await?;
557 secrets_by_scope.insert(scope.clone(), secrets);
558 }
559
560 let mut resolved = HashMap::with_capacity(env.len());
562
563 for (key, value) in non_secret_entries {
565 resolved.insert(key, value);
566 }
567
568 for (scope, refs) in refs_by_scope {
570 let scope_secrets = secrets_by_scope.get(&scope).ok_or_else(|| {
571 SecretsError::Provider(format!("missing secrets for scope: {scope}"))
572 })?;
573
574 for (env_key, secret_ref) in refs {
575 let secret =
576 scope_secrets
577 .get(&secret_ref.name)
578 .ok_or_else(|| SecretsError::NotFound {
579 name: secret_ref.name.clone(),
580 })?;
581
582 let value = match &secret_ref.field {
583 Some(field) => Self::extract_field(secret.expose(), field)?,
584 None => secret.expose().to_string(),
585 };
586
587 resolved.insert(env_key, value);
588 }
589 }
590
591 Ok(resolved)
592 }
593
594 fn extract_field(secret_value: &str, field: &str) -> Result<String> {
596 let json: serde_json::Value = serde_json::from_str(secret_value)
597 .map_err(|e| SecretsError::Decryption(e.to_string()))?;
598
599 match json.get(field) {
600 Some(serde_json::Value::String(s)) => Ok(s.clone()),
601 Some(serde_json::Value::Number(n)) => Ok(n.to_string()),
602 Some(serde_json::Value::Bool(b)) => Ok(b.to_string()),
603 Some(serde_json::Value::Null) => Ok(String::new()),
604 Some(v) => Ok(v.to_string()), None => Err(SecretsError::NotFound {
606 name: format!("field '{field}' in secret"),
607 }),
608 }
609 }
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615 use std::collections::HashMap;
616 use std::sync::Mutex;
617
618 struct MockProvider {
620 secrets: Mutex<HashMap<String, HashMap<String, Secret>>>,
621 }
622
623 impl MockProvider {
624 fn new() -> Self {
625 Self {
626 secrets: Mutex::new(HashMap::new()),
627 }
628 }
629
630 fn add_secret(&self, scope: &str, name: &str, value: &str) {
631 let mut secrets = self.secrets.lock().unwrap();
632 secrets
633 .entry(scope.to_string())
634 .or_default()
635 .insert(name.to_string(), Secret::new(value));
636 }
637 }
638
639 #[async_trait]
640 impl SecretsProvider for MockProvider {
641 async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
642 let secrets = self.secrets.lock().unwrap();
643 secrets
644 .get(scope)
645 .and_then(|s| s.get(name))
646 .cloned()
647 .ok_or_else(|| SecretsError::NotFound {
648 name: name.to_string(),
649 })
650 }
651
652 async fn get_secrets(
653 &self,
654 scope: &str,
655 names: &[&str],
656 ) -> Result<HashMap<String, Secret>> {
657 let secrets = self.secrets.lock().unwrap();
658 let scope_secrets = secrets.get(scope);
659
660 let mut result = HashMap::new();
661 if let Some(scope_secrets) = scope_secrets {
662 for name in names {
663 if let Some(secret) = scope_secrets.get(*name) {
664 result.insert((*name).to_string(), secret.clone());
665 }
666 }
667 }
668 Ok(result)
669 }
670
671 async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
672 let secrets = self.secrets.lock().unwrap();
673 Ok(secrets
674 .get(scope)
675 .map(|s| s.keys().map(SecretMetadata::new).collect())
676 .unwrap_or_default())
677 }
678
679 async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
680 let secrets = self.secrets.lock().unwrap();
681 Ok(secrets.get(scope).is_some_and(|s| s.contains_key(name)))
682 }
683 }
684
685 #[tokio::test]
686 async fn test_resolve_non_secret_value() {
687 let provider = MockProvider::new();
688 let resolver = SecretsResolver::new(provider, "test-deployment");
689
690 let result = resolver.resolve_value("plain-value").await.unwrap();
691 assert_eq!(result, "plain-value");
692 }
693
694 #[tokio::test]
695 async fn test_resolve_secret_value() {
696 let provider = MockProvider::new();
697 provider.add_secret("test-deployment", "api-key", "secret-api-key-123");
698
699 let resolver = SecretsResolver::new(provider, "test-deployment");
700
701 let result = resolver.resolve_value("$S:api-key").await.unwrap();
702 assert_eq!(result, "secret-api-key-123");
703 }
704
705 #[tokio::test]
706 async fn test_resolve_service_scoped_secret() {
707 let provider = MockProvider::new();
708 provider.add_secret("test-deployment/api", "db-password", "service-specific-pwd");
709
710 let resolver = SecretsResolver::new(provider, "test-deployment");
711
712 let result = resolver.resolve_value("$S:@api/db-password").await.unwrap();
713 assert_eq!(result, "service-specific-pwd");
714 }
715
716 #[tokio::test]
717 async fn test_resolve_secret_with_field() {
718 let provider = MockProvider::new();
719 provider.add_secret(
720 "test-deployment",
721 "database",
722 r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
723 );
724
725 let resolver = SecretsResolver::new(provider, "test-deployment");
726
727 let result = resolver
728 .resolve_value("$S:database/password")
729 .await
730 .unwrap();
731 assert_eq!(result, "db-secret");
732
733 let provider = MockProvider::new();
735 provider.add_secret(
736 "test-deployment",
737 "database",
738 r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
739 );
740 let resolver = SecretsResolver::new(provider, "test-deployment");
741
742 let result = resolver.resolve_value("$S:database/port").await.unwrap();
743 assert_eq!(result, "5432");
744 }
745
746 #[tokio::test]
747 async fn test_resolve_missing_secret() {
748 let provider = MockProvider::new();
749 let resolver = SecretsResolver::new(provider, "test-deployment");
750
751 let result = resolver.resolve_value("$S:nonexistent").await;
752 assert!(result.is_err());
753 assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
754 }
755
756 #[tokio::test]
757 async fn test_resolve_env() {
758 let provider = MockProvider::new();
759 provider.add_secret("test-deployment", "api-key", "secret-key");
760 provider.add_secret("test-deployment", "db-password", "secret-pwd");
761 provider.add_secret("test-deployment/worker", "worker-token", "worker-secret");
762
763 let resolver = SecretsResolver::new(provider, "test-deployment");
764
765 let mut env = HashMap::new();
766 env.insert("API_KEY".to_string(), "$S:api-key".to_string());
767 env.insert("DB_PASSWORD".to_string(), "$S:db-password".to_string());
768 env.insert(
769 "WORKER_TOKEN".to_string(),
770 "$S:@worker/worker-token".to_string(),
771 );
772 env.insert("PLAIN_VAR".to_string(), "plain-value".to_string());
773
774 let resolved_env = resolver.resolve_env(&env).await.unwrap();
775
776 assert_eq!(resolved_env.get("API_KEY").unwrap(), "secret-key");
777 assert_eq!(resolved_env.get("DB_PASSWORD").unwrap(), "secret-pwd");
778 assert_eq!(resolved_env.get("WORKER_TOKEN").unwrap(), "worker-secret");
779 assert_eq!(resolved_env.get("PLAIN_VAR").unwrap(), "plain-value");
780 }
781
782 #[tokio::test]
783 async fn test_resolve_env_with_missing_secret() {
784 let provider = MockProvider::new();
785 provider.add_secret("test-deployment", "exists", "value");
786
787 let resolver = SecretsResolver::new(provider, "test-deployment");
788
789 let mut env = HashMap::new();
790 env.insert("EXISTS".to_string(), "$S:exists".to_string());
791 env.insert("MISSING".to_string(), "$S:does-not-exist".to_string());
792
793 let result = resolver.resolve_env(&env).await;
794 assert!(result.is_err());
795 }
796
797 #[tokio::test]
798 async fn test_provider_exists() {
799 let provider = MockProvider::new();
800 provider.add_secret("scope", "exists", "value");
801
802 assert!(provider.exists("scope", "exists").await.unwrap());
803 assert!(!provider.exists("scope", "missing").await.unwrap());
804 assert!(!provider.exists("other-scope", "exists").await.unwrap());
805 }
806
807 #[tokio::test]
808 async fn test_provider_list_secrets() {
809 let provider = MockProvider::new();
810 provider.add_secret("scope", "secret1", "value1");
811 provider.add_secret("scope", "secret2", "value2");
812 provider.add_secret("other", "secret3", "value3");
813
814 let list = provider.list_secrets("scope").await.unwrap();
815 assert_eq!(list.len(), 2);
816
817 let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
818 assert!(names.contains(&"secret1"));
819 assert!(names.contains(&"secret2"));
820 }
821
822 #[tokio::test]
823 async fn test_resolver_accessors() {
824 let provider = MockProvider::new();
825 let resolver = SecretsResolver::new(provider, "my-scope");
826
827 assert_eq!(resolver.scope(), "my-scope");
828 let _ = resolver.provider();
830 }
831
832 type MockStoreData = Mutex<HashMap<String, HashMap<String, (Secret, u32)>>>;
835
836 struct MockStore {
837 data: MockStoreData,
839 }
840
841 impl MockStore {
842 fn new() -> Self {
843 Self {
844 data: Mutex::new(HashMap::new()),
845 }
846 }
847 }
848
849 #[async_trait]
850 impl SecretsProvider for MockStore {
851 async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
852 let data = self.data.lock().unwrap();
853 data.get(scope)
854 .and_then(|s| s.get(name))
855 .map(|(secret, _)| secret.clone())
856 .ok_or_else(|| SecretsError::NotFound {
857 name: name.to_string(),
858 })
859 }
860
861 async fn get_secrets(
862 &self,
863 scope: &str,
864 names: &[&str],
865 ) -> Result<HashMap<String, Secret>> {
866 let data = self.data.lock().unwrap();
867 let mut result = HashMap::new();
868 if let Some(scope_data) = data.get(scope) {
869 for name in names {
870 if let Some((secret, _)) = scope_data.get(*name) {
871 result.insert((*name).to_string(), secret.clone());
872 }
873 }
874 }
875 Ok(result)
876 }
877
878 async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
879 let data = self.data.lock().unwrap();
880 Ok(data
881 .get(scope)
882 .map(|s| {
883 s.iter()
884 .map(|(name, (_, version))| {
885 let mut meta = SecretMetadata::new(name);
886 meta.version = *version;
887 meta
888 })
889 .collect()
890 })
891 .unwrap_or_default())
892 }
893
894 async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
895 let data = self.data.lock().unwrap();
896 Ok(data.get(scope).is_some_and(|s| s.contains_key(name)))
897 }
898 }
899
900 #[async_trait]
901 impl SecretsStore for MockStore {
902 async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()> {
903 let mut data = self.data.lock().unwrap();
904 let scope_data = data.entry(scope.to_string()).or_default();
905 let next_version = scope_data
906 .get(name)
907 .map_or(1, |(_, version)| version.saturating_add(1));
908 scope_data.insert(name.to_string(), (value.clone(), next_version));
909 Ok(())
910 }
911
912 async fn delete_secret(&self, scope: &str, name: &str) -> Result<()> {
913 let mut data = self.data.lock().unwrap();
914 let scope_data = data.get_mut(scope).ok_or_else(|| SecretsError::NotFound {
915 name: name.to_string(),
916 })?;
917 scope_data
918 .remove(name)
919 .ok_or_else(|| SecretsError::NotFound {
920 name: name.to_string(),
921 })?;
922 Ok(())
923 }
924 }
925
926 #[tokio::test]
927 async fn test_rotate_secret_default_impl() {
928 let store = MockStore::new();
929 let scope = "test-scope";
930 let name = "test-key";
931
932 store
934 .set_secret(scope, name, &Secret::new("v1"))
935 .await
936 .unwrap();
937
938 let result = store
940 .rotate_secret(scope, name, &Secret::new("v2"))
941 .await
942 .unwrap();
943
944 assert_eq!(result.previous_version, Some(1));
945 assert_eq!(result.new_version, 2);
946
947 let current = store.get_secret(scope, name).await.unwrap();
949 assert_eq!(current.expose(), "v2");
950 }
951
952 #[tokio::test]
953 async fn test_rotate_secret_missing_returns_not_found() {
954 let store = MockStore::new();
955 let result = store
956 .rotate_secret("scope", "does-not-exist", &Secret::new("v1"))
957 .await;
958 assert!(matches!(result, Err(SecretsError::NotFound { .. })));
959 }
960
961 struct MockEnvScope {
970 map: HashMap<String, String>,
971 }
972
973 impl MockEnvScope {
974 fn new(pairs: &[(&str, &str)]) -> Self {
975 Self {
976 map: pairs
977 .iter()
978 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
979 .collect(),
980 }
981 }
982 }
983
984 #[async_trait]
985 impl EnvScopeProvider for MockEnvScope {
986 async fn resolve_env_scope(&self, name_or_id: &str) -> Result<String> {
987 self.map
988 .get(name_or_id)
989 .cloned()
990 .ok_or_else(|| SecretsError::NotFound {
991 name: format!("env:{name_or_id}"),
992 })
993 }
994 }
995
996 #[tokio::test]
997 async fn test_secret_url_resolves_via_env_resolver() {
998 let provider = MockProvider::new();
999 provider.add_secret("env:abc", "PWD", "xyz");
1000
1001 let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
1002
1003 let resolver =
1004 SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
1005
1006 let result = resolver
1007 .resolve_value("$secret://bootstrap/PWD")
1008 .await
1009 .unwrap();
1010 assert_eq!(result, "xyz");
1011 }
1012
1013 #[tokio::test]
1014 async fn test_secret_url_without_env_resolver_errors() {
1015 let provider = MockProvider::new();
1016 provider.add_secret("env:abc", "PWD", "xyz");
1017
1018 let resolver = SecretsResolver::new(provider, "ignored-scope");
1020
1021 let err = resolver
1022 .resolve_value("$secret://bootstrap/PWD")
1023 .await
1024 .unwrap_err();
1025
1026 match err {
1027 SecretsError::Provider(msg) => {
1028 assert!(
1029 msg.contains("$secret://"),
1030 "expected error to mention `$secret://`, got: {msg}"
1031 );
1032 }
1033 other => panic!("expected SecretsError::Provider, got {other:?}"),
1034 }
1035 }
1036
1037 #[tokio::test]
1038 async fn test_secret_url_with_json_field_extraction() {
1039 let provider = MockProvider::new();
1040 provider.add_secret(
1041 "env:abc",
1042 "database",
1043 r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
1044 );
1045
1046 let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
1047
1048 let resolver =
1049 SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
1050
1051 let pwd = resolver
1053 .resolve_value("$secret://bootstrap/database/password")
1054 .await
1055 .unwrap();
1056 assert_eq!(pwd, "db-secret");
1057
1058 let port = resolver
1060 .resolve_value("$secret://bootstrap/database/port")
1061 .await
1062 .unwrap();
1063 assert_eq!(port, "5432");
1064 }
1065
1066 #[tokio::test]
1067 async fn test_secret_url_malformed_missing_key_errors() {
1068 let provider = MockProvider::new();
1069 let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
1070 let resolver =
1071 SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
1072
1073 let err = resolver
1075 .resolve_value("$secret://bootstrap")
1076 .await
1077 .unwrap_err();
1078 assert!(matches!(err, SecretsError::InvalidName { .. }));
1079
1080 let err = resolver
1082 .resolve_value("$secret://bootstrap/")
1083 .await
1084 .unwrap_err();
1085 assert!(matches!(err, SecretsError::InvalidName { .. }));
1086 }
1087
1088 #[tokio::test]
1089 async fn test_secret_url_unknown_env_propagates_not_found() {
1090 let provider = MockProvider::new();
1091 let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
1092 let resolver =
1093 SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
1094
1095 let err = resolver
1096 .resolve_value("$secret://unknown-env/PWD")
1097 .await
1098 .unwrap_err();
1099 assert!(matches!(err, SecretsError::NotFound { .. }));
1100 }
1101}