use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum EnvPart {
Secret(crate::secrets::Secret),
Literal(String),
}
impl EnvPart {
#[must_use]
pub fn is_secret(&self) -> bool {
matches!(self, EnvPart::Secret(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Policy {
#[serde(skip_serializing_if = "Option::is_none", rename = "allowTasks")]
pub allow_tasks: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "allowExec")]
pub allow_exec: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EnvVarWithPolicies {
pub value: EnvValueSimple,
#[serde(skip_serializing_if = "Option::is_none")]
pub policies: Option<Vec<Policy>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum EnvValueSimple {
Secret(crate::secrets::Secret),
Interpolated(Vec<EnvPart>),
String(String),
Int(i64),
Bool(bool),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum EnvValue {
WithPolicies(EnvVarWithPolicies),
Secret(crate::secrets::Secret),
Interpolated(Vec<EnvPart>),
String(String),
Int(i64),
Bool(bool),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Env {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<HashMap<String, HashMap<String, EnvValue>>>,
#[serde(flatten)]
pub base: HashMap<String, EnvValue>,
}
impl Env {
pub fn for_environment(&self, env_name: &str) -> HashMap<String, EnvValue> {
let mut result = self.base.clone();
if let Some(environments) = &self.environment
&& let Some(env_overrides) = environments.get(env_name)
{
result.extend(env_overrides.clone());
}
result
}
}
impl EnvValue {
pub fn is_accessible_by_task(&self, task_name: &str) -> bool {
match self {
EnvValue::String(_)
| EnvValue::Int(_)
| EnvValue::Bool(_)
| EnvValue::Secret(_)
| EnvValue::Interpolated(_) => true,
EnvValue::WithPolicies(var) => match &var.policies {
None => true, Some(policies) if policies.is_empty() => true, Some(policies) => {
policies.iter().any(|policy| {
policy
.allow_tasks
.as_ref()
.is_some_and(|tasks| tasks.iter().any(|t| t == task_name))
})
}
},
}
}
pub fn is_accessible_by_exec(&self, command: &str) -> bool {
match self {
EnvValue::String(_)
| EnvValue::Int(_)
| EnvValue::Bool(_)
| EnvValue::Secret(_)
| EnvValue::Interpolated(_) => true,
EnvValue::WithPolicies(var) => match &var.policies {
None => true, Some(policies) if policies.is_empty() => true, Some(policies) => {
policies.iter().any(|policy| {
policy
.allow_exec
.as_ref()
.is_some_and(|execs| execs.iter().any(|e| e == command))
})
}
},
}
}
pub fn to_string_value(&self) -> String {
match self {
EnvValue::String(s) => s.clone(),
EnvValue::Int(i) => i.to_string(),
EnvValue::Bool(b) => b.to_string(),
EnvValue::Secret(_) => cuenv_events::REDACTED_PLACEHOLDER.to_string(),
EnvValue::Interpolated(parts) => Self::parts_to_string_value(parts),
EnvValue::WithPolicies(var) => match &var.value {
EnvValueSimple::String(s) => s.clone(),
EnvValueSimple::Int(i) => i.to_string(),
EnvValueSimple::Bool(b) => b.to_string(),
EnvValueSimple::Secret(_) => cuenv_events::REDACTED_PLACEHOLDER.to_string(),
EnvValueSimple::Interpolated(parts) => Self::parts_to_string_value(parts),
},
}
}
fn parts_to_string_value(parts: &[EnvPart]) -> String {
parts
.iter()
.map(|p| match p {
EnvPart::Literal(s) => s.clone(),
EnvPart::Secret(_) => cuenv_events::REDACTED_PLACEHOLDER.to_string(),
})
.collect()
}
#[must_use]
pub fn is_secret(&self) -> bool {
match self {
EnvValue::Secret(_) => true,
EnvValue::Interpolated(parts) => parts.iter().any(EnvPart::is_secret),
EnvValue::WithPolicies(var) => match &var.value {
EnvValueSimple::Secret(_) => true,
EnvValueSimple::Interpolated(parts) => parts.iter().any(EnvPart::is_secret),
_ => false,
},
_ => false,
}
}
pub async fn resolve(&self) -> crate::Result<String> {
let (resolved, _) = self.resolve_with_secrets().await?;
Ok(resolved)
}
pub async fn resolve_with_secrets(&self) -> crate::Result<(String, Vec<String>)> {
match self {
EnvValue::String(s) => Ok((s.clone(), vec![])),
EnvValue::Int(i) => Ok((i.to_string(), vec![])),
EnvValue::Bool(b) => Ok((b.to_string(), vec![])),
EnvValue::Secret(s) => {
let resolved = s.resolve().await?;
Ok((resolved.clone(), vec![resolved]))
}
EnvValue::Interpolated(parts) => Self::resolve_parts_with_secrets(parts).await,
EnvValue::WithPolicies(var) => Self::resolve_simple_with_secrets(&var.value).await,
}
}
async fn resolve_parts_with_secrets(parts: &[EnvPart]) -> crate::Result<(String, Vec<String>)> {
let mut result = String::new();
let mut secrets = Vec::new();
for part in parts {
match part {
EnvPart::Literal(s) => result.push_str(s),
EnvPart::Secret(s) => {
let resolved = s.resolve().await?;
result.push_str(&resolved);
secrets.push(resolved);
}
}
}
Ok((result, secrets))
}
async fn resolve_simple_with_secrets(
value: &EnvValueSimple,
) -> crate::Result<(String, Vec<String>)> {
match value {
EnvValueSimple::String(s) => Ok((s.clone(), vec![])),
EnvValueSimple::Int(i) => Ok((i.to_string(), vec![])),
EnvValueSimple::Bool(b) => Ok((b.to_string(), vec![])),
EnvValueSimple::Secret(s) => {
let resolved = s.resolve().await?;
Ok((resolved.clone(), vec![resolved]))
}
EnvValueSimple::Interpolated(parts) => Self::resolve_parts_with_secrets(parts).await,
}
}
fn collect_secrets(&self) -> Vec<(usize, &crate::secrets::Secret)> {
match self {
EnvValue::Secret(s) => vec![(0, s)],
EnvValue::Interpolated(parts) => Self::collect_secrets_from_parts(parts),
EnvValue::WithPolicies(var) => match &var.value {
EnvValueSimple::Secret(s) => vec![(0, s)],
EnvValueSimple::Interpolated(parts) => Self::collect_secrets_from_parts(parts),
_ => vec![],
},
_ => vec![],
}
}
fn collect_secrets_from_parts(parts: &[EnvPart]) -> Vec<(usize, &crate::secrets::Secret)> {
parts
.iter()
.enumerate()
.filter_map(|(i, part)| match part {
EnvPart::Secret(s) => Some((i, s)),
EnvPart::Literal(_) => None,
})
.collect()
}
fn reassemble_with_resolved(
&self,
resolved_secrets: &HashMap<usize, String>,
) -> (String, Vec<String>) {
match self {
EnvValue::String(s) => (s.clone(), vec![]),
EnvValue::Int(i) => (i.to_string(), vec![]),
EnvValue::Bool(b) => (b.to_string(), vec![]),
EnvValue::Secret(_) => {
let val = resolved_secrets.get(&0).cloned().unwrap_or_default();
(val.clone(), vec![val])
}
EnvValue::Interpolated(parts) => Self::reassemble_parts(parts, resolved_secrets),
EnvValue::WithPolicies(var) => match &var.value {
EnvValueSimple::String(s) => (s.clone(), vec![]),
EnvValueSimple::Int(i) => (i.to_string(), vec![]),
EnvValueSimple::Bool(b) => (b.to_string(), vec![]),
EnvValueSimple::Secret(_) => {
let val = resolved_secrets.get(&0).cloned().unwrap_or_default();
(val.clone(), vec![val])
}
EnvValueSimple::Interpolated(parts) => {
Self::reassemble_parts(parts, resolved_secrets)
}
},
}
}
fn reassemble_parts(
parts: &[EnvPart],
resolved_secrets: &HashMap<usize, String>,
) -> (String, Vec<String>) {
let mut result = String::new();
let mut secrets = Vec::new();
for (i, part) in parts.iter().enumerate() {
match part {
EnvPart::Literal(s) => result.push_str(s),
EnvPart::Secret(_) => {
if let Some(val) = resolved_secrets.get(&i) {
result.push_str(val);
secrets.push(val.clone());
}
}
}
}
(result, secrets)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Environment {
#[serde(flatten)]
pub vars: HashMap<String, String>,
}
impl Environment {
pub fn new() -> Self {
Self::default()
}
pub fn from_map(vars: HashMap<String, String>) -> Self {
Self { vars }
}
pub fn get(&self, key: &str) -> Option<&str> {
self.vars.get(key).map(|s| s.as_str())
}
pub fn set(&mut self, key: String, value: String) {
self.vars.insert(key, value);
}
pub fn contains(&self, key: &str) -> bool {
self.vars.contains_key(key)
}
pub fn to_env_vec(&self) -> Vec<String> {
self.vars
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect()
}
pub fn merge_with_system(&self) -> HashMap<String, String> {
let mut merged: HashMap<String, String> = env::vars().collect();
for (key, value) in &self.vars {
merged.insert(key.clone(), value.clone());
}
merged
}
const HERMETIC_ALLOWED_VARS: &'static [&'static str] = &[
"HOME",
"USER",
"LOGNAME",
"SHELL",
"TERM",
"COLORTERM",
"LANG",
"LC_ALL",
"LC_CTYPE",
"LC_MESSAGES",
"TMPDIR",
"TMP",
"TEMP",
"XDG_RUNTIME_DIR",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
];
pub fn merge_with_system_hermetic(&self) -> HashMap<String, String> {
let mut merged: HashMap<String, String> = HashMap::new();
for var in Self::HERMETIC_ALLOWED_VARS {
if let Ok(value) = env::var(var) {
merged.insert((*var).to_string(), value);
}
}
for (key, value) in env::vars() {
if key.starts_with("LC_") {
merged.insert(key, value);
}
}
for (key, value) in &self.vars {
merged.insert(key.clone(), value.clone());
}
merged
}
pub fn to_full_env_vec(&self) -> Vec<String> {
self.merge_with_system()
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect()
}
pub fn len(&self) -> usize {
self.vars.len()
}
pub fn is_empty(&self) -> bool {
self.vars.is_empty()
}
pub fn resolve_command(&self, command: &str) -> String {
if command.starts_with('/') {
tracing::debug!(command = %command, "Command is already absolute path");
return command.to_string();
}
let path_value = self
.vars
.get("PATH")
.cloned()
.or_else(|| env::var("PATH").ok())
.unwrap_or_default();
tracing::debug!(
command = %command,
env_has_path = self.vars.contains_key("PATH"),
path_len = path_value.len(),
"Resolving command in PATH"
);
for dir in path_value.split(':') {
if dir.is_empty() {
continue;
}
let candidate = std::path::Path::new(dir).join(command);
if candidate.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(&candidate) {
let permissions = metadata.permissions();
if permissions.mode() & 0o111 != 0 {
tracing::debug!(
command = %command,
resolved = %candidate.display(),
"Command resolved to path"
);
return candidate.to_string_lossy().to_string();
}
}
}
#[cfg(not(unix))]
{
tracing::debug!(
command = %command,
resolved = %candidate.display(),
"Command resolved to path"
);
return candidate.to_string_lossy().to_string();
}
}
}
if self.vars.contains_key("PATH")
&& let Ok(system_path) = env::var("PATH")
{
tracing::debug!(
command = %command,
"Command not found in env PATH, trying system PATH"
);
for dir in system_path.split(':') {
if dir.is_empty() {
continue;
}
let candidate = std::path::Path::new(dir).join(command);
if candidate.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(&candidate) {
let permissions = metadata.permissions();
if permissions.mode() & 0o111 != 0 {
tracing::debug!(
command = %command,
resolved = %candidate.display(),
"Command resolved from system PATH"
);
return candidate.to_string_lossy().to_string();
}
}
}
#[cfg(not(unix))]
{
tracing::debug!(
command = %command,
resolved = %candidate.display(),
"Command resolved from system PATH"
);
return candidate.to_string_lossy().to_string();
}
}
}
}
tracing::warn!(
command = %command,
env_path_set = self.vars.contains_key("PATH"),
"Command not found in PATH, returning original"
);
command.to_string()
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
self.vars.iter()
}
pub fn build_for_task(
task_name: &str,
env_vars: &HashMap<String, EnvValue>,
) -> HashMap<String, String> {
env_vars
.iter()
.filter(|(_, value)| value.is_accessible_by_task(task_name))
.map(|(key, value)| (key.clone(), value.to_string_value()))
.collect()
}
pub async fn resolve_all_with_secrets(
env_vars: &HashMap<String, EnvValue>,
) -> crate::Result<(HashMap<String, String>, Vec<String>)> {
let all: Vec<_> = env_vars.iter().collect();
Self::resolve_filtered_with_secrets(&all).await
}
pub async fn resolve_for_task(
task_name: &str,
env_vars: &HashMap<String, EnvValue>,
) -> crate::Result<HashMap<String, String>> {
let (resolved, _secrets) = Self::resolve_for_task_with_secrets(task_name, env_vars).await?;
Ok(resolved)
}
pub async fn resolve_for_task_with_secrets(
task_name: &str,
env_vars: &HashMap<String, EnvValue>,
) -> crate::Result<(HashMap<String, String>, Vec<String>)> {
tracing::debug!(
task = task_name,
env_count = env_vars.len(),
"resolve_for_task_with_secrets"
);
let accessible: Vec<_> = env_vars
.iter()
.filter(|(_, value)| value.is_accessible_by_task(task_name))
.collect();
Self::resolve_filtered_with_secrets(&accessible).await
}
pub async fn resolve_for_service_with_secrets(
service_name: &str,
env_vars: &HashMap<String, EnvValue>,
) -> crate::Result<(HashMap<String, String>, Vec<String>)> {
tracing::debug!(
service = service_name,
env_count = env_vars.len(),
"resolve_for_service_with_secrets"
);
let accessible: Vec<_> = env_vars
.iter()
.filter(|(_, value)| value.is_accessible_by_task(service_name))
.collect();
Self::resolve_filtered_with_secrets(&accessible).await
}
pub fn build_for_exec(
command: &str,
env_vars: &HashMap<String, EnvValue>,
) -> HashMap<String, String> {
env_vars
.iter()
.filter(|(_, value)| value.is_accessible_by_exec(command))
.map(|(key, value)| (key.clone(), value.to_string_value()))
.collect()
}
pub async fn resolve_for_exec(
command: &str,
env_vars: &HashMap<String, EnvValue>,
) -> crate::Result<HashMap<String, String>> {
let (resolved, _secrets) = Self::resolve_for_exec_with_secrets(command, env_vars).await?;
Ok(resolved)
}
pub async fn resolve_for_exec_with_secrets(
command: &str,
env_vars: &HashMap<String, EnvValue>,
) -> crate::Result<(HashMap<String, String>, Vec<String>)> {
let accessible: Vec<_> = env_vars
.iter()
.filter(|(_, value)| value.is_accessible_by_exec(command))
.collect();
Self::resolve_filtered_with_secrets(&accessible).await
}
async fn resolve_filtered_with_secrets(
accessible: &[(&String, &EnvValue)],
) -> crate::Result<(HashMap<String, String>, Vec<String>)> {
let mut resolved = HashMap::new();
let mut all_secrets = Vec::new();
type SecretVarEntry<'a> = (
&'a String,
&'a EnvValue,
Vec<(usize, crate::secrets::Secret)>,
);
let mut secret_vars: Vec<SecretVarEntry<'_>> = Vec::new();
for (key, value) in accessible {
let collected = value.collect_secrets();
if collected.is_empty() {
resolved.insert((*key).clone(), value.to_string_value());
} else {
let owned_secrets: Vec<(usize, crate::secrets::Secret)> = collected
.into_iter()
.map(|(idx, s)| (idx, s.clone()))
.collect();
secret_vars.push((key, value, owned_secrets));
}
}
if secret_vars.is_empty() {
return Ok((resolved, all_secrets));
}
let registry = Arc::new(crate::secrets::create_default_registry()?);
let mut join_set = tokio::task::JoinSet::new();
for (key, _, secrets) in &secret_vars {
for (part_idx, secret) in secrets {
let key = (*key).clone();
let part_idx = *part_idx;
let secret = secret.clone();
let registry = Arc::clone(®istry);
join_set.spawn(async move {
let value = secret.resolve_with_registry(®istry).await?;
Ok::<_, crate::Error>((key, part_idx, value))
});
}
}
let mut resolved_by_key: HashMap<String, HashMap<usize, String>> = HashMap::new();
while let Some(result) = join_set.join_next().await {
let (key, part_idx, value) = result.map_err(|e| {
crate::Error::configuration(format!("Secret resolution task panicked: {e}"))
})??;
resolved_by_key
.entry(key)
.or_default()
.insert(part_idx, value);
}
for (key, value, _) in &secret_vars {
let key_resolved = resolved_by_key.get(*key).cloned().unwrap_or_default();
let (final_value, mut value_secrets) = value.reassemble_with_resolved(&key_resolved);
if !value_secrets.is_empty() {
tracing::debug!(
key = *key,
secret_count = value_secrets.len(),
"resolved secrets"
);
}
all_secrets.append(&mut value_secrets);
resolved.insert((*key).clone(), final_value);
}
Ok((resolved, all_secrets))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_environment_basics() {
let mut env = Environment::new();
assert!(env.is_empty());
env.set("FOO".to_string(), "bar".to_string());
assert_eq!(env.len(), 1);
assert!(env.contains("FOO"));
assert_eq!(env.get("FOO"), Some("bar"));
assert!(!env.contains("BAR"));
}
#[test]
fn test_environment_from_map() {
let mut vars = HashMap::new();
vars.insert("KEY1".to_string(), "value1".to_string());
vars.insert("KEY2".to_string(), "value2".to_string());
let env = Environment::from_map(vars);
assert_eq!(env.len(), 2);
assert_eq!(env.get("KEY1"), Some("value1"));
assert_eq!(env.get("KEY2"), Some("value2"));
}
#[test]
fn test_environment_to_vec() {
let mut env = Environment::new();
env.set("VAR1".to_string(), "val1".to_string());
env.set("VAR2".to_string(), "val2".to_string());
let vec = env.to_env_vec();
assert_eq!(vec.len(), 2);
assert!(vec.contains(&"VAR1=val1".to_string()));
assert!(vec.contains(&"VAR2=val2".to_string()));
}
#[test]
fn test_environment_merge_with_system() {
let mut env = Environment::new();
env.set("PATH".to_string(), "/custom/path".to_string());
env.set("CUSTOM_VAR".to_string(), "custom_value".to_string());
let merged = env.merge_with_system();
assert_eq!(merged.get("PATH"), Some(&"/custom/path".to_string()));
assert_eq!(merged.get("CUSTOM_VAR"), Some(&"custom_value".to_string()));
assert!(merged.len() >= 2);
}
#[test]
fn test_environment_iteration() {
let mut env = Environment::new();
env.set("A".to_string(), "1".to_string());
env.set("B".to_string(), "2".to_string());
let mut count = 0;
for (key, value) in env.iter() {
assert!(key == "A" || key == "B");
assert!(value == "1" || value == "2");
count += 1;
}
assert_eq!(count, 2);
}
#[test]
fn test_env_value_types() {
let str_val = EnvValue::String("test".to_string());
let int_val = EnvValue::Int(42);
let bool_val = EnvValue::Bool(true);
assert_eq!(str_val, EnvValue::String("test".to_string()));
assert_eq!(int_val, EnvValue::Int(42));
assert_eq!(bool_val, EnvValue::Bool(true));
}
#[test]
fn test_policy_task_access() {
let simple_var = EnvValue::String("simple".to_string());
assert!(simple_var.is_accessible_by_task("any_task"));
let no_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
value: EnvValueSimple::String("value".to_string()),
policies: None,
});
assert!(no_policy_var.is_accessible_by_task("any_task"));
let empty_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
value: EnvValueSimple::String("value".to_string()),
policies: Some(vec![]),
});
assert!(empty_policy_var.is_accessible_by_task("any_task"));
let restricted_var = EnvValue::WithPolicies(EnvVarWithPolicies {
value: EnvValueSimple::String("secret".to_string()),
policies: Some(vec![Policy {
allow_tasks: Some(vec!["deploy".to_string(), "release".to_string()]),
allow_exec: None,
}]),
});
assert!(restricted_var.is_accessible_by_task("deploy"));
assert!(restricted_var.is_accessible_by_task("release"));
assert!(!restricted_var.is_accessible_by_task("test"));
assert!(!restricted_var.is_accessible_by_task("build"));
}
#[test]
fn test_policy_exec_access() {
let simple_var = EnvValue::String("simple".to_string());
assert!(simple_var.is_accessible_by_exec("bash"));
let restricted_var = EnvValue::WithPolicies(EnvVarWithPolicies {
value: EnvValueSimple::String("secret".to_string()),
policies: Some(vec![Policy {
allow_tasks: None,
allow_exec: Some(vec!["kubectl".to_string(), "terraform".to_string()]),
}]),
});
assert!(restricted_var.is_accessible_by_exec("kubectl"));
assert!(restricted_var.is_accessible_by_exec("terraform"));
assert!(!restricted_var.is_accessible_by_exec("bash"));
assert!(!restricted_var.is_accessible_by_exec("sh"));
}
#[test]
fn test_multiple_policies() {
let multi_policy_var = EnvValue::WithPolicies(EnvVarWithPolicies {
value: EnvValueSimple::String("value".to_string()),
policies: Some(vec![
Policy {
allow_tasks: Some(vec!["task1".to_string()]),
allow_exec: None,
},
Policy {
allow_tasks: Some(vec!["task2".to_string()]),
allow_exec: Some(vec!["kubectl".to_string()]),
},
]),
});
assert!(multi_policy_var.is_accessible_by_task("task1"));
assert!(multi_policy_var.is_accessible_by_task("task2"));
assert!(!multi_policy_var.is_accessible_by_task("task3"));
assert!(multi_policy_var.is_accessible_by_exec("kubectl"));
assert!(!multi_policy_var.is_accessible_by_exec("bash"));
}
#[test]
fn test_to_string_value() {
assert_eq!(
EnvValue::String("test".to_string()).to_string_value(),
"test"
);
assert_eq!(EnvValue::Int(42).to_string_value(), "42");
assert_eq!(EnvValue::Bool(true).to_string_value(), "true");
assert_eq!(EnvValue::Bool(false).to_string_value(), "false");
let with_policies = EnvValue::WithPolicies(EnvVarWithPolicies {
value: EnvValueSimple::String("policy_value".to_string()),
policies: Some(vec![]),
});
assert_eq!(with_policies.to_string_value(), "policy_value");
}
#[test]
fn test_build_for_task() {
let mut env_vars = HashMap::new();
env_vars.insert(
"PUBLIC".to_string(),
EnvValue::String("public_value".to_string()),
);
env_vars.insert(
"SECRET".to_string(),
EnvValue::WithPolicies(EnvVarWithPolicies {
value: EnvValueSimple::String("secret_value".to_string()),
policies: Some(vec![Policy {
allow_tasks: Some(vec!["deploy".to_string()]),
allow_exec: None,
}]),
}),
);
let deploy_env = Environment::build_for_task("deploy", &env_vars);
assert_eq!(deploy_env.len(), 2);
assert_eq!(deploy_env.get("PUBLIC"), Some(&"public_value".to_string()));
assert_eq!(deploy_env.get("SECRET"), Some(&"secret_value".to_string()));
let test_env = Environment::build_for_task("test", &env_vars);
assert_eq!(test_env.len(), 1);
assert_eq!(test_env.get("PUBLIC"), Some(&"public_value".to_string()));
assert_eq!(test_env.get("SECRET"), None);
}
#[test]
fn test_build_for_exec() {
let mut env_vars = HashMap::new();
env_vars.insert(
"PUBLIC".to_string(),
EnvValue::String("public_value".to_string()),
);
env_vars.insert(
"SECRET".to_string(),
EnvValue::WithPolicies(EnvVarWithPolicies {
value: EnvValueSimple::String("secret_value".to_string()),
policies: Some(vec![Policy {
allow_tasks: None,
allow_exec: Some(vec!["kubectl".to_string()]),
}]),
}),
);
let kubectl_env = Environment::build_for_exec("kubectl", &env_vars);
assert_eq!(kubectl_env.len(), 2);
assert_eq!(kubectl_env.get("PUBLIC"), Some(&"public_value".to_string()));
assert_eq!(kubectl_env.get("SECRET"), Some(&"secret_value".to_string()));
let bash_env = Environment::build_for_exec("bash", &env_vars);
assert_eq!(bash_env.len(), 1);
assert_eq!(bash_env.get("PUBLIC"), Some(&"public_value".to_string()));
assert_eq!(bash_env.get("SECRET"), None);
}
#[test]
fn test_env_for_environment() {
let mut base = HashMap::new();
base.insert("BASE_VAR".to_string(), EnvValue::String("base".to_string()));
base.insert(
"OVERRIDE_ME".to_string(),
EnvValue::String("original".to_string()),
);
let mut dev_env = HashMap::new();
dev_env.insert(
"OVERRIDE_ME".to_string(),
EnvValue::String("dev".to_string()),
);
dev_env.insert(
"DEV_VAR".to_string(),
EnvValue::String("development".to_string()),
);
let mut environments = HashMap::new();
environments.insert("development".to_string(), dev_env);
let env = Env {
base,
environment: Some(environments),
};
let dev_vars = env.for_environment("development");
assert_eq!(
dev_vars.get("BASE_VAR"),
Some(&EnvValue::String("base".to_string()))
);
assert_eq!(
dev_vars.get("OVERRIDE_ME"),
Some(&EnvValue::String("dev".to_string()))
);
assert_eq!(
dev_vars.get("DEV_VAR"),
Some(&EnvValue::String("development".to_string()))
);
}
#[test]
fn test_env_deserialize_with_environment_overrides() {
let json = r#"{
"API_URL": "https://api.example.com",
"environment": {
"production": {
"API_URL": "https://api.prod.example.com",
"AUTH_SECRET": {"resolver": "exec", "command": "echo", "args": ["token"]}
}
}
}"#;
let env: Env = serde_json::from_str(json).expect("valid env payload");
assert!(env.base.contains_key("API_URL"));
assert!(!env.base.contains_key("environment"));
let environments = env
.environment
.expect("environment overrides should deserialize");
let production = environments
.get("production")
.expect("production overrides should exist");
assert!(production.contains_key("AUTH_SECRET"));
}
#[tokio::test]
async fn test_resolve_plain_string() {
let env_val = EnvValue::String("plain_value".to_string());
let resolved = env_val.resolve().await.unwrap();
assert_eq!(resolved, "plain_value");
}
#[tokio::test]
async fn test_resolve_int() {
let env_val = EnvValue::Int(42);
let resolved = env_val.resolve().await.unwrap();
assert_eq!(resolved, "42");
}
#[tokio::test]
async fn test_resolve_bool() {
let env_val = EnvValue::Bool(true);
let resolved = env_val.resolve().await.unwrap();
assert_eq!(resolved, "true");
}
#[tokio::test]
async fn test_resolve_with_policies_plain_string() {
let env_val = EnvValue::WithPolicies(EnvVarWithPolicies {
value: EnvValueSimple::String("policy_value".to_string()),
policies: None,
});
let resolved = env_val.resolve().await.unwrap();
assert_eq!(resolved, "policy_value");
}
#[test]
fn test_env_part_literal() {
let part = EnvPart::Literal("hello".to_string());
assert!(!part.is_secret());
}
#[test]
fn test_env_part_secret() {
let secret = crate::secrets::Secret::new("echo".to_string(), vec!["test".to_string()]);
let part = EnvPart::Secret(secret);
assert!(part.is_secret());
}
#[test]
fn test_env_part_deserialization_literal() {
let json = r#""hello""#;
let part: EnvPart = serde_json::from_str(json).unwrap();
assert!(matches!(part, EnvPart::Literal(ref s) if s == "hello"));
assert!(!part.is_secret());
}
#[test]
fn test_env_part_deserialization_secret() {
let json = r#"{"resolver": "exec", "command": "echo", "args": ["test"]}"#;
let part: EnvPart = serde_json::from_str(json).unwrap();
assert!(part.is_secret());
}
#[test]
fn test_env_value_interpolated_deserialization() {
let json =
r#"["prefix-", {"resolver": "exec", "command": "gh", "args": ["auth", "token"]}]"#;
let value: EnvValue = serde_json::from_str(json).unwrap();
assert!(matches!(value, EnvValue::Interpolated(_)));
assert!(value.is_secret());
}
#[test]
fn test_interpolated_is_secret_with_no_secrets() {
let parts = vec![
EnvPart::Literal("hello".to_string()),
EnvPart::Literal("world".to_string()),
];
let value = EnvValue::Interpolated(parts);
assert!(!value.is_secret());
}
#[test]
fn test_interpolated_is_secret_with_secret() {
let secret = crate::secrets::Secret::new("echo".to_string(), vec![]);
let parts = vec![
EnvPart::Literal("prefix".to_string()),
EnvPart::Secret(secret),
];
let value = EnvValue::Interpolated(parts);
assert!(value.is_secret());
}
#[test]
fn test_interpolated_to_string_value_redacts_secrets() {
let secret = crate::secrets::Secret::new(
"gh".to_string(),
vec!["auth".to_string(), "token".to_string()],
);
let parts = vec![
EnvPart::Literal("access-tokens = github.com=".to_string()),
EnvPart::Secret(secret),
];
let value = EnvValue::Interpolated(parts);
assert_eq!(value.to_string_value(), "access-tokens = github.com=*_*");
}
#[test]
fn test_interpolated_to_string_value_no_secrets() {
let parts = vec![
EnvPart::Literal("hello".to_string()),
EnvPart::Literal("-".to_string()),
EnvPart::Literal("world".to_string()),
];
let value = EnvValue::Interpolated(parts);
assert_eq!(value.to_string_value(), "hello-world");
}
#[tokio::test]
async fn test_resolve_with_secrets_collects_only_secret_parts() {
let parts = vec![
EnvPart::Literal("hello-".to_string()),
EnvPart::Literal("world".to_string()),
];
let value = EnvValue::Interpolated(parts);
let (resolved, secrets) = value.resolve_with_secrets().await.unwrap();
assert_eq!(resolved, "hello-world");
assert!(secrets.is_empty()); }
#[tokio::test]
async fn test_resolve_interpolated_concatenates_parts() {
let parts = vec![
EnvPart::Literal("a".to_string()),
EnvPart::Literal("b".to_string()),
EnvPart::Literal("c".to_string()),
];
let value = EnvValue::Interpolated(parts);
let resolved = value.resolve().await.unwrap();
assert_eq!(resolved, "abc");
}
#[test]
fn test_interpolated_with_policies_is_secret() {
let secret = crate::secrets::Secret::new("cmd".to_string(), vec![]);
let parts = vec![
EnvPart::Literal("prefix".to_string()),
EnvPart::Secret(secret),
];
let value = EnvValue::WithPolicies(EnvVarWithPolicies {
value: EnvValueSimple::Interpolated(parts),
policies: Some(vec![Policy {
allow_tasks: Some(vec!["deploy".to_string()]),
allow_exec: None,
}]),
});
assert!(value.is_secret());
}
#[test]
fn test_interpolated_with_policies_to_string_value() {
let secret = crate::secrets::Secret::new("cmd".to_string(), vec![]);
let parts = vec![
EnvPart::Literal("before-".to_string()),
EnvPart::Secret(secret),
EnvPart::Literal("-after".to_string()),
];
let value = EnvValue::WithPolicies(EnvVarWithPolicies {
value: EnvValueSimple::Interpolated(parts),
policies: None,
});
assert_eq!(value.to_string_value(), "before-*_*-after");
}
#[test]
fn test_interpolated_accessible_by_task() {
let parts = vec![EnvPart::Literal("value".to_string())];
let value = EnvValue::Interpolated(parts);
assert!(value.is_accessible_by_task("any_task"));
}
#[test]
fn test_extract_static_env_vars_skips_interpolated_secrets() {
let secret = crate::secrets::Secret::new("cmd".to_string(), vec![]);
let parts = vec![
EnvPart::Literal("prefix".to_string()),
EnvPart::Secret(secret),
];
let mut base = HashMap::new();
base.insert("PLAIN".to_string(), EnvValue::String("value".to_string()));
base.insert(
"INTERPOLATED_SECRET".to_string(),
EnvValue::Interpolated(parts),
);
base.insert(
"INTERPOLATED_PLAIN".to_string(),
EnvValue::Interpolated(vec![
EnvPart::Literal("a".to_string()),
EnvPart::Literal("b".to_string()),
]),
);
let vars: HashMap<_, _> = base
.iter()
.filter(|(_, v)| !v.is_secret())
.map(|(k, v)| (k.clone(), v.to_string_value()))
.collect();
assert!(vars.contains_key("PLAIN"));
assert!(!vars.contains_key("INTERPOLATED_SECRET"));
assert!(vars.contains_key("INTERPOLATED_PLAIN"));
assert_eq!(vars.get("INTERPOLATED_PLAIN"), Some(&"ab".to_string()));
}
#[test]
fn test_env_value_simple_interpolated_deserialization() {
let json = r#"["a", "b", "c"]"#;
let value: EnvValueSimple = serde_json::from_str(json).unwrap();
assert!(matches!(value, EnvValueSimple::Interpolated(_)));
}
#[test]
fn test_env_value_with_policies_interpolated_deserialization() {
let json = r#"{
"value": ["prefix-", {"resolver": "exec", "command": "gh", "args": ["auth", "token"]}],
"policies": [{"allowTasks": ["deploy"]}]
}"#;
let value: EnvValue = serde_json::from_str(json).unwrap();
assert!(matches!(value, EnvValue::WithPolicies(_)));
assert!(value.is_secret());
}
#[test]
fn test_interpolated_empty_array() {
let parts = vec![];
let value = EnvValue::Interpolated(parts);
assert_eq!(value.to_string_value(), "");
assert!(!value.is_secret());
}
#[tokio::test]
async fn test_resolve_interpolated_with_actual_secret() {
let secret =
crate::secrets::Secret::new("echo".to_string(), vec!["secret_value".to_string()]);
let parts = vec![
EnvPart::Literal("prefix-".to_string()),
EnvPart::Secret(secret),
EnvPart::Literal("-suffix".to_string()),
];
let value = EnvValue::Interpolated(parts);
let (resolved, secrets) = value.resolve_with_secrets().await.unwrap();
assert!(resolved.contains("prefix-"));
assert!(resolved.contains("secret_value"));
assert!(resolved.contains("-suffix"));
assert_eq!(secrets.len(), 1);
}
}