Skip to main content

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/// A part of an interpolated environment variable value.
11/// Can be a literal string or a secret that needs runtime resolution.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(untagged)]
14pub enum EnvPart {
15    /// A secret that needs runtime resolution (must come first for serde untagged)
16    Secret(crate::secrets::Secret),
17    /// A literal string value
18    Literal(String),
19}
20
21impl EnvPart {
22    /// Check if this part is a secret
23    #[must_use]
24    pub fn is_secret(&self) -> bool {
25        matches!(self, EnvPart::Secret(_))
26    }
27}
28
29/// Policy for controlling environment variable access
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct Policy {
32    /// Allowlist of task names that can access this variable
33    #[serde(skip_serializing_if = "Option::is_none", rename = "allowTasks")]
34    pub allow_tasks: Option<Vec<String>>,
35
36    /// Allowlist of exec commands that can access this variable
37    #[serde(skip_serializing_if = "Option::is_none", rename = "allowExec")]
38    pub allow_exec: Option<Vec<String>>,
39}
40
41/// Environment variable with optional access policies
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct EnvVarWithPolicies {
44    /// The actual value
45    pub value: EnvValueSimple,
46
47    /// Optional access policies
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub policies: Option<Vec<Policy>>,
50}
51
52/// Simple environment variable values (non-recursive)
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54#[serde(untagged)]
55pub enum EnvValueSimple {
56    /// A secret that needs runtime resolution
57    Secret(crate::secrets::Secret),
58    /// An interpolated value composed of literal strings and secrets
59    Interpolated(Vec<EnvPart>),
60    /// A simple string value
61    String(String),
62    /// An integer value
63    Int(i64),
64    /// A boolean value
65    Bool(bool),
66}
67
68/// Environment variable values can be strings, integers, booleans, secrets,
69/// interpolated arrays, or values with policies.
70/// When exported to actual environment, these will always be strings.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72#[serde(untagged)]
73pub enum EnvValue {
74    // Value with policies must come first for serde untagged to try it first
75    // (it's an object with a specific "value" + "policies" shape)
76    WithPolicies(EnvVarWithPolicies),
77    // Secret must come before String to parse {"resolver": ...} correctly
78    Secret(crate::secrets::Secret),
79    // Interpolated array must come before simple types
80    Interpolated(Vec<EnvPart>),
81    // Simple values (backward compatible)
82    String(String),
83    Int(i64),
84    Bool(bool),
85}
86
87/// Environment configuration with environment-specific overrides
88/// Based on schema/env.cue
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
90pub struct Env {
91    /// Base environment variables
92    /// Keys must match pattern: ^[A-Z][A-Z0-9_]*$
93    #[serde(flatten)]
94    pub base: HashMap<String, EnvValue>,
95
96    /// Environment-specific overrides
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub environment: Option<HashMap<String, HashMap<String, EnvValue>>>,
99}
100
101impl Env {
102    /// Get environment variables for a specific environment
103    pub fn for_environment(&self, env_name: &str) -> HashMap<String, EnvValue> {
104        let mut result = self.base.clone();
105
106        if let Some(environments) = &self.environment
107            && let Some(env_overrides) = environments.get(env_name)
108        {
109            result.extend(env_overrides.clone());
110        }
111
112        result
113    }
114}
115
116impl EnvValue {
117    /// Check if a task has access to this environment variable
118    pub fn is_accessible_by_task(&self, task_name: &str) -> bool {
119        match self {
120            // Simple values are always accessible
121            EnvValue::String(_)
122            | EnvValue::Int(_)
123            | EnvValue::Bool(_)
124            | EnvValue::Secret(_)
125            | EnvValue::Interpolated(_) => true,
126
127            // Check policies for restricted variables
128            EnvValue::WithPolicies(var) => match &var.policies {
129                None => true,                                  // No policies means accessible
130                Some(policies) if policies.is_empty() => true, // Empty policies means accessible
131                Some(policies) => {
132                    // Check if any policy allows this task
133                    policies.iter().any(|policy| {
134                        policy
135                            .allow_tasks
136                            .as_ref()
137                            .is_some_and(|tasks| tasks.iter().any(|t| t == task_name))
138                    })
139                }
140            },
141        }
142    }
143
144    /// Check if an exec command has access to this environment variable
145    pub fn is_accessible_by_exec(&self, command: &str) -> bool {
146        match self {
147            // Simple values are always accessible
148            EnvValue::String(_)
149            | EnvValue::Int(_)
150            | EnvValue::Bool(_)
151            | EnvValue::Secret(_)
152            | EnvValue::Interpolated(_) => true,
153
154            // Check policies for restricted variables
155            EnvValue::WithPolicies(var) => match &var.policies {
156                None => true,                                  // No policies means accessible
157                Some(policies) if policies.is_empty() => true, // Empty policies means accessible
158                Some(policies) => {
159                    // Check if any policy allows this exec command
160                    policies.iter().any(|policy| {
161                        policy
162                            .allow_exec
163                            .as_ref()
164                            .is_some_and(|execs| execs.iter().any(|e| e == command))
165                    })
166                }
167            },
168        }
169    }
170
171    /// Get the actual string value of the environment variable.
172    /// Secrets are redacted as `*_*` placeholders.
173    pub fn to_string_value(&self) -> String {
174        match self {
175            EnvValue::String(s) => s.clone(),
176            EnvValue::Int(i) => i.to_string(),
177            EnvValue::Bool(b) => b.to_string(),
178            EnvValue::Secret(_) => cuenv_events::REDACTED_PLACEHOLDER.to_string(),
179            EnvValue::Interpolated(parts) => Self::parts_to_string_value(parts),
180            EnvValue::WithPolicies(var) => match &var.value {
181                EnvValueSimple::String(s) => s.clone(),
182                EnvValueSimple::Int(i) => i.to_string(),
183                EnvValueSimple::Bool(b) => b.to_string(),
184                EnvValueSimple::Secret(_) => cuenv_events::REDACTED_PLACEHOLDER.to_string(),
185                EnvValueSimple::Interpolated(parts) => Self::parts_to_string_value(parts),
186            },
187        }
188    }
189
190    /// Convert interpolated parts to a string value with secrets redacted.
191    fn parts_to_string_value(parts: &[EnvPart]) -> String {
192        parts
193            .iter()
194            .map(|p| match p {
195                EnvPart::Literal(s) => s.clone(),
196                EnvPart::Secret(_) => cuenv_events::REDACTED_PLACEHOLDER.to_string(),
197            })
198            .collect()
199    }
200
201    /// Check if this environment value contains any secrets (requires resolution)
202    #[must_use]
203    pub fn is_secret(&self) -> bool {
204        match self {
205            EnvValue::Secret(_) => true,
206            EnvValue::Interpolated(parts) => parts.iter().any(EnvPart::is_secret),
207            EnvValue::WithPolicies(var) => match &var.value {
208                EnvValueSimple::Secret(_) => true,
209                EnvValueSimple::Interpolated(parts) => parts.iter().any(EnvPart::is_secret),
210                _ => false,
211            },
212            _ => false,
213        }
214    }
215
216    /// Resolve the environment variable value, executing secrets if necessary
217    ///
218    /// Secrets with typed resolvers (onepassword, exec, aws, etc.) are resolved
219    /// via the trait-based [`SecretResolver`] system.
220    pub async fn resolve(&self) -> crate::Result<String> {
221        let (resolved, _) = self.resolve_with_secrets().await?;
222        Ok(resolved)
223    }
224
225    /// Resolve the environment variable, returning both the final value
226    /// and a list of resolved secret values (for redaction).
227    ///
228    /// This is the preferred method when you need to track which values
229    /// should be redacted from output.
230    pub async fn resolve_with_secrets(&self) -> crate::Result<(String, Vec<String>)> {
231        match self {
232            EnvValue::String(s) => Ok((s.clone(), vec![])),
233            EnvValue::Int(i) => Ok((i.to_string(), vec![])),
234            EnvValue::Bool(b) => Ok((b.to_string(), vec![])),
235            EnvValue::Secret(s) => {
236                let resolved = s.resolve().await?;
237                Ok((resolved.clone(), vec![resolved]))
238            }
239            EnvValue::Interpolated(parts) => Self::resolve_parts_with_secrets(parts).await,
240            EnvValue::WithPolicies(var) => Self::resolve_simple_with_secrets(&var.value).await,
241        }
242    }
243
244    /// Resolve interpolated parts, returning the concatenated result and secret values.
245    async fn resolve_parts_with_secrets(parts: &[EnvPart]) -> crate::Result<(String, Vec<String>)> {
246        let mut result = String::new();
247        let mut secrets = Vec::new();
248
249        for part in parts {
250            match part {
251                EnvPart::Literal(s) => result.push_str(s),
252                EnvPart::Secret(s) => {
253                    let resolved = s.resolve().await?;
254                    result.push_str(&resolved);
255                    secrets.push(resolved);
256                }
257            }
258        }
259
260        Ok((result, secrets))
261    }
262
263    /// Resolve a simple value (used by WithPolicies variant).
264    async fn resolve_simple_with_secrets(
265        value: &EnvValueSimple,
266    ) -> crate::Result<(String, Vec<String>)> {
267        match value {
268            EnvValueSimple::String(s) => Ok((s.clone(), vec![])),
269            EnvValueSimple::Int(i) => Ok((i.to_string(), vec![])),
270            EnvValueSimple::Bool(b) => Ok((b.to_string(), vec![])),
271            EnvValueSimple::Secret(s) => {
272                let resolved = s.resolve().await?;
273                Ok((resolved.clone(), vec![resolved]))
274            }
275            EnvValueSimple::Interpolated(parts) => Self::resolve_parts_with_secrets(parts).await,
276        }
277    }
278}
279
280/// Runtime environment variables for task execution
281#[derive(Debug, Clone, Serialize, Deserialize, Default)]
282pub struct Environment {
283    /// Map of environment variable names to values
284    #[serde(flatten)]
285    pub vars: HashMap<String, String>,
286}
287
288impl Environment {
289    /// Create a new empty environment
290    pub fn new() -> Self {
291        Self::default()
292    }
293
294    /// Create environment from a map
295    pub fn from_map(vars: HashMap<String, String>) -> Self {
296        Self { vars }
297    }
298
299    /// Get an environment variable value
300    pub fn get(&self, key: &str) -> Option<&str> {
301        self.vars.get(key).map(|s| s.as_str())
302    }
303
304    /// Set an environment variable
305    pub fn set(&mut self, key: String, value: String) {
306        self.vars.insert(key, value);
307    }
308
309    /// Check if an environment variable exists
310    pub fn contains(&self, key: &str) -> bool {
311        self.vars.contains_key(key)
312    }
313
314    /// Get all environment variables as a vector of key=value strings
315    pub fn to_env_vec(&self) -> Vec<String> {
316        self.vars
317            .iter()
318            .map(|(k, v)| format!("{}={}", k, v))
319            .collect()
320    }
321
322    /// Merge with system environment variables
323    /// CUE environment variables take precedence
324    pub fn merge_with_system(&self) -> HashMap<String, String> {
325        let mut merged: HashMap<String, String> = env::vars().collect();
326
327        // Override with CUE environment variables
328        for (key, value) in &self.vars {
329            merged.insert(key.clone(), value.clone());
330        }
331
332        merged
333    }
334
335    /// Essential system variables to preserve in hermetic mode.
336    /// These are required for basic process operation but don't pollute PATH.
337    const HERMETIC_ALLOWED_VARS: &'static [&'static str] = &[
338        "HOME",
339        "USER",
340        "LOGNAME",
341        "SHELL",
342        "TERM",
343        "COLORTERM",
344        "LANG",
345        "LC_ALL",
346        "LC_CTYPE",
347        "LC_MESSAGES",
348        "TMPDIR",
349        "TMP",
350        "TEMP",
351        "XDG_RUNTIME_DIR",
352        "XDG_CONFIG_HOME",
353        "XDG_CACHE_HOME",
354        "XDG_DATA_HOME",
355    ];
356
357    /// Merge with only essential system environment variables (hermetic mode).
358    ///
359    /// Unlike `merge_with_system()`, this excludes PATH and other potentially
360    /// polluting variables. PATH should come from cuenv tools activation.
361    ///
362    /// Included variables:
363    /// - User identity: HOME, USER, LOGNAME, SHELL
364    /// - Terminal: TERM, COLORTERM
365    /// - Locale: LANG, LC_* variables
366    /// - Temp directories: TMPDIR, TMP, TEMP
367    /// - XDG directories: XDG_RUNTIME_DIR, XDG_CONFIG_HOME, etc.
368    pub fn merge_with_system_hermetic(&self) -> HashMap<String, String> {
369        let mut merged: HashMap<String, String> = HashMap::new();
370
371        // Only include allowed system variables
372        for var in Self::HERMETIC_ALLOWED_VARS {
373            if let Ok(value) = env::var(var) {
374                merged.insert((*var).to_string(), value);
375            }
376        }
377
378        // Also include any LC_* variables (locale settings)
379        for (key, value) in env::vars() {
380            if key.starts_with("LC_") {
381                merged.insert(key, value);
382            }
383        }
384
385        // Override with CUE environment variables (including cuenv-constructed PATH)
386        for (key, value) in &self.vars {
387            merged.insert(key.clone(), value.clone());
388        }
389
390        merged
391    }
392
393    /// Convert to a vector of key=value strings including system environment
394    pub fn to_full_env_vec(&self) -> Vec<String> {
395        self.merge_with_system()
396            .iter()
397            .map(|(k, v)| format!("{}={}", k, v))
398            .collect()
399    }
400
401    /// Get the number of environment variables
402    pub fn len(&self) -> usize {
403        self.vars.len()
404    }
405
406    /// Check if the environment is empty
407    pub fn is_empty(&self) -> bool {
408        self.vars.is_empty()
409    }
410
411    /// Resolve a command to its full path using this environment's PATH.
412    /// This is necessary because when spawning a process, the OS looks up
413    /// the executable in the current process's PATH, not the environment
414    /// that will be set on the child process.
415    ///
416    /// Returns the full path if found, or the original command if not found
417    /// (letting the spawn fail with a proper error).
418    pub fn resolve_command(&self, command: &str) -> String {
419        // If command is already an absolute path, use it directly
420        if command.starts_with('/') {
421            tracing::debug!(command = %command, "Command is already absolute path");
422            return command.to_string();
423        }
424
425        // Get the PATH from this environment, falling back to system PATH
426        let path_value = self
427            .vars
428            .get("PATH")
429            .cloned()
430            .or_else(|| env::var("PATH").ok())
431            .unwrap_or_default();
432
433        tracing::debug!(
434            command = %command,
435            env_has_path = self.vars.contains_key("PATH"),
436            path_len = path_value.len(),
437            "Resolving command in PATH"
438        );
439
440        // Search for the command in each PATH directory
441        for dir in path_value.split(':') {
442            if dir.is_empty() {
443                continue;
444            }
445            let candidate = std::path::Path::new(dir).join(command);
446            if candidate.is_file() {
447                // Check if it's executable (on Unix)
448                #[cfg(unix)]
449                {
450                    use std::os::unix::fs::PermissionsExt;
451                    if let Ok(metadata) = std::fs::metadata(&candidate) {
452                        let permissions = metadata.permissions();
453                        if permissions.mode() & 0o111 != 0 {
454                            tracing::debug!(
455                                command = %command,
456                                resolved = %candidate.display(),
457                                "Command resolved to path"
458                            );
459                            return candidate.to_string_lossy().to_string();
460                        }
461                    }
462                }
463                #[cfg(not(unix))]
464                {
465                    tracing::debug!(
466                        command = %command,
467                        resolved = %candidate.display(),
468                        "Command resolved to path"
469                    );
470                    return candidate.to_string_lossy().to_string();
471                }
472            }
473        }
474
475        // Command not found in environment PATH - try system PATH as fallback
476        // This is necessary when the environment has tool paths but not system paths,
477        // since we still need to find system commands like echo, bash, etc.
478        if self.vars.contains_key("PATH")
479            && let Ok(system_path) = env::var("PATH")
480        {
481            tracing::debug!(
482                command = %command,
483                "Command not found in env PATH, trying system PATH"
484            );
485            for dir in system_path.split(':') {
486                if dir.is_empty() {
487                    continue;
488                }
489                let candidate = std::path::Path::new(dir).join(command);
490                if candidate.is_file() {
491                    #[cfg(unix)]
492                    {
493                        use std::os::unix::fs::PermissionsExt;
494                        if let Ok(metadata) = std::fs::metadata(&candidate) {
495                            let permissions = metadata.permissions();
496                            if permissions.mode() & 0o111 != 0 {
497                                tracing::debug!(
498                                    command = %command,
499                                    resolved = %candidate.display(),
500                                    "Command resolved from system PATH"
501                                );
502                                return candidate.to_string_lossy().to_string();
503                            }
504                        }
505                    }
506                    #[cfg(not(unix))]
507                    {
508                        tracing::debug!(
509                            command = %command,
510                            resolved = %candidate.display(),
511                            "Command resolved from system PATH"
512                        );
513                        return candidate.to_string_lossy().to_string();
514                    }
515                }
516            }
517        }
518
519        // Command not found in any PATH, return original (spawn will fail with proper error)
520        tracing::warn!(
521            command = %command,
522            env_path_set = self.vars.contains_key("PATH"),
523            "Command not found in PATH, returning original"
524        );
525        command.to_string()
526    }
527
528    /// Iterate over environment variables
529    pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
530        self.vars.iter()
531    }
532
533    /// Build environment for a task, filtering based on policies
534    pub fn build_for_task(
535        task_name: &str,
536        env_vars: &HashMap<String, EnvValue>,
537    ) -> HashMap<String, String> {
538        env_vars
539            .iter()
540            .filter(|(_, value)| value.is_accessible_by_task(task_name))
541            .map(|(key, value)| (key.clone(), value.to_string_value()))
542            .collect()
543    }
544
545    /// Build and resolve environment for a task, filtering based on policies
546    ///
547    /// Resolves all environment variables including secrets via the
548    /// trait-based resolver system.
549    pub async fn resolve_for_task(
550        task_name: &str,
551        env_vars: &HashMap<String, EnvValue>,
552    ) -> crate::Result<HashMap<String, String>> {
553        let (resolved, _secrets) = Self::resolve_for_task_with_secrets(task_name, env_vars).await?;
554        Ok(resolved)
555    }
556
557    /// Build and resolve environment for a task, also returning secret values
558    ///
559    /// Returns `(resolved_env_vars, secret_values)` where `secret_values` contains
560    /// the resolved values of any secrets, for use in output redaction.
561    /// For interpolated values, only the actual secret parts are collected for redaction,
562    /// not the full interpolated string.
563    pub async fn resolve_for_task_with_secrets(
564        task_name: &str,
565        env_vars: &HashMap<String, EnvValue>,
566    ) -> crate::Result<(HashMap<String, String>, Vec<String>)> {
567        let mut resolved = HashMap::new();
568        let mut secrets = Vec::new();
569
570        tracing::debug!(
571            task = task_name,
572            env_count = env_vars.len(),
573            "resolve_for_task_with_secrets"
574        );
575        for (key, value) in env_vars {
576            tracing::debug!(
577                key = key,
578                is_secret = value.is_secret(),
579                accessible = value.is_accessible_by_task(task_name),
580                "checking env var"
581            );
582            if value.is_accessible_by_task(task_name) {
583                let (resolved_value, mut value_secrets) = value.resolve_with_secrets().await?;
584                if !value_secrets.is_empty() {
585                    tracing::debug!(
586                        key = key,
587                        secret_count = value_secrets.len(),
588                        "resolved secrets"
589                    );
590                }
591                secrets.append(&mut value_secrets);
592                resolved.insert(key.clone(), resolved_value);
593            }
594        }
595        Ok((resolved, secrets))
596    }
597
598    /// Build environment for exec command, filtering based on policies
599    pub fn build_for_exec(
600        command: &str,
601        env_vars: &HashMap<String, EnvValue>,
602    ) -> HashMap<String, String> {
603        env_vars
604            .iter()
605            .filter(|(_, value)| value.is_accessible_by_exec(command))
606            .map(|(key, value)| (key.clone(), value.to_string_value()))
607            .collect()
608    }
609
610    /// Build and resolve environment for exec command, filtering based on policies
611    ///
612    /// Resolves all environment variables including secrets via the
613    /// trait-based resolver system.
614    pub async fn resolve_for_exec(
615        command: &str,
616        env_vars: &HashMap<String, EnvValue>,
617    ) -> crate::Result<HashMap<String, String>> {
618        let (resolved, _secrets) = Self::resolve_for_exec_with_secrets(command, env_vars).await?;
619        Ok(resolved)
620    }
621
622    /// Build and resolve environment for exec command, also returning secret values
623    ///
624    /// Returns `(resolved_env_vars, secret_values)` where `secret_values` contains
625    /// the resolved values of any secrets, for use in output redaction.
626    /// For interpolated values, only the actual secret parts are collected for redaction,
627    /// not the full interpolated string.
628    pub async fn resolve_for_exec_with_secrets(
629        command: &str,
630        env_vars: &HashMap<String, EnvValue>,
631    ) -> crate::Result<(HashMap<String, String>, Vec<String>)> {
632        let mut resolved = HashMap::new();
633        let mut secrets = Vec::new();
634
635        for (key, value) in env_vars {
636            if value.is_accessible_by_exec(command) {
637                let (resolved_value, mut value_secrets) = value.resolve_with_secrets().await?;
638                secrets.append(&mut value_secrets);
639                resolved.insert(key.clone(), resolved_value);
640            }
641        }
642        Ok((resolved, secrets))
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn test_environment_basics() {
652        let mut env = Environment::new();
653        assert!(env.is_empty());
654
655        env.set("FOO".to_string(), "bar".to_string());
656        assert_eq!(env.len(), 1);
657        assert!(env.contains("FOO"));
658        assert_eq!(env.get("FOO"), Some("bar"));
659        assert!(!env.contains("BAR"));
660    }
661
662    #[test]
663    fn test_environment_from_map() {
664        let mut vars = HashMap::new();
665        vars.insert("KEY1".to_string(), "value1".to_string());
666        vars.insert("KEY2".to_string(), "value2".to_string());
667
668        let env = Environment::from_map(vars);
669        assert_eq!(env.len(), 2);
670        assert_eq!(env.get("KEY1"), Some("value1"));
671        assert_eq!(env.get("KEY2"), Some("value2"));
672    }
673
674    #[test]
675    fn test_environment_to_vec() {
676        let mut env = Environment::new();
677        env.set("VAR1".to_string(), "val1".to_string());
678        env.set("VAR2".to_string(), "val2".to_string());
679
680        let vec = env.to_env_vec();
681        assert_eq!(vec.len(), 2);
682        assert!(vec.contains(&"VAR1=val1".to_string()));
683        assert!(vec.contains(&"VAR2=val2".to_string()));
684    }
685
686    #[test]
687    fn test_environment_merge_with_system() {
688        let mut env = Environment::new();
689        env.set("PATH".to_string(), "/custom/path".to_string());
690        env.set("CUSTOM_VAR".to_string(), "custom_value".to_string());
691
692        let merged = env.merge_with_system();
693
694        // Custom variables should be present
695        assert_eq!(merged.get("PATH"), Some(&"/custom/path".to_string()));
696        assert_eq!(merged.get("CUSTOM_VAR"), Some(&"custom_value".to_string()));
697
698        // System variables should still be present (like HOME, USER, etc.)
699        // We can't test specific values but we can check that merging happened
700        assert!(merged.len() >= 2);
701    }
702
703    #[test]
704    fn test_environment_iteration() {
705        let mut env = Environment::new();
706        env.set("A".to_string(), "1".to_string());
707        env.set("B".to_string(), "2".to_string());
708
709        let mut count = 0;
710        for (key, value) in env.iter() {
711            assert!(key == "A" || key == "B");
712            assert!(value == "1" || value == "2");
713            count += 1;
714        }
715        assert_eq!(count, 2);
716    }
717
718    #[test]
719    fn test_env_value_types() {
720        let str_val = EnvValue::String("test".to_string());
721        let int_val = EnvValue::Int(42);
722        let bool_val = EnvValue::Bool(true);
723
724        assert_eq!(str_val, EnvValue::String("test".to_string()));
725        assert_eq!(int_val, EnvValue::Int(42));
726        assert_eq!(bool_val, EnvValue::Bool(true));
727    }
728
729    #[test]
730    fn test_policy_task_access() {
731        // Simple value - always accessible
732        let simple_var = EnvValue::String("simple".to_string());
733        assert!(simple_var.is_accessible_by_task("any_task"));
734
735        // Variable with no policies - accessible
736        let no_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
737            value: EnvValueSimple::String("value".to_string()),
738            policies: None,
739        });
740        assert!(no_policy_var.is_accessible_by_task("any_task"));
741
742        // Variable with empty policies - accessible
743        let empty_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
744            value: EnvValueSimple::String("value".to_string()),
745            policies: Some(vec![]),
746        });
747        assert!(empty_policy_var.is_accessible_by_task("any_task"));
748
749        // Variable with task restrictions
750        let restricted_var = EnvValue::WithPolicies(EnvVarWithPolicies {
751            value: EnvValueSimple::String("secret".to_string()),
752            policies: Some(vec![Policy {
753                allow_tasks: Some(vec!["deploy".to_string(), "release".to_string()]),
754                allow_exec: None,
755            }]),
756        });
757        assert!(restricted_var.is_accessible_by_task("deploy"));
758        assert!(restricted_var.is_accessible_by_task("release"));
759        assert!(!restricted_var.is_accessible_by_task("test"));
760        assert!(!restricted_var.is_accessible_by_task("build"));
761    }
762
763    #[test]
764    fn test_policy_exec_access() {
765        // Simple value - always accessible
766        let simple_var = EnvValue::String("simple".to_string());
767        assert!(simple_var.is_accessible_by_exec("bash"));
768
769        // Variable with exec restrictions
770        let restricted_var = EnvValue::WithPolicies(EnvVarWithPolicies {
771            value: EnvValueSimple::String("secret".to_string()),
772            policies: Some(vec![Policy {
773                allow_tasks: None,
774                allow_exec: Some(vec!["kubectl".to_string(), "terraform".to_string()]),
775            }]),
776        });
777        assert!(restricted_var.is_accessible_by_exec("kubectl"));
778        assert!(restricted_var.is_accessible_by_exec("terraform"));
779        assert!(!restricted_var.is_accessible_by_exec("bash"));
780        assert!(!restricted_var.is_accessible_by_exec("sh"));
781    }
782
783    #[test]
784    fn test_multiple_policies() {
785        // Variable with multiple policies - should allow if ANY policy allows
786        let multi_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
787            value: EnvValueSimple::String("value".to_string()),
788            policies: Some(vec![
789                Policy {
790                    allow_tasks: Some(vec!["task1".to_string()]),
791                    allow_exec: None,
792                },
793                Policy {
794                    allow_tasks: Some(vec!["task2".to_string()]),
795                    allow_exec: Some(vec!["kubectl".to_string()]),
796                },
797            ]),
798        });
799
800        // Task access - either policy allows
801        assert!(multi_policy_var.is_accessible_by_task("task1"));
802        assert!(multi_policy_var.is_accessible_by_task("task2"));
803        assert!(!multi_policy_var.is_accessible_by_task("task3"));
804
805        // Exec access - only second policy has exec rules
806        assert!(multi_policy_var.is_accessible_by_exec("kubectl"));
807        assert!(!multi_policy_var.is_accessible_by_exec("bash"));
808    }
809
810    #[test]
811    fn test_to_string_value() {
812        assert_eq!(
813            EnvValue::String("test".to_string()).to_string_value(),
814            "test"
815        );
816        assert_eq!(EnvValue::Int(42).to_string_value(), "42");
817        assert_eq!(EnvValue::Bool(true).to_string_value(), "true");
818        assert_eq!(EnvValue::Bool(false).to_string_value(), "false");
819
820        let with_policies = EnvValue::WithPolicies(EnvVarWithPolicies {
821            value: EnvValueSimple::String("policy_value".to_string()),
822            policies: Some(vec![]),
823        });
824        assert_eq!(with_policies.to_string_value(), "policy_value");
825    }
826
827    #[test]
828    fn test_build_for_task() {
829        let mut env_vars = HashMap::new();
830
831        // Unrestricted variable
832        env_vars.insert(
833            "PUBLIC".to_string(),
834            EnvValue::String("public_value".to_string()),
835        );
836
837        // Restricted variable
838        env_vars.insert(
839            "SECRET".to_string(),
840            EnvValue::WithPolicies(EnvVarWithPolicies {
841                value: EnvValueSimple::String("secret_value".to_string()),
842                policies: Some(vec![Policy {
843                    allow_tasks: Some(vec!["deploy".to_string()]),
844                    allow_exec: None,
845                }]),
846            }),
847        );
848
849        // Build for deploy task - should get both
850        let deploy_env = Environment::build_for_task("deploy", &env_vars);
851        assert_eq!(deploy_env.len(), 2);
852        assert_eq!(deploy_env.get("PUBLIC"), Some(&"public_value".to_string()));
853        assert_eq!(deploy_env.get("SECRET"), Some(&"secret_value".to_string()));
854
855        // Build for test task - should only get public
856        let test_env = Environment::build_for_task("test", &env_vars);
857        assert_eq!(test_env.len(), 1);
858        assert_eq!(test_env.get("PUBLIC"), Some(&"public_value".to_string()));
859        assert_eq!(test_env.get("SECRET"), None);
860    }
861
862    #[test]
863    fn test_build_for_exec() {
864        let mut env_vars = HashMap::new();
865
866        // Unrestricted variable
867        env_vars.insert(
868            "PUBLIC".to_string(),
869            EnvValue::String("public_value".to_string()),
870        );
871
872        // Restricted variable
873        env_vars.insert(
874            "SECRET".to_string(),
875            EnvValue::WithPolicies(EnvVarWithPolicies {
876                value: EnvValueSimple::String("secret_value".to_string()),
877                policies: Some(vec![Policy {
878                    allow_tasks: None,
879                    allow_exec: Some(vec!["kubectl".to_string()]),
880                }]),
881            }),
882        );
883
884        // Build for kubectl - should get both
885        let kubectl_env = Environment::build_for_exec("kubectl", &env_vars);
886        assert_eq!(kubectl_env.len(), 2);
887        assert_eq!(kubectl_env.get("PUBLIC"), Some(&"public_value".to_string()));
888        assert_eq!(kubectl_env.get("SECRET"), Some(&"secret_value".to_string()));
889
890        // Build for bash - should only get public
891        let bash_env = Environment::build_for_exec("bash", &env_vars);
892        assert_eq!(bash_env.len(), 1);
893        assert_eq!(bash_env.get("PUBLIC"), Some(&"public_value".to_string()));
894        assert_eq!(bash_env.get("SECRET"), None);
895    }
896
897    #[test]
898    fn test_env_for_environment() {
899        let mut base = HashMap::new();
900        base.insert("BASE_VAR".to_string(), EnvValue::String("base".to_string()));
901        base.insert(
902            "OVERRIDE_ME".to_string(),
903            EnvValue::String("original".to_string()),
904        );
905
906        let mut dev_env = HashMap::new();
907        dev_env.insert(
908            "OVERRIDE_ME".to_string(),
909            EnvValue::String("dev".to_string()),
910        );
911        dev_env.insert(
912            "DEV_VAR".to_string(),
913            EnvValue::String("development".to_string()),
914        );
915
916        let mut environments = HashMap::new();
917        environments.insert("development".to_string(), dev_env);
918
919        let env = Env {
920            base,
921            environment: Some(environments),
922        };
923
924        let dev_vars = env.for_environment("development");
925        assert_eq!(
926            dev_vars.get("BASE_VAR"),
927            Some(&EnvValue::String("base".to_string()))
928        );
929        assert_eq!(
930            dev_vars.get("OVERRIDE_ME"),
931            Some(&EnvValue::String("dev".to_string()))
932        );
933        assert_eq!(
934            dev_vars.get("DEV_VAR"),
935            Some(&EnvValue::String("development".to_string()))
936        );
937    }
938
939    #[tokio::test]
940    async fn test_resolve_plain_string() {
941        let env_val = EnvValue::String("plain_value".to_string());
942        let resolved = env_val.resolve().await.unwrap();
943        assert_eq!(resolved, "plain_value");
944    }
945
946    #[tokio::test]
947    async fn test_resolve_int() {
948        let env_val = EnvValue::Int(42);
949        let resolved = env_val.resolve().await.unwrap();
950        assert_eq!(resolved, "42");
951    }
952
953    #[tokio::test]
954    async fn test_resolve_bool() {
955        let env_val = EnvValue::Bool(true);
956        let resolved = env_val.resolve().await.unwrap();
957        assert_eq!(resolved, "true");
958    }
959
960    #[tokio::test]
961    async fn test_resolve_with_policies_plain_string() {
962        let env_val = EnvValue::WithPolicies(EnvVarWithPolicies {
963            value: EnvValueSimple::String("policy_value".to_string()),
964            policies: None,
965        });
966        let resolved = env_val.resolve().await.unwrap();
967        assert_eq!(resolved, "policy_value");
968    }
969
970    // ==========================================================================
971    // Interpolation tests
972    // ==========================================================================
973
974    #[test]
975    fn test_env_part_literal() {
976        let part = EnvPart::Literal("hello".to_string());
977        assert!(!part.is_secret());
978    }
979
980    #[test]
981    fn test_env_part_secret() {
982        let secret = crate::secrets::Secret::new("echo".to_string(), vec!["test".to_string()]);
983        let part = EnvPart::Secret(secret);
984        assert!(part.is_secret());
985    }
986
987    #[test]
988    fn test_env_part_deserialization_literal() {
989        let json = r#""hello""#;
990        let part: EnvPart = serde_json::from_str(json).unwrap();
991        assert!(matches!(part, EnvPart::Literal(ref s) if s == "hello"));
992        assert!(!part.is_secret());
993    }
994
995    #[test]
996    fn test_env_part_deserialization_secret() {
997        let json = r#"{"resolver": "exec", "command": "echo", "args": ["test"]}"#;
998        let part: EnvPart = serde_json::from_str(json).unwrap();
999        assert!(part.is_secret());
1000    }
1001
1002    #[test]
1003    fn test_env_value_interpolated_deserialization() {
1004        let json =
1005            r#"["prefix-", {"resolver": "exec", "command": "gh", "args": ["auth", "token"]}]"#;
1006        let value: EnvValue = serde_json::from_str(json).unwrap();
1007        assert!(matches!(value, EnvValue::Interpolated(_)));
1008        assert!(value.is_secret());
1009    }
1010
1011    #[test]
1012    fn test_interpolated_is_secret_with_no_secrets() {
1013        let parts = vec![
1014            EnvPart::Literal("hello".to_string()),
1015            EnvPart::Literal("world".to_string()),
1016        ];
1017        let value = EnvValue::Interpolated(parts);
1018        assert!(!value.is_secret());
1019    }
1020
1021    #[test]
1022    fn test_interpolated_is_secret_with_secret() {
1023        let secret = crate::secrets::Secret::new("echo".to_string(), vec![]);
1024        let parts = vec![
1025            EnvPart::Literal("prefix".to_string()),
1026            EnvPart::Secret(secret),
1027        ];
1028        let value = EnvValue::Interpolated(parts);
1029        assert!(value.is_secret());
1030    }
1031
1032    #[test]
1033    fn test_interpolated_to_string_value_redacts_secrets() {
1034        let secret = crate::secrets::Secret::new(
1035            "gh".to_string(),
1036            vec!["auth".to_string(), "token".to_string()],
1037        );
1038        let parts = vec![
1039            EnvPart::Literal("access-tokens = github.com=".to_string()),
1040            EnvPart::Secret(secret),
1041        ];
1042        let value = EnvValue::Interpolated(parts);
1043        assert_eq!(value.to_string_value(), "access-tokens = github.com=*_*");
1044    }
1045
1046    #[test]
1047    fn test_interpolated_to_string_value_no_secrets() {
1048        let parts = vec![
1049            EnvPart::Literal("hello".to_string()),
1050            EnvPart::Literal("-".to_string()),
1051            EnvPart::Literal("world".to_string()),
1052        ];
1053        let value = EnvValue::Interpolated(parts);
1054        assert_eq!(value.to_string_value(), "hello-world");
1055    }
1056
1057    #[tokio::test]
1058    async fn test_resolve_with_secrets_collects_only_secret_parts() {
1059        // Test that only actual secret values are collected for redaction,
1060        // not the full interpolated string (when there are no secrets)
1061        let parts = vec![
1062            EnvPart::Literal("hello-".to_string()),
1063            EnvPart::Literal("world".to_string()),
1064        ];
1065        let value = EnvValue::Interpolated(parts);
1066        let (resolved, secrets) = value.resolve_with_secrets().await.unwrap();
1067        assert_eq!(resolved, "hello-world");
1068        assert!(secrets.is_empty()); // No secrets to redact
1069    }
1070
1071    #[tokio::test]
1072    async fn test_resolve_interpolated_concatenates_parts() {
1073        let parts = vec![
1074            EnvPart::Literal("a".to_string()),
1075            EnvPart::Literal("b".to_string()),
1076            EnvPart::Literal("c".to_string()),
1077        ];
1078        let value = EnvValue::Interpolated(parts);
1079        let resolved = value.resolve().await.unwrap();
1080        assert_eq!(resolved, "abc");
1081    }
1082
1083    #[test]
1084    fn test_interpolated_with_policies_is_secret() {
1085        let secret = crate::secrets::Secret::new("cmd".to_string(), vec![]);
1086        let parts = vec![
1087            EnvPart::Literal("prefix".to_string()),
1088            EnvPart::Secret(secret),
1089        ];
1090
1091        let value = EnvValue::WithPolicies(EnvVarWithPolicies {
1092            value: EnvValueSimple::Interpolated(parts),
1093            policies: Some(vec![Policy {
1094                allow_tasks: Some(vec!["deploy".to_string()]),
1095                allow_exec: None,
1096            }]),
1097        });
1098
1099        assert!(value.is_secret());
1100    }
1101
1102    #[test]
1103    fn test_interpolated_with_policies_to_string_value() {
1104        let secret = crate::secrets::Secret::new("cmd".to_string(), vec![]);
1105        let parts = vec![
1106            EnvPart::Literal("before-".to_string()),
1107            EnvPart::Secret(secret),
1108            EnvPart::Literal("-after".to_string()),
1109        ];
1110
1111        let value = EnvValue::WithPolicies(EnvVarWithPolicies {
1112            value: EnvValueSimple::Interpolated(parts),
1113            policies: None,
1114        });
1115
1116        assert_eq!(value.to_string_value(), "before-*_*-after");
1117    }
1118
1119    #[test]
1120    fn test_interpolated_accessible_by_task() {
1121        let parts = vec![EnvPart::Literal("value".to_string())];
1122        let value = EnvValue::Interpolated(parts);
1123        // Interpolated values without policies are always accessible
1124        assert!(value.is_accessible_by_task("any_task"));
1125    }
1126
1127    #[test]
1128    fn test_extract_static_env_vars_skips_interpolated_secrets() {
1129        // Simulate the extract_static_env_vars logic
1130        let secret = crate::secrets::Secret::new("cmd".to_string(), vec![]);
1131        let parts = vec![
1132            EnvPart::Literal("prefix".to_string()),
1133            EnvPart::Secret(secret),
1134        ];
1135
1136        let mut base = HashMap::new();
1137        base.insert("PLAIN".to_string(), EnvValue::String("value".to_string()));
1138        base.insert(
1139            "INTERPOLATED_SECRET".to_string(),
1140            EnvValue::Interpolated(parts),
1141        );
1142        base.insert(
1143            "INTERPOLATED_PLAIN".to_string(),
1144            EnvValue::Interpolated(vec![
1145                EnvPart::Literal("a".to_string()),
1146                EnvPart::Literal("b".to_string()),
1147            ]),
1148        );
1149
1150        // Filter out secrets (simulating extract_static_env_vars logic)
1151        let vars: HashMap<_, _> = base
1152            .iter()
1153            .filter(|(_, v)| !v.is_secret())
1154            .map(|(k, v)| (k.clone(), v.to_string_value()))
1155            .collect();
1156
1157        assert!(vars.contains_key("PLAIN"));
1158        assert!(!vars.contains_key("INTERPOLATED_SECRET"));
1159        assert!(vars.contains_key("INTERPOLATED_PLAIN"));
1160        assert_eq!(vars.get("INTERPOLATED_PLAIN"), Some(&"ab".to_string()));
1161    }
1162
1163    #[test]
1164    fn test_env_value_simple_interpolated_deserialization() {
1165        // Test that EnvValueSimple can deserialize interpolated arrays
1166        let json = r#"["a", "b", "c"]"#;
1167        let value: EnvValueSimple = serde_json::from_str(json).unwrap();
1168        assert!(matches!(value, EnvValueSimple::Interpolated(_)));
1169    }
1170
1171    #[test]
1172    fn test_env_value_with_policies_interpolated_deserialization() {
1173        let json = r#"{
1174            "value": ["prefix-", {"resolver": "exec", "command": "gh", "args": ["auth", "token"]}],
1175            "policies": [{"allowTasks": ["deploy"]}]
1176        }"#;
1177        let value: EnvValue = serde_json::from_str(json).unwrap();
1178        assert!(matches!(value, EnvValue::WithPolicies(_)));
1179        assert!(value.is_secret());
1180    }
1181
1182    #[test]
1183    fn test_interpolated_empty_array() {
1184        let parts = vec![];
1185        let value = EnvValue::Interpolated(parts);
1186        assert_eq!(value.to_string_value(), "");
1187        assert!(!value.is_secret());
1188    }
1189
1190    #[tokio::test]
1191    async fn test_resolve_interpolated_with_actual_secret() {
1192        let secret =
1193            crate::secrets::Secret::new("echo".to_string(), vec!["secret_value".to_string()]);
1194        let parts = vec![
1195            EnvPart::Literal("prefix-".to_string()),
1196            EnvPart::Secret(secret),
1197            EnvPart::Literal("-suffix".to_string()),
1198        ];
1199        let value = EnvValue::Interpolated(parts);
1200        let (resolved, secrets) = value.resolve_with_secrets().await.unwrap();
1201
1202        assert!(resolved.contains("prefix-"));
1203        assert!(resolved.contains("secret_value"));
1204        assert!(resolved.contains("-suffix"));
1205        assert_eq!(secrets.len(), 1);
1206    }
1207}