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