1use std::collections::HashMap;
10use thiserror::Error;
11use zlayer_secrets::{SecretRef, SecretsError, SecretsProvider};
12
13const ENV_REF_PREFIX: &str = "$E:";
15
16const SECRET_REF_PREFIX: &str = "$S:";
18
19#[derive(Error, Debug, Clone)]
21pub enum EnvResolutionError {
22 #[error("environment variable '{var}' referenced by $E:{var} is not set")]
23 MissingEnvVar { var: String },
24
25 #[error("secret '{name}' referenced by $S:{name} was not found")]
26 SecretNotFound { name: String },
27
28 #[error("secret resolution error: {message}")]
29 SecretResolution { message: String },
30}
31
32pub struct ResolvedEnv {
34 pub vars: Vec<String>,
36 pub warnings: Vec<String>,
38}
39
40pub fn resolve_env_value(value: &str) -> Result<String, EnvResolutionError> {
55 if let Some(var_name) = value.strip_prefix(ENV_REF_PREFIX) {
56 match std::env::var(var_name) {
57 Ok(val) => Ok(val),
58 Err(std::env::VarError::NotPresent | std::env::VarError::NotUnicode(_)) => {
59 Err(EnvResolutionError::MissingEnvVar {
60 var: var_name.to_string(),
61 })
62 }
63 }
64 } else {
65 Ok(value.to_string())
66 }
67}
68
69#[allow(clippy::implicit_hasher)]
103pub fn resolve_env_vars(
104 env: &HashMap<String, String>,
105) -> Result<HashMap<String, String>, EnvResolutionError> {
106 let mut resolved = HashMap::with_capacity(env.len());
107
108 for (key, value) in env {
109 let resolved_value = resolve_env_value(value)?;
110 resolved.insert(key.clone(), resolved_value);
111 }
112
113 Ok(resolved)
114}
115
116#[allow(clippy::implicit_hasher)]
130pub fn resolve_env_vars_with_warnings(
131 env: &HashMap<String, String>,
132) -> Result<ResolvedEnv, EnvResolutionError> {
133 let mut vars = Vec::with_capacity(env.len());
134 let mut warnings = Vec::new();
135
136 for (key, value) in env {
137 let resolved_value = if let Some(var_name) = value.strip_prefix(ENV_REF_PREFIX) {
138 match std::env::var(var_name) {
139 Ok(val) => {
140 if val.is_empty() {
141 warnings.push(format!(
142 "environment variable '{var_name}' is set but empty"
143 ));
144 }
145 val
146 }
147 Err(std::env::VarError::NotPresent | std::env::VarError::NotUnicode(_)) => {
148 return Err(EnvResolutionError::MissingEnvVar {
149 var: var_name.to_string(),
150 });
151 }
152 }
153 } else {
154 value.clone()
155 };
156
157 vars.push(format!("{key}={resolved_value}"));
158 }
159
160 Ok(ResolvedEnv { vars, warnings })
161}
162
163#[must_use]
167#[allow(clippy::implicit_hasher)]
168pub fn has_env_references(env: &HashMap<String, String>) -> bool {
169 env.values().any(|v| v.starts_with(ENV_REF_PREFIX))
170}
171
172#[must_use]
176#[allow(clippy::implicit_hasher)]
177pub fn get_env_references(env: &HashMap<String, String>) -> Vec<&str> {
178 env.values()
179 .filter_map(|v| v.strip_prefix(ENV_REF_PREFIX))
180 .collect()
181}
182
183#[must_use]
187#[allow(clippy::implicit_hasher)]
188pub fn has_secret_references(env: &HashMap<String, String>) -> bool {
189 env.values().any(|v| v.starts_with(SECRET_REF_PREFIX))
190}
191
192#[must_use]
196#[allow(clippy::implicit_hasher)]
197pub fn get_secret_references(env: &HashMap<String, String>) -> Vec<&str> {
198 env.values()
199 .filter_map(|v| v.strip_prefix(SECRET_REF_PREFIX))
200 .collect()
201}
202
203#[allow(clippy::implicit_hasher)]
238pub async fn resolve_env_with_secrets<P: SecretsProvider + ?Sized>(
239 env: &HashMap<String, String>,
240 secrets_provider: &P,
241 scope: &str,
242) -> Result<HashMap<String, String>, EnvResolutionError> {
243 let mut resolved = HashMap::with_capacity(env.len());
244
245 for (key, value) in env {
246 let resolved_value = resolve_value_with_secrets(value, secrets_provider, scope).await?;
247 resolved.insert(key.clone(), resolved_value);
248 }
249
250 Ok(resolved)
251}
252
253pub async fn resolve_value_with_secrets<P: SecretsProvider + ?Sized>(
271 value: &str,
272 secrets_provider: &P,
273 scope: &str,
274) -> Result<String, EnvResolutionError> {
275 if SecretRef::is_secret_ref(value) {
277 let secret_ref =
278 SecretRef::parse(value).ok_or_else(|| EnvResolutionError::SecretResolution {
279 message: format!("invalid secret reference syntax: {value}"),
280 })?;
281
282 let effective_scope = match &secret_ref.service {
284 Some(service) => format!("{scope}/{service}"),
285 None => scope.to_string(),
286 };
287
288 let secret = secrets_provider
290 .get_secret(&effective_scope, &secret_ref.name)
291 .await
292 .map_err(|e| match e {
293 SecretsError::NotFound { name } => EnvResolutionError::SecretNotFound { name },
294 other => EnvResolutionError::SecretResolution {
295 message: other.to_string(),
296 },
297 })?;
298
299 let secret_value = secret.expose();
300
301 match &secret_ref.field {
303 Some(field) => extract_json_field(secret_value, field),
304 None => Ok(secret_value.to_string()),
305 }
306 } else if let Some(var_name) = value.strip_prefix(ENV_REF_PREFIX) {
307 match std::env::var(var_name) {
309 Ok(val) => Ok(val),
310 Err(std::env::VarError::NotPresent | std::env::VarError::NotUnicode(_)) => {
311 Err(EnvResolutionError::MissingEnvVar {
312 var: var_name.to_string(),
313 })
314 }
315 }
316 } else {
317 Ok(value.to_string())
319 }
320}
321
322fn extract_json_field(secret_value: &str, field: &str) -> Result<String, EnvResolutionError> {
324 let json: serde_json::Value =
325 serde_json::from_str(secret_value).map_err(|e| EnvResolutionError::SecretResolution {
326 message: format!("failed to parse secret as JSON: {e}"),
327 })?;
328
329 match json.get(field) {
330 Some(serde_json::Value::String(s)) => Ok(s.clone()),
331 Some(serde_json::Value::Number(n)) => Ok(n.to_string()),
332 Some(serde_json::Value::Bool(b)) => Ok(b.to_string()),
333 Some(serde_json::Value::Null) => Ok(String::new()),
334 Some(v) => Ok(v.to_string()), None => Err(EnvResolutionError::SecretNotFound {
336 name: format!("field '{field}' in secret"),
337 }),
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn test_resolve_env_value_plain() {
347 let result = resolve_env_value("plain_value").unwrap();
348 assert_eq!(result, "plain_value");
349 }
350
351 #[test]
352 fn test_resolve_env_value_reference() {
353 std::env::set_var("TEST_RESOLVE_SINGLE", "resolved_value");
354
355 let result = resolve_env_value("$E:TEST_RESOLVE_SINGLE").unwrap();
356 assert_eq!(result, "resolved_value");
357
358 std::env::remove_var("TEST_RESOLVE_SINGLE");
359 }
360
361 #[test]
362 fn test_resolve_env_value_missing() {
363 let result = resolve_env_value("$E:DEFINITELY_NOT_SET_SINGLE_12345");
364
365 assert!(result.is_err());
366 match result {
367 Err(EnvResolutionError::MissingEnvVar { var }) => {
368 assert_eq!(var, "DEFINITELY_NOT_SET_SINGLE_12345");
369 }
370 _ => panic!("Expected MissingEnvVar error"),
371 }
372 }
373
374 #[test]
375 fn test_resolve_plain_vars() {
376 let mut env = HashMap::new();
377 env.insert("NODE_ENV".to_string(), "production".to_string());
378 env.insert("PORT".to_string(), "8080".to_string());
379
380 let result = resolve_env_vars(&env).unwrap();
381
382 assert_eq!(result.get("NODE_ENV").unwrap(), "production");
383 assert_eq!(result.get("PORT").unwrap(), "8080");
384 }
385
386 #[test]
387 fn test_resolve_env_reference() {
388 std::env::set_var("TEST_RESOLVE_VAR", "test_value");
389
390 let mut env = HashMap::new();
391 env.insert("MY_VAR".to_string(), "$E:TEST_RESOLVE_VAR".to_string());
392
393 let result = resolve_env_vars(&env).unwrap();
394
395 assert_eq!(result.get("MY_VAR").unwrap(), "test_value");
396
397 std::env::remove_var("TEST_RESOLVE_VAR");
398 }
399
400 #[test]
401 fn test_missing_env_reference_fails() {
402 let mut env = HashMap::new();
403 env.insert(
404 "MY_VAR".to_string(),
405 "$E:DEFINITELY_NOT_SET_12345".to_string(),
406 );
407
408 let result = resolve_env_vars(&env);
409
410 assert!(result.is_err());
411 match result {
412 Err(EnvResolutionError::MissingEnvVar { var }) => {
413 assert_eq!(var, "DEFINITELY_NOT_SET_12345");
414 }
415 _ => panic!("Expected MissingEnvVar error"),
416 }
417 }
418
419 #[test]
420 fn test_mixed_vars() {
421 std::env::set_var("TEST_DB_URL", "postgres://localhost/test");
422
423 let mut env = HashMap::new();
424 env.insert("NODE_ENV".to_string(), "production".to_string());
425 env.insert("DATABASE_URL".to_string(), "$E:TEST_DB_URL".to_string());
426
427 let result = resolve_env_vars(&env).unwrap();
428
429 assert_eq!(result.len(), 2);
430 assert_eq!(result.get("NODE_ENV").unwrap(), "production");
431 assert_eq!(
432 result.get("DATABASE_URL").unwrap(),
433 "postgres://localhost/test"
434 );
435
436 std::env::remove_var("TEST_DB_URL");
437 }
438
439 #[test]
440 fn test_resolve_with_warnings_empty_value() {
441 std::env::set_var("TEST_EMPTY_VAR", "");
442
443 let mut env = HashMap::new();
444 env.insert("EMPTY".to_string(), "$E:TEST_EMPTY_VAR".to_string());
445
446 let result = resolve_env_vars_with_warnings(&env).unwrap();
447
448 assert!(result.vars.iter().any(|v| v == "EMPTY="));
449 assert_eq!(result.warnings.len(), 1);
450 assert!(result.warnings[0].contains("TEST_EMPTY_VAR"));
451
452 std::env::remove_var("TEST_EMPTY_VAR");
453 }
454
455 #[test]
456 fn test_resolve_with_warnings_no_warnings() {
457 std::env::set_var("TEST_NONEMPTY_VAR", "value");
458
459 let mut env = HashMap::new();
460 env.insert("VAR".to_string(), "$E:TEST_NONEMPTY_VAR".to_string());
461
462 let result = resolve_env_vars_with_warnings(&env).unwrap();
463
464 assert!(result.vars.iter().any(|v| v == "VAR=value"));
465 assert!(result.warnings.is_empty());
466
467 std::env::remove_var("TEST_NONEMPTY_VAR");
468 }
469
470 #[test]
471 fn test_has_env_references() {
472 let mut env = HashMap::new();
473 env.insert("PLAIN".to_string(), "value".to_string());
474 assert!(!has_env_references(&env));
475
476 env.insert("REF".to_string(), "$E:SOME_VAR".to_string());
477 assert!(has_env_references(&env));
478 }
479
480 #[test]
481 fn test_get_env_references() {
482 let mut env = HashMap::new();
483 env.insert("PLAIN".to_string(), "value".to_string());
484 env.insert("DB".to_string(), "$E:DATABASE_URL".to_string());
485 env.insert("SECRET".to_string(), "$E:API_KEY".to_string());
486
487 let refs = get_env_references(&env);
488
489 assert_eq!(refs.len(), 2);
490 assert!(refs.contains(&"DATABASE_URL"));
491 assert!(refs.contains(&"API_KEY"));
492 }
493
494 #[test]
495 fn test_empty_env_map() {
496 let env = HashMap::new();
497
498 let result = resolve_env_vars(&env).unwrap();
499 assert!(result.is_empty());
500
501 let result_with_warnings = resolve_env_vars_with_warnings(&env).unwrap();
502 assert!(result_with_warnings.vars.is_empty());
503 assert!(result_with_warnings.warnings.is_empty());
504 }
505
506 #[test]
507 fn test_dollar_e_not_at_start() {
508 let mut env = HashMap::new();
510 env.insert("VAR".to_string(), "prefix$E:SOMETHING".to_string());
511
512 let result = resolve_env_vars(&env).unwrap();
513 assert_eq!(result.get("VAR").unwrap(), "prefix$E:SOMETHING");
515 }
516
517 #[test]
518 fn test_partial_prefix() {
519 let mut env = HashMap::new();
521 env.insert("VAR".to_string(), "$E".to_string());
522
523 let result = resolve_env_vars(&env).unwrap();
524 assert_eq!(result.get("VAR").unwrap(), "$E");
525 }
526
527 #[test]
528 fn test_has_secret_references() {
529 let mut env = HashMap::new();
530 env.insert("PLAIN".to_string(), "value".to_string());
531 assert!(!has_secret_references(&env));
532
533 env.insert("SECRET".to_string(), "$S:api-key".to_string());
534 assert!(has_secret_references(&env));
535 }
536
537 #[test]
538 fn test_get_secret_references() {
539 let mut env = HashMap::new();
540 env.insert("PLAIN".to_string(), "value".to_string());
541 env.insert("SECRET1".to_string(), "$S:api-key".to_string());
542 env.insert("SECRET2".to_string(), "$S:database-url".to_string());
543 env.insert("ENV_VAR".to_string(), "$E:HOST_VAR".to_string());
544
545 let refs = get_secret_references(&env);
546
547 assert_eq!(refs.len(), 2);
548 assert!(refs.contains(&"api-key"));
549 assert!(refs.contains(&"database-url"));
550 }
551
552 mod secrets_tests {
554 use super::*;
555 use async_trait::async_trait;
556 use std::sync::Mutex;
557 use zlayer_secrets::{Secret, SecretMetadata, SecretsProvider};
558
559 struct MockSecretsProvider {
561 secrets: Mutex<HashMap<String, HashMap<String, Secret>>>,
562 }
563
564 impl MockSecretsProvider {
565 fn new() -> Self {
566 Self {
567 secrets: Mutex::new(HashMap::new()),
568 }
569 }
570
571 fn add_secret(&self, scope: &str, name: &str, value: &str) {
572 let mut secrets = self.secrets.lock().unwrap();
573 secrets
574 .entry(scope.to_string())
575 .or_default()
576 .insert(name.to_string(), Secret::new(value));
577 }
578 }
579
580 #[async_trait]
581 impl SecretsProvider for MockSecretsProvider {
582 async fn get_secret(&self, scope: &str, name: &str) -> zlayer_secrets::Result<Secret> {
583 let secrets = self.secrets.lock().unwrap();
584 secrets
585 .get(scope)
586 .and_then(|s| s.get(name))
587 .cloned()
588 .ok_or_else(|| zlayer_secrets::SecretsError::NotFound {
589 name: name.to_string(),
590 })
591 }
592
593 async fn get_secrets(
594 &self,
595 scope: &str,
596 names: &[&str],
597 ) -> zlayer_secrets::Result<HashMap<String, Secret>> {
598 let secrets = self.secrets.lock().unwrap();
599 let scope_secrets = secrets.get(scope);
600
601 let mut result = HashMap::new();
602 if let Some(scope_secrets) = scope_secrets {
603 for name in names {
604 if let Some(secret) = scope_secrets.get(*name) {
605 result.insert((*name).to_string(), secret.clone());
606 }
607 }
608 }
609 Ok(result)
610 }
611
612 async fn list_secrets(
613 &self,
614 scope: &str,
615 ) -> zlayer_secrets::Result<Vec<SecretMetadata>> {
616 let secrets = self.secrets.lock().unwrap();
617 Ok(secrets
618 .get(scope)
619 .map(|s| s.keys().map(SecretMetadata::new).collect())
620 .unwrap_or_default())
621 }
622
623 async fn exists(&self, scope: &str, name: &str) -> zlayer_secrets::Result<bool> {
624 let secrets = self.secrets.lock().unwrap();
625 Ok(secrets.get(scope).is_some_and(|s| s.contains_key(name)))
626 }
627 }
628
629 #[tokio::test]
630 async fn test_resolve_value_with_secrets_plain() {
631 let provider = MockSecretsProvider::new();
632 let result = resolve_value_with_secrets("plain_value", &provider, "test-scope")
633 .await
634 .unwrap();
635 assert_eq!(result, "plain_value");
636 }
637
638 #[tokio::test]
639 async fn test_resolve_value_with_secrets_env_ref() {
640 std::env::set_var("TEST_SECRETS_ENV_VAR", "env_value");
641
642 let provider = MockSecretsProvider::new();
643 let result =
644 resolve_value_with_secrets("$E:TEST_SECRETS_ENV_VAR", &provider, "test-scope")
645 .await
646 .unwrap();
647 assert_eq!(result, "env_value");
648
649 std::env::remove_var("TEST_SECRETS_ENV_VAR");
650 }
651
652 #[tokio::test]
653 async fn test_resolve_value_with_secrets_secret_ref() {
654 let provider = MockSecretsProvider::new();
655 provider.add_secret("test-deployment", "api-key", "secret-api-key-123");
656
657 let result = resolve_value_with_secrets("$S:api-key", &provider, "test-deployment")
658 .await
659 .unwrap();
660 assert_eq!(result, "secret-api-key-123");
661 }
662
663 #[tokio::test]
664 async fn test_resolve_value_with_secrets_service_scoped() {
665 let provider = MockSecretsProvider::new();
666 provider.add_secret("test-deployment/api", "db-password", "service-specific-pwd");
667
668 let result =
669 resolve_value_with_secrets("$S:@api/db-password", &provider, "test-deployment")
670 .await
671 .unwrap();
672 assert_eq!(result, "service-specific-pwd");
673 }
674
675 #[tokio::test]
676 async fn test_resolve_value_with_secrets_field_extraction() {
677 let provider = MockSecretsProvider::new();
678 provider.add_secret(
679 "test-deployment",
680 "database",
681 r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
682 );
683
684 let result =
685 resolve_value_with_secrets("$S:database/password", &provider, "test-deployment")
686 .await
687 .unwrap();
688 assert_eq!(result, "db-secret");
689
690 let result =
692 resolve_value_with_secrets("$S:database/port", &provider, "test-deployment")
693 .await
694 .unwrap();
695 assert_eq!(result, "5432");
696 }
697
698 #[tokio::test]
699 async fn test_resolve_value_with_secrets_missing_secret() {
700 let provider = MockSecretsProvider::new();
701
702 let result =
703 resolve_value_with_secrets("$S:nonexistent", &provider, "test-deployment").await;
704 assert!(result.is_err());
705 match result {
706 Err(EnvResolutionError::SecretNotFound { name }) => {
707 assert_eq!(name, "nonexistent");
708 }
709 _ => panic!("Expected SecretNotFound error"),
710 }
711 }
712
713 #[tokio::test]
714 async fn test_resolve_env_with_secrets_mixed() {
715 std::env::set_var("TEST_MIXED_HOST_VAR", "host_value");
716
717 let provider = MockSecretsProvider::new();
718 provider.add_secret("test-deployment", "api-key", "secret-key");
719 provider.add_secret("test-deployment", "db-password", "secret-pwd");
720
721 let mut env = HashMap::new();
722 env.insert("API_KEY".to_string(), "$S:api-key".to_string());
723 env.insert("DB_PASSWORD".to_string(), "$S:db-password".to_string());
724 env.insert("HOST_VAR".to_string(), "$E:TEST_MIXED_HOST_VAR".to_string());
725 env.insert("PLAIN_VAR".to_string(), "plain-value".to_string());
726
727 let resolved = resolve_env_with_secrets(&env, &provider, "test-deployment")
728 .await
729 .unwrap();
730
731 assert_eq!(resolved.get("API_KEY").unwrap(), "secret-key");
732 assert_eq!(resolved.get("DB_PASSWORD").unwrap(), "secret-pwd");
733 assert_eq!(resolved.get("HOST_VAR").unwrap(), "host_value");
734 assert_eq!(resolved.get("PLAIN_VAR").unwrap(), "plain-value");
735
736 std::env::remove_var("TEST_MIXED_HOST_VAR");
737 }
738
739 #[tokio::test]
740 async fn test_resolve_env_with_secrets_missing_env_var() {
741 let provider = MockSecretsProvider::new();
742
743 let mut env = HashMap::new();
744 env.insert(
745 "MISSING".to_string(),
746 "$E:DEFINITELY_NOT_SET_SECRETS_12345".to_string(),
747 );
748
749 let result = resolve_env_with_secrets(&env, &provider, "test-deployment").await;
750 assert!(result.is_err());
751 match result {
752 Err(EnvResolutionError::MissingEnvVar { var }) => {
753 assert_eq!(var, "DEFINITELY_NOT_SET_SECRETS_12345");
754 }
755 _ => panic!("Expected MissingEnvVar error"),
756 }
757 }
758
759 #[tokio::test]
760 async fn test_resolve_env_with_secrets_missing_secret() {
761 let provider = MockSecretsProvider::new();
762 provider.add_secret("test-deployment", "exists", "value");
763
764 let mut env = HashMap::new();
765 env.insert("EXISTS".to_string(), "$S:exists".to_string());
766 env.insert("MISSING".to_string(), "$S:does-not-exist".to_string());
767
768 let result = resolve_env_with_secrets(&env, &provider, "test-deployment").await;
769 assert!(result.is_err());
770 match result {
771 Err(EnvResolutionError::SecretNotFound { name }) => {
772 assert_eq!(name, "does-not-exist");
773 }
774 _ => panic!("Expected SecretNotFound error"),
775 }
776 }
777
778 #[tokio::test]
779 async fn test_extract_json_field_missing_field() {
780 let provider = MockSecretsProvider::new();
781 provider.add_secret(
782 "test-deployment",
783 "database",
784 r#"{"host":"localhost","port":5432}"#,
785 );
786
787 let result =
788 resolve_value_with_secrets("$S:database/nonexistent", &provider, "test-deployment")
789 .await;
790 assert!(result.is_err());
791 match result {
792 Err(EnvResolutionError::SecretNotFound { name }) => {
793 assert!(name.contains("nonexistent"));
794 }
795 _ => panic!("Expected SecretNotFound error for missing field"),
796 }
797 }
798
799 #[tokio::test]
800 async fn test_extract_json_field_invalid_json() {
801 let provider = MockSecretsProvider::new();
802 provider.add_secret("test-deployment", "not-json", "this is not json");
803
804 let result =
805 resolve_value_with_secrets("$S:not-json/field", &provider, "test-deployment").await;
806 assert!(result.is_err());
807 match result {
808 Err(EnvResolutionError::SecretResolution { message }) => {
809 assert!(message.contains("JSON"));
810 }
811 _ => panic!("Expected SecretResolution error for invalid JSON"),
812 }
813 }
814 }
815}