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