use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use std::sync::LazyLock;
use anyhow::{bail, Context, Result};
use regex::Regex;
use super::model::DevrigConfig;
static ENV_VAR_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)|\$\$")
.expect("valid regex")
});
#[derive(Debug, Default)]
pub struct SecretRegistry {
secret_values: HashSet<String>,
}
impl SecretRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn track(&mut self, value: &str) {
if !value.is_empty() {
self.secret_values.insert(value.to_string());
}
}
pub fn mask_value(&self, value: &str) -> String {
let mut result = value.to_string();
for secret in &self.secret_values {
if !secret.is_empty() {
result = result.replace(secret.as_str(), "****");
}
}
result
}
pub fn contains_secret(&self, value: &str) -> bool {
self.secret_values
.iter()
.any(|s| !s.is_empty() && value.contains(s.as_str()))
}
}
pub fn parse_env_file(path: &Path) -> Result<BTreeMap<String, String>> {
if !path.exists() {
return Ok(BTreeMap::new());
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading .env file {}", path.display()))?;
parse_env_content(&content)
}
fn parse_env_content(content: &str) -> Result<BTreeMap<String, String>> {
let mut vars = BTreeMap::new();
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
bail!(
".env line {}: expected KEY=VALUE, got {:?}",
line_num + 1,
trimmed
);
};
let key = key.trim().to_string();
if key.is_empty() {
bail!(".env line {}: empty key", line_num + 1);
}
let value = value.trim();
let value = if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
&value[1..value.len() - 1]
} else {
value
};
vars.insert(key, value.to_string());
}
Ok(vars)
}
pub fn expand_env_vars(
input: &str,
env_file_vars: &BTreeMap<String, String>,
field_context: &str,
) -> Result<(String, bool)> {
if !input.contains('$') {
return Ok((input.to_string(), false));
}
let mut result = String::with_capacity(input.len());
let mut last_end = 0;
let mut had_expansion = false;
for caps in ENV_VAR_RE.captures_iter(input) {
let m = caps.get(0).unwrap();
result.push_str(&input[last_end..m.start()]);
let matched = m.as_str();
if matched == "$$" {
result.push('$');
} else {
let var_name = caps
.get(1)
.or_else(|| caps.get(2))
.unwrap()
.as_str();
let value = env_file_vars
.get(var_name)
.cloned()
.or_else(|| std::env::var(var_name).ok());
match value {
Some(val) => {
result.push_str(&val);
had_expansion = true;
}
None => {
bail!(
"undefined environment variable ${} in {}",
var_name,
field_context
);
}
}
}
last_end = m.end();
}
result.push_str(&input[last_end..]);
Ok((result, had_expansion))
}
pub fn load_env_files(
config: &DevrigConfig,
config_dir: &Path,
) -> Result<BTreeMap<String, String>> {
let mut vars = BTreeMap::new();
if let Some(env_file) = &config.project.env_file {
let path = config_dir.join(env_file);
let file_vars = parse_env_file(&path)
.with_context(|| format!("loading project env_file {:?}", env_file))?;
vars.extend(file_vars);
}
for (name, svc) in &config.services {
if let Some(env_file) = &svc.env_file {
let path = config_dir.join(env_file);
let file_vars = parse_env_file(&path)
.with_context(|| format!("loading env_file for service {:?}", name))?;
vars.extend(file_vars);
}
}
Ok(vars)
}
pub fn merge_env_file_values(
config: &mut DevrigConfig,
config_dir: &Path,
) -> Result<()> {
if let Some(env_file) = &config.project.env_file {
let path = config_dir.join(env_file);
let file_vars = parse_env_file(&path)?;
for (key, value) in file_vars {
config.env.entry(key).or_insert(value);
}
}
for svc in config.services.values_mut() {
if let Some(env_file) = &svc.env_file {
let path = config_dir.join(env_file);
let file_vars = parse_env_file(&path)?;
for (key, value) in file_vars {
svc.env.entry(key).or_insert(value);
}
}
}
Ok(())
}
pub fn expand_config_env_vars(
config: &mut DevrigConfig,
env_file_vars: &BTreeMap<String, String>,
) -> Result<SecretRegistry> {
let mut registry = SecretRegistry::new();
let pairs: Vec<(String, String)> = config.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (key, value) in pairs {
let (expanded, was_secret) = expand_env_vars(&value, env_file_vars, &format!("env.{key}"))?;
if was_secret {
registry.track(&expanded);
}
config.env.insert(key, expanded);
}
let svc_names: Vec<String> = config.services.keys().cloned().collect();
for svc_name in svc_names {
let pairs: Vec<(String, String)> = config.services[&svc_name]
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (key, value) in pairs {
let (expanded, was_secret) = expand_env_vars(
&value,
env_file_vars,
&format!("services.{svc_name}.env.{key}"),
)?;
if was_secret {
registry.track(&expanded);
}
if let Some(svc) = config.services.get_mut(&svc_name) {
svc.env.insert(key, expanded);
}
}
}
let docker_names: Vec<String> = config.docker.keys().cloned().collect();
for docker_name in docker_names {
let pairs: Vec<(String, String)> = config.docker[&docker_name]
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (key, value) in pairs {
let (expanded, was_secret) = expand_env_vars(
&value,
env_file_vars,
&format!("docker.{docker_name}.env.{key}"),
)?;
if was_secret {
registry.track(&expanded);
}
if let Some(docker) = config.docker.get_mut(&docker_name) {
docker.env.insert(key, expanded);
}
}
let image = config.docker[&docker_name].image.clone();
let (expanded, was_secret) = expand_env_vars(
&image,
env_file_vars,
&format!("docker.{docker_name}.image"),
)?;
if was_secret {
registry.track(&expanded);
}
if let Some(docker) = config.docker.get_mut(&docker_name) {
docker.image = expanded;
}
if let Some(auth) = config.docker[&docker_name].registry_auth.as_ref() {
let username = auth.username.clone();
let password = auth.password.clone();
let (expanded_user, user_secret) = expand_env_vars(
&username,
env_file_vars,
&format!("docker.{docker_name}.registry_auth.username"),
)?;
let (expanded_pass, pass_secret) = expand_env_vars(
&password,
env_file_vars,
&format!("docker.{docker_name}.registry_auth.password"),
)?;
if user_secret {
registry.track(&expanded_user);
}
if pass_secret {
registry.track(&expanded_pass);
}
if let Some(auth_mut) = config
.docker
.get_mut(&docker_name)
.and_then(|d| d.registry_auth.as_mut())
{
auth_mut.username = expanded_user;
auth_mut.password = expanded_pass;
}
}
}
if let Some(cluster) = &mut config.cluster {
for (i, reg) in cluster.registries.iter_mut().enumerate() {
let (expanded_url, _) = expand_env_vars(
®.url,
env_file_vars,
&format!("cluster.registries[{}].url", i),
)?;
let (expanded_user, user_secret) = expand_env_vars(
®.username,
env_file_vars,
&format!("cluster.registries[{}].username", i),
)?;
let (expanded_pass, pass_secret) = expand_env_vars(
®.password,
env_file_vars,
&format!("cluster.registries[{}].password", i),
)?;
if user_secret {
registry.track(&expanded_user);
}
if pass_secret {
registry.track(&expanded_pass);
}
reg.url = expanded_url;
reg.username = expanded_user;
reg.password = expanded_pass;
}
}
Ok(registry)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_env_basic() {
let content = "FOO=bar\nBAZ=qux\n";
let vars = parse_env_content(content).unwrap();
assert_eq!(vars["FOO"], "bar");
assert_eq!(vars["BAZ"], "qux");
}
#[test]
fn parse_env_comments_and_blanks() {
let content = "# this is a comment\n\nFOO=bar\n # another comment\n\nBAZ=qux\n";
let vars = parse_env_content(content).unwrap();
assert_eq!(vars.len(), 2);
assert_eq!(vars["FOO"], "bar");
}
#[test]
fn parse_env_double_quotes() {
let content = "FOO=\"hello world\"\n";
let vars = parse_env_content(content).unwrap();
assert_eq!(vars["FOO"], "hello world");
}
#[test]
fn parse_env_single_quotes() {
let content = "FOO='hello world'\n";
let vars = parse_env_content(content).unwrap();
assert_eq!(vars["FOO"], "hello world");
}
#[test]
fn parse_env_empty_value() {
let content = "FOO=\n";
let vars = parse_env_content(content).unwrap();
assert_eq!(vars["FOO"], "");
}
#[test]
fn parse_env_missing_file_returns_empty() {
let vars = parse_env_file(Path::new("/nonexistent/.env")).unwrap();
assert!(vars.is_empty());
}
#[test]
fn expand_dollar_var() {
let env = BTreeMap::from([("MY_KEY".to_string(), "secret123".to_string())]);
let (result, expanded) = expand_env_vars("prefix-$MY_KEY-suffix", &env, "test").unwrap();
assert_eq!(result, "prefix-secret123-suffix");
assert!(expanded);
}
#[test]
fn expand_braced_var() {
let env = BTreeMap::from([("DB_PASS".to_string(), "p@ss".to_string())]);
let (result, expanded) =
expand_env_vars("postgres://user:${DB_PASS}@localhost", &env, "test").unwrap();
assert_eq!(result, "postgres://user:p@ss@localhost");
assert!(expanded);
}
#[test]
fn expand_dollar_dollar_escape() {
let env = BTreeMap::new();
let (result, expanded) = expand_env_vars("price is $$5", &env, "test").unwrap();
assert_eq!(result, "price is $5");
assert!(!expanded);
}
#[test]
fn expand_multiple_vars() {
let env = BTreeMap::from([
("USER".to_string(), "admin".to_string()),
("PASS".to_string(), "secret".to_string()),
]);
let (result, expanded) =
expand_env_vars("$USER:$PASS@host", &env, "test").unwrap();
assert_eq!(result, "admin:secret@host");
assert!(expanded);
}
#[test]
fn expand_undefined_var_errors() {
let env = BTreeMap::new();
let result = expand_env_vars("$DEFINITELY_UNDEFINED_VAR_12345", &env, "services.api.env.KEY");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("DEFINITELY_UNDEFINED_VAR_12345"));
assert!(err.contains("services.api.env.KEY"));
}
#[test]
fn expand_no_vars_is_noop() {
let env = BTreeMap::new();
let (result, expanded) =
expand_env_vars("plain string with no vars", &env, "test").unwrap();
assert_eq!(result, "plain string with no vars");
assert!(!expanded);
}
#[test]
fn expand_host_env_fallback() {
let env = BTreeMap::new();
let (result, expanded) = expand_env_vars("$PATH", &env, "test").unwrap();
assert!(!result.is_empty());
assert!(expanded);
}
#[test]
fn expand_env_file_takes_priority_over_host() {
let env = BTreeMap::from([("HOME".to_string(), "/custom/home".to_string())]);
let (result, _) = expand_env_vars("$HOME", &env, "test").unwrap();
assert_eq!(result, "/custom/home");
}
#[test]
fn secret_registry_mask_value() {
let mut reg = SecretRegistry::new();
reg.track("secret123");
reg.track("p@ssword");
assert_eq!(
reg.mask_value("url=postgres://user:secret123@localhost"),
"url=postgres://user:****@localhost"
);
assert_eq!(
reg.mask_value("password=p@ssword"),
"password=****"
);
}
#[test]
fn secret_registry_contains_secret() {
let mut reg = SecretRegistry::new();
reg.track("mysecret");
assert!(reg.contains_secret("prefix-mysecret-suffix"));
assert!(!reg.contains_secret("no secrets here"));
}
#[test]
fn secret_registry_empty_value_ignored() {
let mut reg = SecretRegistry::new();
reg.track("");
assert!(!reg.contains_secret("anything"));
assert_eq!(reg.mask_value("anything"), "anything");
}
#[test]
fn expand_config_env_vars_end_to_end() {
use crate::config::model::*;
let mut config = DevrigConfig {
project: ProjectConfig {
name: "test".to_string(),
env_file: None,
},
services: BTreeMap::new(),
docker: BTreeMap::new(),
compose: None,
cluster: None,
dashboard: None,
oidc: None,
env: BTreeMap::from([("KEY".to_string(), "$TEST_SECRET".to_string())]),
network: None,
links: BTreeMap::new(),
};
let env_file_vars =
BTreeMap::from([("TEST_SECRET".to_string(), "expanded_value".to_string())]);
let registry = expand_config_env_vars(&mut config, &env_file_vars).unwrap();
assert_eq!(config.env["KEY"], "expanded_value");
assert!(registry.contains_secret("expanded_value"));
}
#[test]
fn expand_config_preserves_non_secret_values() {
use crate::config::model::*;
let mut config = DevrigConfig {
project: ProjectConfig {
name: "test".to_string(),
env_file: None,
},
services: BTreeMap::new(),
docker: BTreeMap::new(),
compose: None,
cluster: None,
dashboard: None,
oidc: None,
env: BTreeMap::from([("PLAIN".to_string(), "no-vars-here".to_string())]),
network: None,
links: BTreeMap::new(),
};
let env_file_vars = BTreeMap::new();
let registry = expand_config_env_vars(&mut config, &env_file_vars).unwrap();
assert_eq!(config.env["PLAIN"], "no-vars-here");
assert!(!registry.contains_secret("no-vars-here"));
}
}