Skip to main content

zlayer_agent/
env.rs

1//! Environment variable resolution for $E: and $S: prefix syntax
2//!
3//! Per `V1_SPEC.md` Section 7, values prefixed with `$E:` are resolved
4//! from the runtime environment at container start time.
5//!
6//! Additionally, values prefixed with `$S:` are resolved from the secrets
7//! provider at container start time.
8
9use std::collections::HashMap;
10use thiserror::Error;
11use zlayer_secrets::{SecretRef, SecretsError, SecretsProvider};
12
13/// Prefix that indicates a value should be resolved from host environment
14const ENV_REF_PREFIX: &str = "$E:";
15
16/// Prefix that indicates a value should be resolved from secrets provider
17const SECRET_REF_PREFIX: &str = "$S:";
18
19/// Errors that can occur during environment variable resolution
20#[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
32/// Result of resolving environment variables
33pub struct ResolvedEnv {
34    /// Successfully resolved environment variables (KEY=VALUE format for OCI)
35    pub vars: Vec<String>,
36    /// Any warnings (e.g., empty values)
37    pub warnings: Vec<String>,
38}
39
40/// Resolve a single environment variable value
41///
42/// If the value starts with `$E:`, look up the remainder in `std::env::var()`.
43/// Otherwise, return the value unchanged.
44///
45/// # Arguments
46/// * `value` - The value to resolve (possibly with $E: prefix)
47///
48/// # Returns
49/// * `Ok(String)` - The resolved value
50/// * `Err(EnvResolutionError)` - If a $E: reference cannot be resolved
51///
52/// # Errors
53/// Returns an error if a `$E:` referenced environment variable is not set.
54pub 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/// Resolve environment variables from a `ServiceSpec` env `HashMap`
70///
71/// For each entry:
72/// - If value starts with `$E:`, look up the remainder in `std::env::var()`
73/// - Otherwise, pass the value through unchanged
74///
75/// # Arguments
76/// * `env` - `HashMap` of environment variable names to values (possibly with $E: prefix)
77///
78/// # Returns
79/// * `Ok(HashMap<String, String>)` - Resolved key-value pairs
80/// * `Err(EnvResolutionError)` - If a required $E: reference cannot be resolved
81///
82/// # Errors
83/// Returns an error if a `$E:` referenced environment variable is not set.
84///
85/// # Example
86/// ```
87/// use std::collections::HashMap;
88/// use zlayer_agent::env::resolve_env_vars;
89///
90/// std::env::set_var("MY_SECRET", "secret_value");
91///
92/// let mut env = HashMap::new();
93/// env.insert("NODE_ENV".to_string(), "production".to_string());
94/// env.insert("DATABASE_URL".to_string(), "$E:MY_SECRET".to_string());
95///
96/// let resolved = resolve_env_vars(&env).unwrap();
97/// assert_eq!(resolved.get("NODE_ENV").unwrap(), "production");
98/// assert_eq!(resolved.get("DATABASE_URL").unwrap(), "secret_value");
99///
100/// std::env::remove_var("MY_SECRET");
101/// ```
102#[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/// Resolve environment variables and return in OCI format with warnings
117///
118/// This is the full resolution function that also tracks warnings for empty values.
119///
120/// # Arguments
121/// * `env` - `HashMap` of environment variable names to values (possibly with $E: prefix)
122///
123/// # Returns
124/// * `Ok(ResolvedEnv)` - Resolved variables in KEY=VALUE format and any warnings
125/// * `Err(EnvResolutionError)` - If a required $E: reference cannot be resolved
126///
127/// # Errors
128/// Returns an error if a `$E:` referenced environment variable is not set.
129#[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/// Check if any environment variables use $E: references
164///
165/// Useful for validation and debugging to see which vars will be resolved at runtime.
166#[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/// Get list of $E: referenced variable names
173///
174/// Returns the names of host environment variables that will be looked up.
175#[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/// Check if any environment variables use $S: secret references
184///
185/// Useful for validation and debugging to see which vars will be resolved from secrets.
186#[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/// Get list of $S: referenced secret names
193///
194/// Returns the raw secret reference strings (without the $S: prefix) that will be looked up.
195#[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/// Extended environment resolution with secrets support
204///
205/// Resolves environment variables from multiple sources:
206/// - `$S:` prefixed values are resolved from the secrets provider
207/// - `$E:` prefixed values are resolved from host environment variables
208/// - Other values are returned as-is
209///
210/// # Arguments
211/// * `env` - `HashMap` of environment variable names to values (possibly with $S: or $E: prefix)
212/// * `secrets_provider` - The secrets provider to use for $S: lookups
213/// * `scope` - The scope identifier (e.g., deployment name) for secret lookups
214///
215/// # Returns
216/// * `Ok(HashMap<String, String>)` - Resolved key-value pairs
217/// * `Err(EnvResolutionError)` - If a required $S: or $E: reference cannot be resolved
218///
219/// # Errors
220/// Returns an error if a `$S:` or `$E:` referenced value cannot be resolved.
221///
222/// # Example
223/// ```rust,ignore
224/// use std::collections::HashMap;
225/// use zlayer_agent::env::resolve_env_with_secrets;
226/// use zlayer_secrets::PersistentSecretsStore;
227///
228/// async fn example(provider: &PersistentSecretsStore) {
229///     let mut env = HashMap::new();
230///     env.insert("NODE_ENV".to_string(), "production".to_string());
231///     env.insert("DATABASE_URL".to_string(), "$S:database-url".to_string());
232///     env.insert("HOST_VAR".to_string(), "$E:MY_HOST_VAR".to_string());
233///
234///     let resolved = resolve_env_with_secrets(&env, provider, "my-deployment").await.unwrap();
235/// }
236/// ```
237#[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
253/// Resolve a single environment variable value with secrets support
254///
255/// If the value starts with `$S:`, look up the secret from the provider.
256/// If the value starts with `$E:`, look up the remainder in `std::env::var()`.
257/// Otherwise, return the value unchanged.
258///
259/// # Arguments
260/// * `value` - The value to resolve (possibly with $S: or $E: prefix)
261/// * `secrets_provider` - The secrets provider to use for $S: lookups
262/// * `scope` - The scope identifier (e.g., deployment name) for secret lookups
263///
264/// # Returns
265/// * `Ok(String)` - The resolved value
266/// * `Err(EnvResolutionError)` - If a $S: or $E: reference cannot be resolved
267///
268/// # Errors
269/// Returns an error if a `$S:` or `$E:` referenced value cannot be resolved.
270pub async fn resolve_value_with_secrets<P: SecretsProvider + ?Sized>(
271    value: &str,
272    secrets_provider: &P,
273    scope: &str,
274) -> Result<String, EnvResolutionError> {
275    // Check for secret reference first
276    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        // Determine the scope based on service qualifier
283        let effective_scope = match &secret_ref.service {
284            Some(service) => format!("{scope}/{service}"),
285            None => scope.to_string(),
286        };
287
288        // Fetch the secret
289        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        // Handle field extraction if specified
302        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        // Host environment variable reference
308        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        // Plain value, return as-is
318        Ok(value.to_string())
319    }
320}
321
322/// Extract a field from a JSON-formatted secret value.
323fn 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()), // For arrays/objects, return JSON string
335        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        // "$E:" must be at the start of the value
509        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        // Should pass through unchanged
514        assert_eq!(result.get("VAR").unwrap(), "prefix$E:SOMETHING");
515    }
516
517    #[test]
518    fn test_partial_prefix() {
519        // "$E" without colon should pass through
520        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    // Tests for secrets resolution require async runtime and a mock provider
553    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        /// Mock provider for testing
560        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            // Test numeric field
691            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}