cuenv_core/
environment.rs

1//! Environment management for cuenv
2//!
3//! This module handles environment variables from CUE configurations,
4//! including extraction, propagation, and environment-specific overrides.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::env;
9
10/// Policy for controlling environment variable access
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct Policy {
13    /// Allowlist of task names that can access this variable
14    #[serde(skip_serializing_if = "Option::is_none", rename = "allowTasks")]
15    pub allow_tasks: Option<Vec<String>>,
16
17    /// Allowlist of exec commands that can access this variable
18    #[serde(skip_serializing_if = "Option::is_none", rename = "allowExec")]
19    pub allow_exec: Option<Vec<String>>,
20}
21
22/// Environment variable with optional access policies
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct EnvVarWithPolicies {
25    /// The actual value
26    pub value: EnvValueSimple,
27
28    /// Optional access policies
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub policies: Option<Vec<Policy>>,
31}
32
33/// Simple environment variable values (non-recursive)
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35#[serde(untagged)]
36pub enum EnvValueSimple {
37    String(String),
38    Int(i64),
39    Bool(bool),
40    Secret(crate::secrets::Secret),
41}
42
43/// Environment variable values can be strings, integers, booleans, secrets, or values with policies
44/// When exported to actual environment, these will always be strings
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46#[serde(untagged)]
47pub enum EnvValue {
48    // Value with policies must come first for serde untagged to try it first
49    WithPolicies(EnvVarWithPolicies),
50    // Simple values (backward compatible)
51    String(String),
52    Int(i64),
53    Bool(bool),
54    Secret(crate::secrets::Secret),
55}
56
57/// Environment configuration with environment-specific overrides
58/// Based on schema/env.cue
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
60pub struct Env {
61    /// Base environment variables
62    /// Keys must match pattern: ^[A-Z][A-Z0-9_]*$
63    #[serde(flatten)]
64    pub base: HashMap<String, EnvValue>,
65
66    /// Environment-specific overrides
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub environment: Option<HashMap<String, HashMap<String, EnvValue>>>,
69}
70
71impl Env {
72    /// Get environment variables for a specific environment
73    pub fn for_environment(&self, env_name: &str) -> HashMap<String, EnvValue> {
74        let mut result = self.base.clone();
75
76        if let Some(environments) = &self.environment
77            && let Some(env_overrides) = environments.get(env_name)
78        {
79            result.extend(env_overrides.clone());
80        }
81
82        result
83    }
84}
85
86impl EnvValue {
87    /// Check if a task has access to this environment variable
88    pub fn is_accessible_by_task(&self, task_name: &str) -> bool {
89        match self {
90            // Simple values are always accessible
91            EnvValue::String(_) | EnvValue::Int(_) | EnvValue::Bool(_) | EnvValue::Secret(_) => {
92                true
93            }
94
95            // Check policies for restricted variables
96            EnvValue::WithPolicies(var) => match &var.policies {
97                None => true,                                  // No policies means accessible
98                Some(policies) if policies.is_empty() => true, // Empty policies means accessible
99                Some(policies) => {
100                    // Check if any policy allows this task
101                    policies.iter().any(|policy| {
102                        policy
103                            .allow_tasks
104                            .as_ref()
105                            .is_some_and(|tasks| tasks.iter().any(|t| t == task_name))
106                    })
107                }
108            },
109        }
110    }
111
112    /// Check if an exec command has access to this environment variable
113    pub fn is_accessible_by_exec(&self, command: &str) -> bool {
114        match self {
115            // Simple values are always accessible
116            EnvValue::String(_) | EnvValue::Int(_) | EnvValue::Bool(_) | EnvValue::Secret(_) => {
117                true
118            }
119
120            // Check policies for restricted variables
121            EnvValue::WithPolicies(var) => match &var.policies {
122                None => true,                                  // No policies means accessible
123                Some(policies) if policies.is_empty() => true, // Empty policies means accessible
124                Some(policies) => {
125                    // Check if any policy allows this exec command
126                    policies.iter().any(|policy| {
127                        policy
128                            .allow_exec
129                            .as_ref()
130                            .is_some_and(|execs| execs.iter().any(|e| e == command))
131                    })
132                }
133            },
134        }
135    }
136
137    /// Get the actual string value of the environment variable
138    pub fn to_string_value(&self) -> String {
139        match self {
140            EnvValue::String(s) => s.clone(),
141            EnvValue::Int(i) => i.to_string(),
142            EnvValue::Bool(b) => b.to_string(),
143            EnvValue::Secret(_) => "[SECRET]".to_string(), // Placeholder for secrets
144            EnvValue::WithPolicies(var) => match &var.value {
145                EnvValueSimple::String(s) => s.clone(),
146                EnvValueSimple::Int(i) => i.to_string(),
147                EnvValueSimple::Bool(b) => b.to_string(),
148                EnvValueSimple::Secret(_) => "[SECRET]".to_string(),
149            },
150        }
151    }
152
153    /// Check if this environment value is a secret (requires resolution)
154    #[must_use]
155    pub fn is_secret(&self) -> bool {
156        match self {
157            EnvValue::Secret(_) => true,
158            EnvValue::WithPolicies(var) => matches!(var.value, EnvValueSimple::Secret(_)),
159            _ => false,
160        }
161    }
162
163    /// Resolve the environment variable value, executing secrets if necessary
164    ///
165    /// Secrets with typed resolvers (onepassword, exec, aws, etc.) are resolved
166    /// via the trait-based [`SecretResolver`] system.
167    pub async fn resolve(&self) -> crate::Result<String> {
168        match self {
169            EnvValue::String(s) => Ok(s.clone()),
170            EnvValue::Int(i) => Ok(i.to_string()),
171            EnvValue::Bool(b) => Ok(b.to_string()),
172            EnvValue::Secret(s) => s.resolve().await,
173            EnvValue::WithPolicies(var) => match &var.value {
174                EnvValueSimple::String(s) => Ok(s.clone()),
175                EnvValueSimple::Int(i) => Ok(i.to_string()),
176                EnvValueSimple::Bool(b) => Ok(b.to_string()),
177                EnvValueSimple::Secret(s) => s.resolve().await,
178            },
179        }
180    }
181}
182
183/// Runtime environment variables for task execution
184#[derive(Debug, Clone, Serialize, Deserialize, Default)]
185pub struct Environment {
186    /// Map of environment variable names to values
187    #[serde(flatten)]
188    pub vars: HashMap<String, String>,
189}
190
191impl Environment {
192    /// Create a new empty environment
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    /// Create environment from a map
198    pub fn from_map(vars: HashMap<String, String>) -> Self {
199        Self { vars }
200    }
201
202    /// Get an environment variable value
203    pub fn get(&self, key: &str) -> Option<&str> {
204        self.vars.get(key).map(|s| s.as_str())
205    }
206
207    /// Set an environment variable
208    pub fn set(&mut self, key: String, value: String) {
209        self.vars.insert(key, value);
210    }
211
212    /// Check if an environment variable exists
213    pub fn contains(&self, key: &str) -> bool {
214        self.vars.contains_key(key)
215    }
216
217    /// Get all environment variables as a vector of key=value strings
218    pub fn to_env_vec(&self) -> Vec<String> {
219        self.vars
220            .iter()
221            .map(|(k, v)| format!("{}={}", k, v))
222            .collect()
223    }
224
225    /// Merge with system environment variables
226    /// CUE environment variables take precedence
227    pub fn merge_with_system(&self) -> HashMap<String, String> {
228        let mut merged: HashMap<String, String> = env::vars().collect();
229
230        // Override with CUE environment variables
231        for (key, value) in &self.vars {
232            merged.insert(key.clone(), value.clone());
233        }
234
235        merged
236    }
237
238    /// Essential system variables to preserve in hermetic mode.
239    /// These are required for basic process operation but don't pollute PATH.
240    const HERMETIC_ALLOWED_VARS: &'static [&'static str] = &[
241        "HOME",
242        "USER",
243        "LOGNAME",
244        "SHELL",
245        "TERM",
246        "COLORTERM",
247        "LANG",
248        "LC_ALL",
249        "LC_CTYPE",
250        "LC_MESSAGES",
251        "TMPDIR",
252        "TMP",
253        "TEMP",
254        "XDG_RUNTIME_DIR",
255        "XDG_CONFIG_HOME",
256        "XDG_CACHE_HOME",
257        "XDG_DATA_HOME",
258    ];
259
260    /// Merge with only essential system environment variables (hermetic mode).
261    ///
262    /// Unlike `merge_with_system()`, this excludes PATH and other potentially
263    /// polluting variables. PATH should come from cuenv tools activation.
264    ///
265    /// Included variables:
266    /// - User identity: HOME, USER, LOGNAME, SHELL
267    /// - Terminal: TERM, COLORTERM
268    /// - Locale: LANG, LC_* variables
269    /// - Temp directories: TMPDIR, TMP, TEMP
270    /// - XDG directories: XDG_RUNTIME_DIR, XDG_CONFIG_HOME, etc.
271    pub fn merge_with_system_hermetic(&self) -> HashMap<String, String> {
272        let mut merged: HashMap<String, String> = HashMap::new();
273
274        // Only include allowed system variables
275        for var in Self::HERMETIC_ALLOWED_VARS {
276            if let Ok(value) = env::var(var) {
277                merged.insert((*var).to_string(), value);
278            }
279        }
280
281        // Also include any LC_* variables (locale settings)
282        for (key, value) in env::vars() {
283            if key.starts_with("LC_") {
284                merged.insert(key, value);
285            }
286        }
287
288        // Override with CUE environment variables (including cuenv-constructed PATH)
289        for (key, value) in &self.vars {
290            merged.insert(key.clone(), value.clone());
291        }
292
293        merged
294    }
295
296    /// Convert to a vector of key=value strings including system environment
297    pub fn to_full_env_vec(&self) -> Vec<String> {
298        self.merge_with_system()
299            .iter()
300            .map(|(k, v)| format!("{}={}", k, v))
301            .collect()
302    }
303
304    /// Get the number of environment variables
305    pub fn len(&self) -> usize {
306        self.vars.len()
307    }
308
309    /// Check if the environment is empty
310    pub fn is_empty(&self) -> bool {
311        self.vars.is_empty()
312    }
313
314    /// Resolve a command to its full path using this environment's PATH.
315    /// This is necessary because when spawning a process, the OS looks up
316    /// the executable in the current process's PATH, not the environment
317    /// that will be set on the child process.
318    ///
319    /// Returns the full path if found, or the original command if not found
320    /// (letting the spawn fail with a proper error).
321    pub fn resolve_command(&self, command: &str) -> String {
322        // If command is already an absolute path, use it directly
323        if command.starts_with('/') {
324            tracing::debug!(command = %command, "Command is already absolute path");
325            return command.to_string();
326        }
327
328        // Get the PATH from this environment, falling back to system PATH
329        let path_value = self
330            .vars
331            .get("PATH")
332            .cloned()
333            .or_else(|| env::var("PATH").ok())
334            .unwrap_or_default();
335
336        tracing::debug!(
337            command = %command,
338            env_has_path = self.vars.contains_key("PATH"),
339            path_len = path_value.len(),
340            "Resolving command in PATH"
341        );
342
343        // Search for the command in each PATH directory
344        for dir in path_value.split(':') {
345            if dir.is_empty() {
346                continue;
347            }
348            let candidate = std::path::Path::new(dir).join(command);
349            if candidate.is_file() {
350                // Check if it's executable (on Unix)
351                #[cfg(unix)]
352                {
353                    use std::os::unix::fs::PermissionsExt;
354                    if let Ok(metadata) = std::fs::metadata(&candidate) {
355                        let permissions = metadata.permissions();
356                        if permissions.mode() & 0o111 != 0 {
357                            tracing::debug!(
358                                command = %command,
359                                resolved = %candidate.display(),
360                                "Command resolved to path"
361                            );
362                            return candidate.to_string_lossy().to_string();
363                        }
364                    }
365                }
366                #[cfg(not(unix))]
367                {
368                    tracing::debug!(
369                        command = %command,
370                        resolved = %candidate.display(),
371                        "Command resolved to path"
372                    );
373                    return candidate.to_string_lossy().to_string();
374                }
375            }
376        }
377
378        // Command not found in environment PATH - try system PATH as fallback
379        // This is necessary when the environment has tool paths but not system paths,
380        // since we still need to find system commands like echo, bash, etc.
381        if self.vars.contains_key("PATH")
382            && let Ok(system_path) = env::var("PATH")
383        {
384            tracing::debug!(
385                command = %command,
386                "Command not found in env PATH, trying system PATH"
387            );
388            for dir in system_path.split(':') {
389                if dir.is_empty() {
390                    continue;
391                }
392                let candidate = std::path::Path::new(dir).join(command);
393                if candidate.is_file() {
394                    #[cfg(unix)]
395                    {
396                        use std::os::unix::fs::PermissionsExt;
397                        if let Ok(metadata) = std::fs::metadata(&candidate) {
398                            let permissions = metadata.permissions();
399                            if permissions.mode() & 0o111 != 0 {
400                                tracing::debug!(
401                                    command = %command,
402                                    resolved = %candidate.display(),
403                                    "Command resolved from system PATH"
404                                );
405                                return candidate.to_string_lossy().to_string();
406                            }
407                        }
408                    }
409                    #[cfg(not(unix))]
410                    {
411                        tracing::debug!(
412                            command = %command,
413                            resolved = %candidate.display(),
414                            "Command resolved from system PATH"
415                        );
416                        return candidate.to_string_lossy().to_string();
417                    }
418                }
419            }
420        }
421
422        // Command not found in any PATH, return original (spawn will fail with proper error)
423        tracing::warn!(
424            command = %command,
425            env_path_set = self.vars.contains_key("PATH"),
426            "Command not found in PATH, returning original"
427        );
428        command.to_string()
429    }
430
431    /// Iterate over environment variables
432    pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
433        self.vars.iter()
434    }
435
436    /// Build environment for a task, filtering based on policies
437    pub fn build_for_task(
438        task_name: &str,
439        env_vars: &HashMap<String, EnvValue>,
440    ) -> HashMap<String, String> {
441        env_vars
442            .iter()
443            .filter(|(_, value)| value.is_accessible_by_task(task_name))
444            .map(|(key, value)| (key.clone(), value.to_string_value()))
445            .collect()
446    }
447
448    /// Build and resolve environment for a task, filtering based on policies
449    ///
450    /// Resolves all environment variables including secrets via the
451    /// trait-based resolver system.
452    pub async fn resolve_for_task(
453        task_name: &str,
454        env_vars: &HashMap<String, EnvValue>,
455    ) -> crate::Result<HashMap<String, String>> {
456        let (resolved, _secrets) = Self::resolve_for_task_with_secrets(task_name, env_vars).await?;
457        Ok(resolved)
458    }
459
460    /// Build and resolve environment for a task, also returning secret values
461    ///
462    /// Returns `(resolved_env_vars, secret_values)` where `secret_values` contains
463    /// the resolved values of any secrets, for use in output redaction.
464    pub async fn resolve_for_task_with_secrets(
465        task_name: &str,
466        env_vars: &HashMap<String, EnvValue>,
467    ) -> crate::Result<(HashMap<String, String>, Vec<String>)> {
468        let mut resolved = HashMap::new();
469        let mut secrets = Vec::new();
470
471        for (key, value) in env_vars {
472            if value.is_accessible_by_task(task_name) {
473                let resolved_value = value.resolve().await?;
474                if value.is_secret() {
475                    secrets.push(resolved_value.clone());
476                }
477                resolved.insert(key.clone(), resolved_value);
478            }
479        }
480        Ok((resolved, secrets))
481    }
482
483    /// Build environment for exec command, filtering based on policies
484    pub fn build_for_exec(
485        command: &str,
486        env_vars: &HashMap<String, EnvValue>,
487    ) -> HashMap<String, String> {
488        env_vars
489            .iter()
490            .filter(|(_, value)| value.is_accessible_by_exec(command))
491            .map(|(key, value)| (key.clone(), value.to_string_value()))
492            .collect()
493    }
494
495    /// Build and resolve environment for exec command, filtering based on policies
496    ///
497    /// Resolves all environment variables including secrets via the
498    /// trait-based resolver system.
499    pub async fn resolve_for_exec(
500        command: &str,
501        env_vars: &HashMap<String, EnvValue>,
502    ) -> crate::Result<HashMap<String, String>> {
503        let (resolved, _secrets) = Self::resolve_for_exec_with_secrets(command, env_vars).await?;
504        Ok(resolved)
505    }
506
507    /// Build and resolve environment for exec command, also returning secret values
508    ///
509    /// Returns `(resolved_env_vars, secret_values)` where `secret_values` contains
510    /// the resolved values of any secrets, for use in output redaction.
511    pub async fn resolve_for_exec_with_secrets(
512        command: &str,
513        env_vars: &HashMap<String, EnvValue>,
514    ) -> crate::Result<(HashMap<String, String>, Vec<String>)> {
515        let mut resolved = HashMap::new();
516        let mut secrets = Vec::new();
517
518        for (key, value) in env_vars {
519            if value.is_accessible_by_exec(command) {
520                let resolved_value = value.resolve().await?;
521                if value.is_secret() {
522                    secrets.push(resolved_value.clone());
523                }
524                resolved.insert(key.clone(), resolved_value);
525            }
526        }
527        Ok((resolved, secrets))
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn test_environment_basics() {
537        let mut env = Environment::new();
538        assert!(env.is_empty());
539
540        env.set("FOO".to_string(), "bar".to_string());
541        assert_eq!(env.len(), 1);
542        assert!(env.contains("FOO"));
543        assert_eq!(env.get("FOO"), Some("bar"));
544        assert!(!env.contains("BAR"));
545    }
546
547    #[test]
548    fn test_environment_from_map() {
549        let mut vars = HashMap::new();
550        vars.insert("KEY1".to_string(), "value1".to_string());
551        vars.insert("KEY2".to_string(), "value2".to_string());
552
553        let env = Environment::from_map(vars);
554        assert_eq!(env.len(), 2);
555        assert_eq!(env.get("KEY1"), Some("value1"));
556        assert_eq!(env.get("KEY2"), Some("value2"));
557    }
558
559    #[test]
560    fn test_environment_to_vec() {
561        let mut env = Environment::new();
562        env.set("VAR1".to_string(), "val1".to_string());
563        env.set("VAR2".to_string(), "val2".to_string());
564
565        let vec = env.to_env_vec();
566        assert_eq!(vec.len(), 2);
567        assert!(vec.contains(&"VAR1=val1".to_string()));
568        assert!(vec.contains(&"VAR2=val2".to_string()));
569    }
570
571    #[test]
572    fn test_environment_merge_with_system() {
573        let mut env = Environment::new();
574        env.set("PATH".to_string(), "/custom/path".to_string());
575        env.set("CUSTOM_VAR".to_string(), "custom_value".to_string());
576
577        let merged = env.merge_with_system();
578
579        // Custom variables should be present
580        assert_eq!(merged.get("PATH"), Some(&"/custom/path".to_string()));
581        assert_eq!(merged.get("CUSTOM_VAR"), Some(&"custom_value".to_string()));
582
583        // System variables should still be present (like HOME, USER, etc.)
584        // We can't test specific values but we can check that merging happened
585        assert!(merged.len() >= 2);
586    }
587
588    #[test]
589    fn test_environment_iteration() {
590        let mut env = Environment::new();
591        env.set("A".to_string(), "1".to_string());
592        env.set("B".to_string(), "2".to_string());
593
594        let mut count = 0;
595        for (key, value) in env.iter() {
596            assert!(key == "A" || key == "B");
597            assert!(value == "1" || value == "2");
598            count += 1;
599        }
600        assert_eq!(count, 2);
601    }
602
603    #[test]
604    fn test_env_value_types() {
605        let str_val = EnvValue::String("test".to_string());
606        let int_val = EnvValue::Int(42);
607        let bool_val = EnvValue::Bool(true);
608
609        assert_eq!(str_val, EnvValue::String("test".to_string()));
610        assert_eq!(int_val, EnvValue::Int(42));
611        assert_eq!(bool_val, EnvValue::Bool(true));
612    }
613
614    #[test]
615    fn test_policy_task_access() {
616        // Simple value - always accessible
617        let simple_var = EnvValue::String("simple".to_string());
618        assert!(simple_var.is_accessible_by_task("any_task"));
619
620        // Variable with no policies - accessible
621        let no_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
622            value: EnvValueSimple::String("value".to_string()),
623            policies: None,
624        });
625        assert!(no_policy_var.is_accessible_by_task("any_task"));
626
627        // Variable with empty policies - accessible
628        let empty_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
629            value: EnvValueSimple::String("value".to_string()),
630            policies: Some(vec![]),
631        });
632        assert!(empty_policy_var.is_accessible_by_task("any_task"));
633
634        // Variable with task restrictions
635        let restricted_var = EnvValue::WithPolicies(EnvVarWithPolicies {
636            value: EnvValueSimple::String("secret".to_string()),
637            policies: Some(vec![Policy {
638                allow_tasks: Some(vec!["deploy".to_string(), "release".to_string()]),
639                allow_exec: None,
640            }]),
641        });
642        assert!(restricted_var.is_accessible_by_task("deploy"));
643        assert!(restricted_var.is_accessible_by_task("release"));
644        assert!(!restricted_var.is_accessible_by_task("test"));
645        assert!(!restricted_var.is_accessible_by_task("build"));
646    }
647
648    #[test]
649    fn test_policy_exec_access() {
650        // Simple value - always accessible
651        let simple_var = EnvValue::String("simple".to_string());
652        assert!(simple_var.is_accessible_by_exec("bash"));
653
654        // Variable with exec restrictions
655        let restricted_var = EnvValue::WithPolicies(EnvVarWithPolicies {
656            value: EnvValueSimple::String("secret".to_string()),
657            policies: Some(vec![Policy {
658                allow_tasks: None,
659                allow_exec: Some(vec!["kubectl".to_string(), "terraform".to_string()]),
660            }]),
661        });
662        assert!(restricted_var.is_accessible_by_exec("kubectl"));
663        assert!(restricted_var.is_accessible_by_exec("terraform"));
664        assert!(!restricted_var.is_accessible_by_exec("bash"));
665        assert!(!restricted_var.is_accessible_by_exec("sh"));
666    }
667
668    #[test]
669    fn test_multiple_policies() {
670        // Variable with multiple policies - should allow if ANY policy allows
671        let multi_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
672            value: EnvValueSimple::String("value".to_string()),
673            policies: Some(vec![
674                Policy {
675                    allow_tasks: Some(vec!["task1".to_string()]),
676                    allow_exec: None,
677                },
678                Policy {
679                    allow_tasks: Some(vec!["task2".to_string()]),
680                    allow_exec: Some(vec!["kubectl".to_string()]),
681                },
682            ]),
683        });
684
685        // Task access - either policy allows
686        assert!(multi_policy_var.is_accessible_by_task("task1"));
687        assert!(multi_policy_var.is_accessible_by_task("task2"));
688        assert!(!multi_policy_var.is_accessible_by_task("task3"));
689
690        // Exec access - only second policy has exec rules
691        assert!(multi_policy_var.is_accessible_by_exec("kubectl"));
692        assert!(!multi_policy_var.is_accessible_by_exec("bash"));
693    }
694
695    #[test]
696    fn test_to_string_value() {
697        assert_eq!(
698            EnvValue::String("test".to_string()).to_string_value(),
699            "test"
700        );
701        assert_eq!(EnvValue::Int(42).to_string_value(), "42");
702        assert_eq!(EnvValue::Bool(true).to_string_value(), "true");
703        assert_eq!(EnvValue::Bool(false).to_string_value(), "false");
704
705        let with_policies = EnvValue::WithPolicies(EnvVarWithPolicies {
706            value: EnvValueSimple::String("policy_value".to_string()),
707            policies: Some(vec![]),
708        });
709        assert_eq!(with_policies.to_string_value(), "policy_value");
710    }
711
712    #[test]
713    fn test_build_for_task() {
714        let mut env_vars = HashMap::new();
715
716        // Unrestricted variable
717        env_vars.insert(
718            "PUBLIC".to_string(),
719            EnvValue::String("public_value".to_string()),
720        );
721
722        // Restricted variable
723        env_vars.insert(
724            "SECRET".to_string(),
725            EnvValue::WithPolicies(EnvVarWithPolicies {
726                value: EnvValueSimple::String("secret_value".to_string()),
727                policies: Some(vec![Policy {
728                    allow_tasks: Some(vec!["deploy".to_string()]),
729                    allow_exec: None,
730                }]),
731            }),
732        );
733
734        // Build for deploy task - should get both
735        let deploy_env = Environment::build_for_task("deploy", &env_vars);
736        assert_eq!(deploy_env.len(), 2);
737        assert_eq!(deploy_env.get("PUBLIC"), Some(&"public_value".to_string()));
738        assert_eq!(deploy_env.get("SECRET"), Some(&"secret_value".to_string()));
739
740        // Build for test task - should only get public
741        let test_env = Environment::build_for_task("test", &env_vars);
742        assert_eq!(test_env.len(), 1);
743        assert_eq!(test_env.get("PUBLIC"), Some(&"public_value".to_string()));
744        assert_eq!(test_env.get("SECRET"), None);
745    }
746
747    #[test]
748    fn test_build_for_exec() {
749        let mut env_vars = HashMap::new();
750
751        // Unrestricted variable
752        env_vars.insert(
753            "PUBLIC".to_string(),
754            EnvValue::String("public_value".to_string()),
755        );
756
757        // Restricted variable
758        env_vars.insert(
759            "SECRET".to_string(),
760            EnvValue::WithPolicies(EnvVarWithPolicies {
761                value: EnvValueSimple::String("secret_value".to_string()),
762                policies: Some(vec![Policy {
763                    allow_tasks: None,
764                    allow_exec: Some(vec!["kubectl".to_string()]),
765                }]),
766            }),
767        );
768
769        // Build for kubectl - should get both
770        let kubectl_env = Environment::build_for_exec("kubectl", &env_vars);
771        assert_eq!(kubectl_env.len(), 2);
772        assert_eq!(kubectl_env.get("PUBLIC"), Some(&"public_value".to_string()));
773        assert_eq!(kubectl_env.get("SECRET"), Some(&"secret_value".to_string()));
774
775        // Build for bash - should only get public
776        let bash_env = Environment::build_for_exec("bash", &env_vars);
777        assert_eq!(bash_env.len(), 1);
778        assert_eq!(bash_env.get("PUBLIC"), Some(&"public_value".to_string()));
779        assert_eq!(bash_env.get("SECRET"), None);
780    }
781
782    #[test]
783    fn test_env_for_environment() {
784        let mut base = HashMap::new();
785        base.insert("BASE_VAR".to_string(), EnvValue::String("base".to_string()));
786        base.insert(
787            "OVERRIDE_ME".to_string(),
788            EnvValue::String("original".to_string()),
789        );
790
791        let mut dev_env = HashMap::new();
792        dev_env.insert(
793            "OVERRIDE_ME".to_string(),
794            EnvValue::String("dev".to_string()),
795        );
796        dev_env.insert(
797            "DEV_VAR".to_string(),
798            EnvValue::String("development".to_string()),
799        );
800
801        let mut environments = HashMap::new();
802        environments.insert("development".to_string(), dev_env);
803
804        let env = Env {
805            base,
806            environment: Some(environments),
807        };
808
809        let dev_vars = env.for_environment("development");
810        assert_eq!(
811            dev_vars.get("BASE_VAR"),
812            Some(&EnvValue::String("base".to_string()))
813        );
814        assert_eq!(
815            dev_vars.get("OVERRIDE_ME"),
816            Some(&EnvValue::String("dev".to_string()))
817        );
818        assert_eq!(
819            dev_vars.get("DEV_VAR"),
820            Some(&EnvValue::String("development".to_string()))
821        );
822    }
823
824    #[tokio::test]
825    async fn test_resolve_plain_string() {
826        let env_val = EnvValue::String("plain_value".to_string());
827        let resolved = env_val.resolve().await.unwrap();
828        assert_eq!(resolved, "plain_value");
829    }
830
831    #[tokio::test]
832    async fn test_resolve_int() {
833        let env_val = EnvValue::Int(42);
834        let resolved = env_val.resolve().await.unwrap();
835        assert_eq!(resolved, "42");
836    }
837
838    #[tokio::test]
839    async fn test_resolve_bool() {
840        let env_val = EnvValue::Bool(true);
841        let resolved = env_val.resolve().await.unwrap();
842        assert_eq!(resolved, "true");
843    }
844
845    #[tokio::test]
846    async fn test_resolve_with_policies_plain_string() {
847        let env_val = EnvValue::WithPolicies(EnvVarWithPolicies {
848            value: EnvValueSimple::String("policy_value".to_string()),
849            policies: None,
850        });
851        let resolved = env_val.resolve().await.unwrap();
852        assert_eq!(resolved, "policy_value");
853    }
854}