cuenv_core/
environment.rs

1//! Environment management for cuenv
2//!
3//! This module handles environment variables from CUE configurations,
4//! including extraction, propagation, and environment-specific overrides.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::env;
9
10/// Policy for controlling environment variable access
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct Policy {
13    /// Allowlist of task names that can access this variable
14    #[serde(skip_serializing_if = "Option::is_none", rename = "allowTasks")]
15    pub allow_tasks: Option<Vec<String>>,
16
17    /// Allowlist of exec commands that can access this variable
18    #[serde(skip_serializing_if = "Option::is_none", rename = "allowExec")]
19    pub allow_exec: Option<Vec<String>>,
20}
21
22/// Environment variable with optional access policies
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct EnvVarWithPolicies {
25    /// The actual value
26    pub value: EnvValueSimple,
27
28    /// Optional access policies
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub policies: Option<Vec<Policy>>,
31}
32
33/// Simple environment variable values (non-recursive)
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35#[serde(untagged)]
36pub enum EnvValueSimple {
37    String(String),
38    Int(i64),
39    Bool(bool),
40    Secret(crate::secrets::Secret),
41}
42
43/// Environment variable values can be strings, integers, booleans, secrets, or values with policies
44/// When exported to actual environment, these will always be strings
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46#[serde(untagged)]
47pub enum EnvValue {
48    // Value with policies must come first for serde untagged to try it first
49    WithPolicies(EnvVarWithPolicies),
50    // Simple values (backward compatible)
51    String(String),
52    Int(i64),
53    Bool(bool),
54    Secret(crate::secrets::Secret),
55}
56
57/// Environment configuration with environment-specific overrides
58/// Based on schema/env.cue
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
60pub struct Env {
61    /// Base environment variables
62    /// Keys must match pattern: ^[A-Z][A-Z0-9_]*$
63    #[serde(flatten)]
64    pub base: HashMap<String, EnvValue>,
65
66    /// Environment-specific overrides
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub environment: Option<HashMap<String, HashMap<String, EnvValue>>>,
69}
70
71impl Env {
72    /// Get environment variables for a specific environment
73    pub fn for_environment(&self, env_name: &str) -> HashMap<String, EnvValue> {
74        let mut result = self.base.clone();
75
76        if let Some(environments) = &self.environment
77            && let Some(env_overrides) = environments.get(env_name)
78        {
79            result.extend(env_overrides.clone());
80        }
81
82        result
83    }
84}
85
86impl EnvValue {
87    /// Check if a task has access to this environment variable
88    pub fn is_accessible_by_task(&self, task_name: &str) -> bool {
89        match self {
90            // Simple values are always accessible
91            EnvValue::String(_) | EnvValue::Int(_) | EnvValue::Bool(_) | EnvValue::Secret(_) => {
92                true
93            }
94
95            // Check policies for restricted variables
96            EnvValue::WithPolicies(var) => match &var.policies {
97                None => true,                                  // No policies means accessible
98                Some(policies) if policies.is_empty() => true, // Empty policies means accessible
99                Some(policies) => {
100                    // Check if any policy allows this task
101                    policies.iter().any(|policy| {
102                        policy
103                            .allow_tasks
104                            .as_ref()
105                            .is_some_and(|tasks| tasks.iter().any(|t| t == task_name))
106                    })
107                }
108            },
109        }
110    }
111
112    /// Check if an exec command has access to this environment variable
113    pub fn is_accessible_by_exec(&self, command: &str) -> bool {
114        match self {
115            // Simple values are always accessible
116            EnvValue::String(_) | EnvValue::Int(_) | EnvValue::Bool(_) | EnvValue::Secret(_) => {
117                true
118            }
119
120            // Check policies for restricted variables
121            EnvValue::WithPolicies(var) => match &var.policies {
122                None => true,                                  // No policies means accessible
123                Some(policies) if policies.is_empty() => true, // Empty policies means accessible
124                Some(policies) => {
125                    // Check if any policy allows this exec command
126                    policies.iter().any(|policy| {
127                        policy
128                            .allow_exec
129                            .as_ref()
130                            .is_some_and(|execs| execs.iter().any(|e| e == command))
131                    })
132                }
133            },
134        }
135    }
136
137    /// Get the actual string value of the environment variable
138    pub fn to_string_value(&self) -> String {
139        match self {
140            EnvValue::String(s) => s.clone(),
141            EnvValue::Int(i) => i.to_string(),
142            EnvValue::Bool(b) => b.to_string(),
143            EnvValue::Secret(_) => "[SECRET]".to_string(), // Placeholder for secrets
144            EnvValue::WithPolicies(var) => match &var.value {
145                EnvValueSimple::String(s) => s.clone(),
146                EnvValueSimple::Int(i) => i.to_string(),
147                EnvValueSimple::Bool(b) => b.to_string(),
148                EnvValueSimple::Secret(_) => "[SECRET]".to_string(),
149            },
150        }
151    }
152
153    /// Resolve the environment variable value, executing secrets if necessary
154    pub async fn resolve(&self) -> crate::Result<String> {
155        match self {
156            EnvValue::String(s) => Ok(s.clone()),
157            EnvValue::Int(i) => Ok(i.to_string()),
158            EnvValue::Bool(b) => Ok(b.to_string()),
159            EnvValue::Secret(s) => s.resolve().await,
160            EnvValue::WithPolicies(var) => match &var.value {
161                EnvValueSimple::String(s) => Ok(s.clone()),
162                EnvValueSimple::Int(i) => Ok(i.to_string()),
163                EnvValueSimple::Bool(b) => Ok(b.to_string()),
164                EnvValueSimple::Secret(s) => s.resolve().await,
165            },
166        }
167    }
168}
169
170/// Runtime environment variables for task execution
171#[derive(Debug, Clone, Serialize, Deserialize, Default)]
172pub struct Environment {
173    /// Map of environment variable names to values
174    #[serde(flatten)]
175    pub vars: HashMap<String, String>,
176}
177
178impl Environment {
179    /// Create a new empty environment
180    pub fn new() -> Self {
181        Self::default()
182    }
183
184    /// Create environment from a map
185    pub fn from_map(vars: HashMap<String, String>) -> Self {
186        Self { vars }
187    }
188
189    /// Get an environment variable value
190    pub fn get(&self, key: &str) -> Option<&str> {
191        self.vars.get(key).map(|s| s.as_str())
192    }
193
194    /// Set an environment variable
195    pub fn set(&mut self, key: String, value: String) {
196        self.vars.insert(key, value);
197    }
198
199    /// Check if an environment variable exists
200    pub fn contains(&self, key: &str) -> bool {
201        self.vars.contains_key(key)
202    }
203
204    /// Get all environment variables as a vector of key=value strings
205    pub fn to_env_vec(&self) -> Vec<String> {
206        self.vars
207            .iter()
208            .map(|(k, v)| format!("{}={}", k, v))
209            .collect()
210    }
211
212    /// Merge with system environment variables
213    /// CUE environment variables take precedence
214    pub fn merge_with_system(&self) -> HashMap<String, String> {
215        let mut merged: HashMap<String, String> = env::vars().collect();
216
217        // Override with CUE environment variables
218        for (key, value) in &self.vars {
219            merged.insert(key.clone(), value.clone());
220        }
221
222        merged
223    }
224
225    /// Convert to a vector of key=value strings including system environment
226    pub fn to_full_env_vec(&self) -> Vec<String> {
227        self.merge_with_system()
228            .iter()
229            .map(|(k, v)| format!("{}={}", k, v))
230            .collect()
231    }
232
233    /// Get the number of environment variables
234    pub fn len(&self) -> usize {
235        self.vars.len()
236    }
237
238    /// Check if the environment is empty
239    pub fn is_empty(&self) -> bool {
240        self.vars.is_empty()
241    }
242
243    /// Resolve a command to its full path using this environment's PATH.
244    /// This is necessary because when spawning a process, the OS looks up
245    /// the executable in the current process's PATH, not the environment
246    /// that will be set on the child process.
247    ///
248    /// Returns the full path if found, or the original command if not found
249    /// (letting the spawn fail with a proper error).
250    pub fn resolve_command(&self, command: &str) -> String {
251        // If command is already an absolute path, use it directly
252        if command.starts_with('/') {
253            tracing::debug!(command = %command, "Command is already absolute path");
254            return command.to_string();
255        }
256
257        // Get the PATH from this environment, falling back to system PATH
258        let path_value = self
259            .vars
260            .get("PATH")
261            .cloned()
262            .or_else(|| env::var("PATH").ok())
263            .unwrap_or_default();
264
265        tracing::debug!(
266            command = %command,
267            env_has_path = self.vars.contains_key("PATH"),
268            path_len = path_value.len(),
269            "Resolving command in PATH"
270        );
271
272        // Search for the command in each PATH directory
273        for dir in path_value.split(':') {
274            if dir.is_empty() {
275                continue;
276            }
277            let candidate = std::path::Path::new(dir).join(command);
278            if candidate.is_file() {
279                // Check if it's executable (on Unix)
280                #[cfg(unix)]
281                {
282                    use std::os::unix::fs::PermissionsExt;
283                    if let Ok(metadata) = std::fs::metadata(&candidate) {
284                        let permissions = metadata.permissions();
285                        if permissions.mode() & 0o111 != 0 {
286                            tracing::debug!(
287                                command = %command,
288                                resolved = %candidate.display(),
289                                "Command resolved to path"
290                            );
291                            return candidate.to_string_lossy().to_string();
292                        }
293                    }
294                }
295                #[cfg(not(unix))]
296                {
297                    tracing::debug!(
298                        command = %command,
299                        resolved = %candidate.display(),
300                        "Command resolved to path"
301                    );
302                    return candidate.to_string_lossy().to_string();
303                }
304            }
305        }
306
307        // Command not found in PATH, return original (spawn will fail with proper error)
308        tracing::warn!(
309            command = %command,
310            env_path_set = self.vars.contains_key("PATH"),
311            "Command not found in PATH, returning original"
312        );
313        command.to_string()
314    }
315
316    /// Iterate over environment variables
317    pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
318        self.vars.iter()
319    }
320
321    /// Build environment for a task, filtering based on policies
322    pub fn build_for_task(
323        task_name: &str,
324        env_vars: &HashMap<String, EnvValue>,
325    ) -> HashMap<String, String> {
326        env_vars
327            .iter()
328            .filter(|(_, value)| value.is_accessible_by_task(task_name))
329            .map(|(key, value)| (key.clone(), value.to_string_value()))
330            .collect()
331    }
332
333    /// Build and resolve environment for a task, filtering based on policies
334    pub async fn resolve_for_task(
335        task_name: &str,
336        env_vars: &HashMap<String, EnvValue>,
337    ) -> crate::Result<HashMap<String, String>> {
338        let mut resolved = HashMap::new();
339        for (key, value) in env_vars {
340            if value.is_accessible_by_task(task_name) {
341                resolved.insert(key.clone(), value.resolve().await?);
342            }
343        }
344        Ok(resolved)
345    }
346
347    /// Build environment for exec command, filtering based on policies
348    pub fn build_for_exec(
349        command: &str,
350        env_vars: &HashMap<String, EnvValue>,
351    ) -> HashMap<String, String> {
352        env_vars
353            .iter()
354            .filter(|(_, value)| value.is_accessible_by_exec(command))
355            .map(|(key, value)| (key.clone(), value.to_string_value()))
356            .collect()
357    }
358
359    /// Build and resolve environment for exec command, filtering based on policies
360    pub async fn resolve_for_exec(
361        command: &str,
362        env_vars: &HashMap<String, EnvValue>,
363    ) -> crate::Result<HashMap<String, String>> {
364        let mut resolved = HashMap::new();
365        for (key, value) in env_vars {
366            if value.is_accessible_by_exec(command) {
367                resolved.insert(key.clone(), value.resolve().await?);
368            }
369        }
370        Ok(resolved)
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn test_environment_basics() {
380        let mut env = Environment::new();
381        assert!(env.is_empty());
382
383        env.set("FOO".to_string(), "bar".to_string());
384        assert_eq!(env.len(), 1);
385        assert!(env.contains("FOO"));
386        assert_eq!(env.get("FOO"), Some("bar"));
387        assert!(!env.contains("BAR"));
388    }
389
390    #[test]
391    fn test_environment_from_map() {
392        let mut vars = HashMap::new();
393        vars.insert("KEY1".to_string(), "value1".to_string());
394        vars.insert("KEY2".to_string(), "value2".to_string());
395
396        let env = Environment::from_map(vars);
397        assert_eq!(env.len(), 2);
398        assert_eq!(env.get("KEY1"), Some("value1"));
399        assert_eq!(env.get("KEY2"), Some("value2"));
400    }
401
402    #[test]
403    fn test_environment_to_vec() {
404        let mut env = Environment::new();
405        env.set("VAR1".to_string(), "val1".to_string());
406        env.set("VAR2".to_string(), "val2".to_string());
407
408        let vec = env.to_env_vec();
409        assert_eq!(vec.len(), 2);
410        assert!(vec.contains(&"VAR1=val1".to_string()));
411        assert!(vec.contains(&"VAR2=val2".to_string()));
412    }
413
414    #[test]
415    fn test_environment_merge_with_system() {
416        let mut env = Environment::new();
417        env.set("PATH".to_string(), "/custom/path".to_string());
418        env.set("CUSTOM_VAR".to_string(), "custom_value".to_string());
419
420        let merged = env.merge_with_system();
421
422        // Custom variables should be present
423        assert_eq!(merged.get("PATH"), Some(&"/custom/path".to_string()));
424        assert_eq!(merged.get("CUSTOM_VAR"), Some(&"custom_value".to_string()));
425
426        // System variables should still be present (like HOME, USER, etc.)
427        // We can't test specific values but we can check that merging happened
428        assert!(merged.len() >= 2);
429    }
430
431    #[test]
432    fn test_environment_iteration() {
433        let mut env = Environment::new();
434        env.set("A".to_string(), "1".to_string());
435        env.set("B".to_string(), "2".to_string());
436
437        let mut count = 0;
438        for (key, value) in env.iter() {
439            assert!(key == "A" || key == "B");
440            assert!(value == "1" || value == "2");
441            count += 1;
442        }
443        assert_eq!(count, 2);
444    }
445
446    #[test]
447    fn test_env_value_types() {
448        let str_val = EnvValue::String("test".to_string());
449        let int_val = EnvValue::Int(42);
450        let bool_val = EnvValue::Bool(true);
451
452        assert_eq!(str_val, EnvValue::String("test".to_string()));
453        assert_eq!(int_val, EnvValue::Int(42));
454        assert_eq!(bool_val, EnvValue::Bool(true));
455    }
456
457    #[test]
458    fn test_policy_task_access() {
459        // Simple value - always accessible
460        let simple_var = EnvValue::String("simple".to_string());
461        assert!(simple_var.is_accessible_by_task("any_task"));
462
463        // Variable with no policies - accessible
464        let no_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
465            value: EnvValueSimple::String("value".to_string()),
466            policies: None,
467        });
468        assert!(no_policy_var.is_accessible_by_task("any_task"));
469
470        // Variable with empty policies - accessible
471        let empty_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
472            value: EnvValueSimple::String("value".to_string()),
473            policies: Some(vec![]),
474        });
475        assert!(empty_policy_var.is_accessible_by_task("any_task"));
476
477        // Variable with task restrictions
478        let restricted_var = EnvValue::WithPolicies(EnvVarWithPolicies {
479            value: EnvValueSimple::String("secret".to_string()),
480            policies: Some(vec![Policy {
481                allow_tasks: Some(vec!["deploy".to_string(), "release".to_string()]),
482                allow_exec: None,
483            }]),
484        });
485        assert!(restricted_var.is_accessible_by_task("deploy"));
486        assert!(restricted_var.is_accessible_by_task("release"));
487        assert!(!restricted_var.is_accessible_by_task("test"));
488        assert!(!restricted_var.is_accessible_by_task("build"));
489    }
490
491    #[test]
492    fn test_policy_exec_access() {
493        // Simple value - always accessible
494        let simple_var = EnvValue::String("simple".to_string());
495        assert!(simple_var.is_accessible_by_exec("bash"));
496
497        // Variable with exec restrictions
498        let restricted_var = EnvValue::WithPolicies(EnvVarWithPolicies {
499            value: EnvValueSimple::String("secret".to_string()),
500            policies: Some(vec![Policy {
501                allow_tasks: None,
502                allow_exec: Some(vec!["kubectl".to_string(), "terraform".to_string()]),
503            }]),
504        });
505        assert!(restricted_var.is_accessible_by_exec("kubectl"));
506        assert!(restricted_var.is_accessible_by_exec("terraform"));
507        assert!(!restricted_var.is_accessible_by_exec("bash"));
508        assert!(!restricted_var.is_accessible_by_exec("sh"));
509    }
510
511    #[test]
512    fn test_multiple_policies() {
513        // Variable with multiple policies - should allow if ANY policy allows
514        let multi_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
515            value: EnvValueSimple::String("value".to_string()),
516            policies: Some(vec![
517                Policy {
518                    allow_tasks: Some(vec!["task1".to_string()]),
519                    allow_exec: None,
520                },
521                Policy {
522                    allow_tasks: Some(vec!["task2".to_string()]),
523                    allow_exec: Some(vec!["kubectl".to_string()]),
524                },
525            ]),
526        });
527
528        // Task access - either policy allows
529        assert!(multi_policy_var.is_accessible_by_task("task1"));
530        assert!(multi_policy_var.is_accessible_by_task("task2"));
531        assert!(!multi_policy_var.is_accessible_by_task("task3"));
532
533        // Exec access - only second policy has exec rules
534        assert!(multi_policy_var.is_accessible_by_exec("kubectl"));
535        assert!(!multi_policy_var.is_accessible_by_exec("bash"));
536    }
537
538    #[test]
539    fn test_to_string_value() {
540        assert_eq!(
541            EnvValue::String("test".to_string()).to_string_value(),
542            "test"
543        );
544        assert_eq!(EnvValue::Int(42).to_string_value(), "42");
545        assert_eq!(EnvValue::Bool(true).to_string_value(), "true");
546        assert_eq!(EnvValue::Bool(false).to_string_value(), "false");
547
548        let with_policies = EnvValue::WithPolicies(EnvVarWithPolicies {
549            value: EnvValueSimple::String("policy_value".to_string()),
550            policies: Some(vec![]),
551        });
552        assert_eq!(with_policies.to_string_value(), "policy_value");
553    }
554
555    #[test]
556    fn test_build_for_task() {
557        let mut env_vars = HashMap::new();
558
559        // Unrestricted variable
560        env_vars.insert(
561            "PUBLIC".to_string(),
562            EnvValue::String("public_value".to_string()),
563        );
564
565        // Restricted variable
566        env_vars.insert(
567            "SECRET".to_string(),
568            EnvValue::WithPolicies(EnvVarWithPolicies {
569                value: EnvValueSimple::String("secret_value".to_string()),
570                policies: Some(vec![Policy {
571                    allow_tasks: Some(vec!["deploy".to_string()]),
572                    allow_exec: None,
573                }]),
574            }),
575        );
576
577        // Build for deploy task - should get both
578        let deploy_env = Environment::build_for_task("deploy", &env_vars);
579        assert_eq!(deploy_env.len(), 2);
580        assert_eq!(deploy_env.get("PUBLIC"), Some(&"public_value".to_string()));
581        assert_eq!(deploy_env.get("SECRET"), Some(&"secret_value".to_string()));
582
583        // Build for test task - should only get public
584        let test_env = Environment::build_for_task("test", &env_vars);
585        assert_eq!(test_env.len(), 1);
586        assert_eq!(test_env.get("PUBLIC"), Some(&"public_value".to_string()));
587        assert_eq!(test_env.get("SECRET"), None);
588    }
589
590    #[test]
591    fn test_build_for_exec() {
592        let mut env_vars = HashMap::new();
593
594        // Unrestricted variable
595        env_vars.insert(
596            "PUBLIC".to_string(),
597            EnvValue::String("public_value".to_string()),
598        );
599
600        // Restricted variable
601        env_vars.insert(
602            "SECRET".to_string(),
603            EnvValue::WithPolicies(EnvVarWithPolicies {
604                value: EnvValueSimple::String("secret_value".to_string()),
605                policies: Some(vec![Policy {
606                    allow_tasks: None,
607                    allow_exec: Some(vec!["kubectl".to_string()]),
608                }]),
609            }),
610        );
611
612        // Build for kubectl - should get both
613        let kubectl_env = Environment::build_for_exec("kubectl", &env_vars);
614        assert_eq!(kubectl_env.len(), 2);
615        assert_eq!(kubectl_env.get("PUBLIC"), Some(&"public_value".to_string()));
616        assert_eq!(kubectl_env.get("SECRET"), Some(&"secret_value".to_string()));
617
618        // Build for bash - should only get public
619        let bash_env = Environment::build_for_exec("bash", &env_vars);
620        assert_eq!(bash_env.len(), 1);
621        assert_eq!(bash_env.get("PUBLIC"), Some(&"public_value".to_string()));
622        assert_eq!(bash_env.get("SECRET"), None);
623    }
624
625    #[test]
626    fn test_env_for_environment() {
627        let mut base = HashMap::new();
628        base.insert("BASE_VAR".to_string(), EnvValue::String("base".to_string()));
629        base.insert(
630            "OVERRIDE_ME".to_string(),
631            EnvValue::String("original".to_string()),
632        );
633
634        let mut dev_env = HashMap::new();
635        dev_env.insert(
636            "OVERRIDE_ME".to_string(),
637            EnvValue::String("dev".to_string()),
638        );
639        dev_env.insert(
640            "DEV_VAR".to_string(),
641            EnvValue::String("development".to_string()),
642        );
643
644        let mut environments = HashMap::new();
645        environments.insert("development".to_string(), dev_env);
646
647        let env = Env {
648            base,
649            environment: Some(environments),
650        };
651
652        let dev_vars = env.for_environment("development");
653        assert_eq!(
654            dev_vars.get("BASE_VAR"),
655            Some(&EnvValue::String("base".to_string()))
656        );
657        assert_eq!(
658            dev_vars.get("OVERRIDE_ME"),
659            Some(&EnvValue::String("dev".to_string()))
660        );
661        assert_eq!(
662            dev_vars.get("DEV_VAR"),
663            Some(&EnvValue::String("development".to_string()))
664        );
665    }
666}