1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::env;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct Policy {
13 #[serde(skip_serializing_if = "Option::is_none", rename = "allowTasks")]
15 pub allow_tasks: Option<Vec<String>>,
16
17 #[serde(skip_serializing_if = "Option::is_none", rename = "allowExec")]
19 pub allow_exec: Option<Vec<String>>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct EnvVarWithPolicies {
25 pub value: EnvValueSimple,
27
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub policies: Option<Vec<Policy>>,
31}
32
33#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46#[serde(untagged)]
47pub enum EnvValue {
48 WithPolicies(EnvVarWithPolicies),
50 String(String),
52 Int(i64),
53 Bool(bool),
54 Secret(crate::secrets::Secret),
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
60pub struct Env {
61 #[serde(flatten)]
64 pub base: HashMap<String, EnvValue>,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub environment: Option<HashMap<String, HashMap<String, EnvValue>>>,
69}
70
71impl Env {
72 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 pub fn is_accessible_by_task(&self, task_name: &str) -> bool {
89 match self {
90 EnvValue::String(_) | EnvValue::Int(_) | EnvValue::Bool(_) | EnvValue::Secret(_) => {
92 true
93 }
94
95 EnvValue::WithPolicies(var) => match &var.policies {
97 None => true, Some(policies) if policies.is_empty() => true, Some(policies) => {
100 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 pub fn is_accessible_by_exec(&self, command: &str) -> bool {
114 match self {
115 EnvValue::String(_) | EnvValue::Int(_) | EnvValue::Bool(_) | EnvValue::Secret(_) => {
117 true
118 }
119
120 EnvValue::WithPolicies(var) => match &var.policies {
122 None => true, Some(policies) if policies.is_empty() => true, Some(policies) => {
125 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 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(), 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 #[must_use]
155 pub fn is_secret(&self) -> bool {
156 match self {
157 EnvValue::Secret(_) => true,
158 EnvValue::WithPolicies(var) => matches!(var.value, EnvValueSimple::Secret(_)),
159 _ => false,
160 }
161 }
162
163 pub async fn resolve(&self) -> crate::Result<String> {
168 match self {
169 EnvValue::String(s) => Ok(s.clone()),
170 EnvValue::Int(i) => Ok(i.to_string()),
171 EnvValue::Bool(b) => Ok(b.to_string()),
172 EnvValue::Secret(s) => s.resolve().await,
173 EnvValue::WithPolicies(var) => match &var.value {
174 EnvValueSimple::String(s) => Ok(s.clone()),
175 EnvValueSimple::Int(i) => Ok(i.to_string()),
176 EnvValueSimple::Bool(b) => Ok(b.to_string()),
177 EnvValueSimple::Secret(s) => s.resolve().await,
178 },
179 }
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, Default)]
185pub struct Environment {
186 #[serde(flatten)]
188 pub vars: HashMap<String, String>,
189}
190
191impl Environment {
192 pub fn new() -> Self {
194 Self::default()
195 }
196
197 pub fn from_map(vars: HashMap<String, String>) -> Self {
199 Self { vars }
200 }
201
202 pub fn get(&self, key: &str) -> Option<&str> {
204 self.vars.get(key).map(|s| s.as_str())
205 }
206
207 pub fn set(&mut self, key: String, value: String) {
209 self.vars.insert(key, value);
210 }
211
212 pub fn contains(&self, key: &str) -> bool {
214 self.vars.contains_key(key)
215 }
216
217 pub fn to_env_vec(&self) -> Vec<String> {
219 self.vars
220 .iter()
221 .map(|(k, v)| format!("{}={}", k, v))
222 .collect()
223 }
224
225 pub fn merge_with_system(&self) -> HashMap<String, String> {
228 let mut merged: HashMap<String, String> = env::vars().collect();
229
230 for (key, value) in &self.vars {
232 merged.insert(key.clone(), value.clone());
233 }
234
235 merged
236 }
237
238 const HERMETIC_ALLOWED_VARS: &'static [&'static str] = &[
241 "HOME",
242 "USER",
243 "LOGNAME",
244 "SHELL",
245 "TERM",
246 "COLORTERM",
247 "LANG",
248 "LC_ALL",
249 "LC_CTYPE",
250 "LC_MESSAGES",
251 "TMPDIR",
252 "TMP",
253 "TEMP",
254 "XDG_RUNTIME_DIR",
255 "XDG_CONFIG_HOME",
256 "XDG_CACHE_HOME",
257 "XDG_DATA_HOME",
258 ];
259
260 pub fn merge_with_system_hermetic(&self) -> HashMap<String, String> {
272 let mut merged: HashMap<String, String> = HashMap::new();
273
274 for var in Self::HERMETIC_ALLOWED_VARS {
276 if let Ok(value) = env::var(var) {
277 merged.insert((*var).to_string(), value);
278 }
279 }
280
281 for (key, value) in env::vars() {
283 if key.starts_with("LC_") {
284 merged.insert(key, value);
285 }
286 }
287
288 for (key, value) in &self.vars {
290 merged.insert(key.clone(), value.clone());
291 }
292
293 merged
294 }
295
296 pub fn to_full_env_vec(&self) -> Vec<String> {
298 self.merge_with_system()
299 .iter()
300 .map(|(k, v)| format!("{}={}", k, v))
301 .collect()
302 }
303
304 pub fn len(&self) -> usize {
306 self.vars.len()
307 }
308
309 pub fn is_empty(&self) -> bool {
311 self.vars.is_empty()
312 }
313
314 pub fn resolve_command(&self, command: &str) -> String {
322 if command.starts_with('/') {
324 tracing::debug!(command = %command, "Command is already absolute path");
325 return command.to_string();
326 }
327
328 let path_value = self
330 .vars
331 .get("PATH")
332 .cloned()
333 .or_else(|| env::var("PATH").ok())
334 .unwrap_or_default();
335
336 tracing::debug!(
337 command = %command,
338 env_has_path = self.vars.contains_key("PATH"),
339 path_len = path_value.len(),
340 "Resolving command in PATH"
341 );
342
343 for dir in path_value.split(':') {
345 if dir.is_empty() {
346 continue;
347 }
348 let candidate = std::path::Path::new(dir).join(command);
349 if candidate.is_file() {
350 #[cfg(unix)]
352 {
353 use std::os::unix::fs::PermissionsExt;
354 if let Ok(metadata) = std::fs::metadata(&candidate) {
355 let permissions = metadata.permissions();
356 if permissions.mode() & 0o111 != 0 {
357 tracing::debug!(
358 command = %command,
359 resolved = %candidate.display(),
360 "Command resolved to path"
361 );
362 return candidate.to_string_lossy().to_string();
363 }
364 }
365 }
366 #[cfg(not(unix))]
367 {
368 tracing::debug!(
369 command = %command,
370 resolved = %candidate.display(),
371 "Command resolved to path"
372 );
373 return candidate.to_string_lossy().to_string();
374 }
375 }
376 }
377
378 if self.vars.contains_key("PATH")
382 && let Ok(system_path) = env::var("PATH")
383 {
384 tracing::debug!(
385 command = %command,
386 "Command not found in env PATH, trying system PATH"
387 );
388 for dir in system_path.split(':') {
389 if dir.is_empty() {
390 continue;
391 }
392 let candidate = std::path::Path::new(dir).join(command);
393 if candidate.is_file() {
394 #[cfg(unix)]
395 {
396 use std::os::unix::fs::PermissionsExt;
397 if let Ok(metadata) = std::fs::metadata(&candidate) {
398 let permissions = metadata.permissions();
399 if permissions.mode() & 0o111 != 0 {
400 tracing::debug!(
401 command = %command,
402 resolved = %candidate.display(),
403 "Command resolved from system PATH"
404 );
405 return candidate.to_string_lossy().to_string();
406 }
407 }
408 }
409 #[cfg(not(unix))]
410 {
411 tracing::debug!(
412 command = %command,
413 resolved = %candidate.display(),
414 "Command resolved from system PATH"
415 );
416 return candidate.to_string_lossy().to_string();
417 }
418 }
419 }
420 }
421
422 tracing::warn!(
424 command = %command,
425 env_path_set = self.vars.contains_key("PATH"),
426 "Command not found in PATH, returning original"
427 );
428 command.to_string()
429 }
430
431 pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
433 self.vars.iter()
434 }
435
436 pub fn build_for_task(
438 task_name: &str,
439 env_vars: &HashMap<String, EnvValue>,
440 ) -> HashMap<String, String> {
441 env_vars
442 .iter()
443 .filter(|(_, value)| value.is_accessible_by_task(task_name))
444 .map(|(key, value)| (key.clone(), value.to_string_value()))
445 .collect()
446 }
447
448 pub async fn resolve_for_task(
453 task_name: &str,
454 env_vars: &HashMap<String, EnvValue>,
455 ) -> crate::Result<HashMap<String, String>> {
456 let (resolved, _secrets) = Self::resolve_for_task_with_secrets(task_name, env_vars).await?;
457 Ok(resolved)
458 }
459
460 pub async fn resolve_for_task_with_secrets(
465 task_name: &str,
466 env_vars: &HashMap<String, EnvValue>,
467 ) -> crate::Result<(HashMap<String, String>, Vec<String>)> {
468 let mut resolved = HashMap::new();
469 let mut secrets = Vec::new();
470
471 for (key, value) in env_vars {
472 if value.is_accessible_by_task(task_name) {
473 let resolved_value = value.resolve().await?;
474 if value.is_secret() {
475 secrets.push(resolved_value.clone());
476 }
477 resolved.insert(key.clone(), resolved_value);
478 }
479 }
480 Ok((resolved, secrets))
481 }
482
483 pub fn build_for_exec(
485 command: &str,
486 env_vars: &HashMap<String, EnvValue>,
487 ) -> HashMap<String, String> {
488 env_vars
489 .iter()
490 .filter(|(_, value)| value.is_accessible_by_exec(command))
491 .map(|(key, value)| (key.clone(), value.to_string_value()))
492 .collect()
493 }
494
495 pub async fn resolve_for_exec(
500 command: &str,
501 env_vars: &HashMap<String, EnvValue>,
502 ) -> crate::Result<HashMap<String, String>> {
503 let (resolved, _secrets) = Self::resolve_for_exec_with_secrets(command, env_vars).await?;
504 Ok(resolved)
505 }
506
507 pub async fn resolve_for_exec_with_secrets(
512 command: &str,
513 env_vars: &HashMap<String, EnvValue>,
514 ) -> crate::Result<(HashMap<String, String>, Vec<String>)> {
515 let mut resolved = HashMap::new();
516 let mut secrets = Vec::new();
517
518 for (key, value) in env_vars {
519 if value.is_accessible_by_exec(command) {
520 let resolved_value = value.resolve().await?;
521 if value.is_secret() {
522 secrets.push(resolved_value.clone());
523 }
524 resolved.insert(key.clone(), resolved_value);
525 }
526 }
527 Ok((resolved, secrets))
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 #[test]
536 fn test_environment_basics() {
537 let mut env = Environment::new();
538 assert!(env.is_empty());
539
540 env.set("FOO".to_string(), "bar".to_string());
541 assert_eq!(env.len(), 1);
542 assert!(env.contains("FOO"));
543 assert_eq!(env.get("FOO"), Some("bar"));
544 assert!(!env.contains("BAR"));
545 }
546
547 #[test]
548 fn test_environment_from_map() {
549 let mut vars = HashMap::new();
550 vars.insert("KEY1".to_string(), "value1".to_string());
551 vars.insert("KEY2".to_string(), "value2".to_string());
552
553 let env = Environment::from_map(vars);
554 assert_eq!(env.len(), 2);
555 assert_eq!(env.get("KEY1"), Some("value1"));
556 assert_eq!(env.get("KEY2"), Some("value2"));
557 }
558
559 #[test]
560 fn test_environment_to_vec() {
561 let mut env = Environment::new();
562 env.set("VAR1".to_string(), "val1".to_string());
563 env.set("VAR2".to_string(), "val2".to_string());
564
565 let vec = env.to_env_vec();
566 assert_eq!(vec.len(), 2);
567 assert!(vec.contains(&"VAR1=val1".to_string()));
568 assert!(vec.contains(&"VAR2=val2".to_string()));
569 }
570
571 #[test]
572 fn test_environment_merge_with_system() {
573 let mut env = Environment::new();
574 env.set("PATH".to_string(), "/custom/path".to_string());
575 env.set("CUSTOM_VAR".to_string(), "custom_value".to_string());
576
577 let merged = env.merge_with_system();
578
579 assert_eq!(merged.get("PATH"), Some(&"/custom/path".to_string()));
581 assert_eq!(merged.get("CUSTOM_VAR"), Some(&"custom_value".to_string()));
582
583 assert!(merged.len() >= 2);
586 }
587
588 #[test]
589 fn test_environment_iteration() {
590 let mut env = Environment::new();
591 env.set("A".to_string(), "1".to_string());
592 env.set("B".to_string(), "2".to_string());
593
594 let mut count = 0;
595 for (key, value) in env.iter() {
596 assert!(key == "A" || key == "B");
597 assert!(value == "1" || value == "2");
598 count += 1;
599 }
600 assert_eq!(count, 2);
601 }
602
603 #[test]
604 fn test_env_value_types() {
605 let str_val = EnvValue::String("test".to_string());
606 let int_val = EnvValue::Int(42);
607 let bool_val = EnvValue::Bool(true);
608
609 assert_eq!(str_val, EnvValue::String("test".to_string()));
610 assert_eq!(int_val, EnvValue::Int(42));
611 assert_eq!(bool_val, EnvValue::Bool(true));
612 }
613
614 #[test]
615 fn test_policy_task_access() {
616 let simple_var = EnvValue::String("simple".to_string());
618 assert!(simple_var.is_accessible_by_task("any_task"));
619
620 let no_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
622 value: EnvValueSimple::String("value".to_string()),
623 policies: None,
624 });
625 assert!(no_policy_var.is_accessible_by_task("any_task"));
626
627 let empty_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
629 value: EnvValueSimple::String("value".to_string()),
630 policies: Some(vec![]),
631 });
632 assert!(empty_policy_var.is_accessible_by_task("any_task"));
633
634 let restricted_var = EnvValue::WithPolicies(EnvVarWithPolicies {
636 value: EnvValueSimple::String("secret".to_string()),
637 policies: Some(vec![Policy {
638 allow_tasks: Some(vec!["deploy".to_string(), "release".to_string()]),
639 allow_exec: None,
640 }]),
641 });
642 assert!(restricted_var.is_accessible_by_task("deploy"));
643 assert!(restricted_var.is_accessible_by_task("release"));
644 assert!(!restricted_var.is_accessible_by_task("test"));
645 assert!(!restricted_var.is_accessible_by_task("build"));
646 }
647
648 #[test]
649 fn test_policy_exec_access() {
650 let simple_var = EnvValue::String("simple".to_string());
652 assert!(simple_var.is_accessible_by_exec("bash"));
653
654 let restricted_var = EnvValue::WithPolicies(EnvVarWithPolicies {
656 value: EnvValueSimple::String("secret".to_string()),
657 policies: Some(vec![Policy {
658 allow_tasks: None,
659 allow_exec: Some(vec!["kubectl".to_string(), "terraform".to_string()]),
660 }]),
661 });
662 assert!(restricted_var.is_accessible_by_exec("kubectl"));
663 assert!(restricted_var.is_accessible_by_exec("terraform"));
664 assert!(!restricted_var.is_accessible_by_exec("bash"));
665 assert!(!restricted_var.is_accessible_by_exec("sh"));
666 }
667
668 #[test]
669 fn test_multiple_policies() {
670 let multi_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
672 value: EnvValueSimple::String("value".to_string()),
673 policies: Some(vec![
674 Policy {
675 allow_tasks: Some(vec!["task1".to_string()]),
676 allow_exec: None,
677 },
678 Policy {
679 allow_tasks: Some(vec!["task2".to_string()]),
680 allow_exec: Some(vec!["kubectl".to_string()]),
681 },
682 ]),
683 });
684
685 assert!(multi_policy_var.is_accessible_by_task("task1"));
687 assert!(multi_policy_var.is_accessible_by_task("task2"));
688 assert!(!multi_policy_var.is_accessible_by_task("task3"));
689
690 assert!(multi_policy_var.is_accessible_by_exec("kubectl"));
692 assert!(!multi_policy_var.is_accessible_by_exec("bash"));
693 }
694
695 #[test]
696 fn test_to_string_value() {
697 assert_eq!(
698 EnvValue::String("test".to_string()).to_string_value(),
699 "test"
700 );
701 assert_eq!(EnvValue::Int(42).to_string_value(), "42");
702 assert_eq!(EnvValue::Bool(true).to_string_value(), "true");
703 assert_eq!(EnvValue::Bool(false).to_string_value(), "false");
704
705 let with_policies = EnvValue::WithPolicies(EnvVarWithPolicies {
706 value: EnvValueSimple::String("policy_value".to_string()),
707 policies: Some(vec![]),
708 });
709 assert_eq!(with_policies.to_string_value(), "policy_value");
710 }
711
712 #[test]
713 fn test_build_for_task() {
714 let mut env_vars = HashMap::new();
715
716 env_vars.insert(
718 "PUBLIC".to_string(),
719 EnvValue::String("public_value".to_string()),
720 );
721
722 env_vars.insert(
724 "SECRET".to_string(),
725 EnvValue::WithPolicies(EnvVarWithPolicies {
726 value: EnvValueSimple::String("secret_value".to_string()),
727 policies: Some(vec![Policy {
728 allow_tasks: Some(vec!["deploy".to_string()]),
729 allow_exec: None,
730 }]),
731 }),
732 );
733
734 let deploy_env = Environment::build_for_task("deploy", &env_vars);
736 assert_eq!(deploy_env.len(), 2);
737 assert_eq!(deploy_env.get("PUBLIC"), Some(&"public_value".to_string()));
738 assert_eq!(deploy_env.get("SECRET"), Some(&"secret_value".to_string()));
739
740 let test_env = Environment::build_for_task("test", &env_vars);
742 assert_eq!(test_env.len(), 1);
743 assert_eq!(test_env.get("PUBLIC"), Some(&"public_value".to_string()));
744 assert_eq!(test_env.get("SECRET"), None);
745 }
746
747 #[test]
748 fn test_build_for_exec() {
749 let mut env_vars = HashMap::new();
750
751 env_vars.insert(
753 "PUBLIC".to_string(),
754 EnvValue::String("public_value".to_string()),
755 );
756
757 env_vars.insert(
759 "SECRET".to_string(),
760 EnvValue::WithPolicies(EnvVarWithPolicies {
761 value: EnvValueSimple::String("secret_value".to_string()),
762 policies: Some(vec![Policy {
763 allow_tasks: None,
764 allow_exec: Some(vec!["kubectl".to_string()]),
765 }]),
766 }),
767 );
768
769 let kubectl_env = Environment::build_for_exec("kubectl", &env_vars);
771 assert_eq!(kubectl_env.len(), 2);
772 assert_eq!(kubectl_env.get("PUBLIC"), Some(&"public_value".to_string()));
773 assert_eq!(kubectl_env.get("SECRET"), Some(&"secret_value".to_string()));
774
775 let bash_env = Environment::build_for_exec("bash", &env_vars);
777 assert_eq!(bash_env.len(), 1);
778 assert_eq!(bash_env.get("PUBLIC"), Some(&"public_value".to_string()));
779 assert_eq!(bash_env.get("SECRET"), None);
780 }
781
782 #[test]
783 fn test_env_for_environment() {
784 let mut base = HashMap::new();
785 base.insert("BASE_VAR".to_string(), EnvValue::String("base".to_string()));
786 base.insert(
787 "OVERRIDE_ME".to_string(),
788 EnvValue::String("original".to_string()),
789 );
790
791 let mut dev_env = HashMap::new();
792 dev_env.insert(
793 "OVERRIDE_ME".to_string(),
794 EnvValue::String("dev".to_string()),
795 );
796 dev_env.insert(
797 "DEV_VAR".to_string(),
798 EnvValue::String("development".to_string()),
799 );
800
801 let mut environments = HashMap::new();
802 environments.insert("development".to_string(), dev_env);
803
804 let env = Env {
805 base,
806 environment: Some(environments),
807 };
808
809 let dev_vars = env.for_environment("development");
810 assert_eq!(
811 dev_vars.get("BASE_VAR"),
812 Some(&EnvValue::String("base".to_string()))
813 );
814 assert_eq!(
815 dev_vars.get("OVERRIDE_ME"),
816 Some(&EnvValue::String("dev".to_string()))
817 );
818 assert_eq!(
819 dev_vars.get("DEV_VAR"),
820 Some(&EnvValue::String("development".to_string()))
821 );
822 }
823
824 #[tokio::test]
825 async fn test_resolve_plain_string() {
826 let env_val = EnvValue::String("plain_value".to_string());
827 let resolved = env_val.resolve().await.unwrap();
828 assert_eq!(resolved, "plain_value");
829 }
830
831 #[tokio::test]
832 async fn test_resolve_int() {
833 let env_val = EnvValue::Int(42);
834 let resolved = env_val.resolve().await.unwrap();
835 assert_eq!(resolved, "42");
836 }
837
838 #[tokio::test]
839 async fn test_resolve_bool() {
840 let env_val = EnvValue::Bool(true);
841 let resolved = env_val.resolve().await.unwrap();
842 assert_eq!(resolved, "true");
843 }
844
845 #[tokio::test]
846 async fn test_resolve_with_policies_plain_string() {
847 let env_val = EnvValue::WithPolicies(EnvVarWithPolicies {
848 value: EnvValueSimple::String("policy_value".to_string()),
849 policies: None,
850 });
851 let resolved = env_val.resolve().await.unwrap();
852 assert_eq!(resolved, "policy_value");
853 }
854}