use std::collections::BTreeMap;
use std::fmt::Write;
use anyhow::{Context, Result};
use tempfile::NamedTempFile;
use crate::config::{Config, ResolvedTarget};
use crate::targets::{DeployMode, DeployOutcome, DeployResult, DeployTarget, SecretValue};
fn format_env_value(value: &str) -> String {
let needs_quoting = value.contains('"')
|| value.contains('\\')
|| value.contains(' ')
|| value.contains('#')
|| value.starts_with('=');
if !needs_quoting {
return value.to_string();
}
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
fn validate_dotenv_value(key: &str, value: &str) -> Result<()> {
if value.contains('\n') || value.contains('\r') {
anyhow::bail!(
".env: secret '{key}' contains newlines, refusing to write multiline values to .env files"
);
}
Ok(())
}
pub struct DotenvTarget<'a> {
pub config: &'a Config,
}
impl DeployTarget for DotenvTarget<'_> {
fn name(&self) -> &'static str {
".env"
}
fn deploy_mode(&self) -> DeployMode {
DeployMode::Batch
}
fn deploy_secret(&self, _key: &str, _value: &str, _target: &ResolvedTarget) -> Result<()> {
Ok(())
}
fn deploy_batch(&self, secrets: &[SecretValue], target: &ResolvedTarget) -> Vec<DeployResult> {
let Some(app) = &target.app else {
return secrets
.iter()
.map(|s| DeployResult {
key: s.key.clone(),
outcome: DeployOutcome::Failed(".env target requires an app".to_string()),
})
.collect();
};
match self.write_dotenv_file(app, &target.environment, secrets) {
Ok(()) => secrets
.iter()
.map(|s| DeployResult {
key: s.key.clone(),
outcome: DeployOutcome::Success,
})
.collect(),
Err(e) => secrets
.iter()
.map(|s| DeployResult {
key: s.key.clone(),
outcome: DeployOutcome::Failed(e.to_string()),
})
.collect(),
}
}
}
impl DotenvTarget<'_> {
fn write_dotenv_file(&self, app: &str, env: &str, secrets: &[SecretValue]) -> Result<()> {
let path = self.config.resolve_dotenv_path(app, env)?;
let mut by_group: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
for secret in secrets {
validate_dotenv_value(&secret.key, &secret.value)?;
by_group
.entry(&secret.group)
.or_default()
.push((&secret.key, &secret.value));
}
let mut content = String::new();
content.push_str("# Auto-generated by esk — do not edit manually\n");
content.push_str("#\n");
content.push_str("# Update secrets: esk set <KEY> --env <ENV>\n");
content.push_str("# Regenerate file: esk deploy --env <ENV>\n");
for (group, mut entries) in by_group {
entries.sort_by_key(|(k, _)| *k);
content.push('\n');
let _ = writeln!(content, "# === {group} ===");
for (key, value) in entries {
let _ = writeln!(content, "{key}={}", format_env_value(value));
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
let dir = path.parent().context("env path has no parent")?;
let tmp = NamedTempFile::new_in(dir)?;
std::fs::write(tmp.path(), &content)?;
tmp.persist(&path)
.with_context(|| format!("failed to write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400))?;
}
#[cfg(not(unix))]
{
let mut perms = std::fs::metadata(&path)?.permissions();
perms.set_readonly(true);
std::fs::set_permissions(&path, perms)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::targets::SecretValue;
fn make_config(dir: &std::path::Path) -> Config {
let yaml = r#"
project: testapp
environments: [dev, prod]
apps:
web:
path: apps/web
targets:
.env:
pattern: "{app_path}/.env{env_suffix}.local"
env_suffix:
dev: ""
prod: ".production"
"#;
let path = dir.join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
Config::load(&path).unwrap()
}
fn make_target(app: Option<&str>, env: &str) -> ResolvedTarget {
ResolvedTarget {
service: ".env".to_string(),
app: app.map(String::from),
environment: env.to_string(),
}
}
fn make_secret(key: &str, value: &str, group: &str) -> SecretValue {
SecretValue {
key: key.to_string(),
value: value.to_string(),
group: group.to_string(),
}
}
#[test]
fn deploy_secret_is_noop() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let target = DotenvTarget { config: &config };
target
.deploy_secret("KEY", "val", &make_target(Some("web"), "dev"))
.unwrap();
}
#[test]
fn deploy_batch_no_app_errors() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let target = DotenvTarget { config: &config };
let secrets = vec![make_secret("A", "1", "G"), make_secret("B", "2", "G")];
let results = target.deploy_batch(&secrets, &make_target(None, "dev"));
assert!(results.iter().all(|r| !r.outcome.is_success()));
assert!(results[0]
.outcome
.error_message()
.unwrap()
.contains("requires an app"));
}
#[test]
fn deploy_batch_writes_env_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("apps/web")).unwrap();
let config = make_config(dir.path());
let target = DotenvTarget { config: &config };
let secrets = vec![make_secret("KEY", "value123", "General")];
let results = target.deploy_batch(&secrets, &make_target(Some("web"), "dev"));
assert!(results.iter().all(|r| r.outcome.is_success()));
let content = std::fs::read_to_string(dir.path().join("apps/web/.env.local")).unwrap();
assert!(content.contains("KEY=value123"));
}
#[test]
fn env_file_header() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("apps/web")).unwrap();
let config = make_config(dir.path());
let target = DotenvTarget { config: &config };
let secrets = vec![make_secret("K", "v", "G")];
target.deploy_batch(&secrets, &make_target(Some("web"), "dev"));
let content = std::fs::read_to_string(dir.path().join("apps/web/.env.local")).unwrap();
assert!(content.starts_with("# Auto-generated by esk"));
}
#[test]
fn env_file_grouped_by_group() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("apps/web")).unwrap();
let config = make_config(dir.path());
let target = DotenvTarget { config: &config };
let secrets = vec![
make_secret("A", "1", "Stripe"),
make_secret("B", "2", "Convex"),
];
target.deploy_batch(&secrets, &make_target(Some("web"), "dev"));
let content = std::fs::read_to_string(dir.path().join("apps/web/.env.local")).unwrap();
assert!(content.contains("# === Stripe ==="));
assert!(content.contains("# === Convex ==="));
}
#[test]
fn env_file_sorted_within_group() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("apps/web")).unwrap();
let config = make_config(dir.path());
let target = DotenvTarget { config: &config };
let secrets = vec![
make_secret("ZEBRA", "z", "G"),
make_secret("APPLE", "a", "G"),
];
target.deploy_batch(&secrets, &make_target(Some("web"), "dev"));
let content = std::fs::read_to_string(dir.path().join("apps/web/.env.local")).unwrap();
let apple_pos = content.find("APPLE=").unwrap();
let zebra_pos = content.find("ZEBRA=").unwrap();
assert!(apple_pos < zebra_pos);
}
#[test]
fn env_file_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let target = DotenvTarget { config: &config };
let secrets = vec![make_secret("K", "v", "G")];
let results = target.deploy_batch(&secrets, &make_target(Some("web"), "dev"));
assert!(results[0].outcome.is_success());
assert!(dir.path().join("apps/web/.env.local").is_file());
}
#[test]
fn deploy_batch_all_success() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("apps/web")).unwrap();
let config = make_config(dir.path());
let target = DotenvTarget { config: &config };
let secrets = vec![
make_secret("A", "1", "G"),
make_secret("B", "2", "G"),
make_secret("C", "3", "G"),
];
let results = target.deploy_batch(&secrets, &make_target(Some("web"), "dev"));
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| r.outcome.is_success()));
}
#[test]
fn format_env_value_plain() {
assert_eq!(format_env_value("simple_value"), "simple_value");
}
#[test]
fn format_env_value_with_double_quote() {
assert_eq!(format_env_value(r#"say "hi""#), r#""say \"hi\"""#);
}
#[test]
fn format_env_value_with_backslash() {
assert_eq!(format_env_value(r"path\to"), r#""path\\to""#);
}
#[test]
fn format_env_value_with_hash() {
assert_eq!(format_env_value("val#comment"), "\"val#comment\"");
}
#[test]
fn format_env_value_with_space() {
assert_eq!(format_env_value("has space"), "\"has space\"");
}
#[test]
fn format_env_value_starts_with_equals() {
assert_eq!(format_env_value("=oops"), "\"=oops\"");
}
#[test]
fn deploy_batch_newline_value_is_rejected() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("apps/web")).unwrap();
let config = make_config(dir.path());
let target = DotenvTarget { config: &config };
let secrets = vec![make_secret("CERT", "line1\nline2", "General")];
let results = target.deploy_batch(&secrets, &make_target(Some("web"), "dev"));
assert!(results.iter().all(|r| !r.outcome.is_success()));
assert!(results[0]
.outcome
.error_message()
.unwrap()
.contains("contains newlines"));
assert!(!dir.path().join("apps/web/.env.local").exists());
}
#[test]
#[cfg(unix)]
fn env_file_has_restricted_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("apps/web")).unwrap();
let config = make_config(dir.path());
let target = DotenvTarget { config: &config };
let secrets = vec![make_secret("KEY", "value", "General")];
let results = target.deploy_batch(&secrets, &make_target(Some("web"), "dev"));
assert!(results.iter().all(|r| r.outcome.is_success()));
let metadata = std::fs::metadata(dir.path().join("apps/web/.env.local")).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode, 0o400);
}
#[test]
fn deploy_batch_write_error() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
project: x
environments: [dev]
apps:
web:
path: apps/web
targets:
.env:
pattern: "{app_path}/.env"
"#;
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let mut config = Config::load(&path).unwrap();
config.root = std::path::PathBuf::from("/nonexistent/root");
let target = DotenvTarget { config: &config };
let secrets = vec![make_secret("K", "v", "G")];
let results = target.deploy_batch(&secrets, &make_target(Some("web"), "dev"));
assert!(!results[0].outcome.is_success());
assert!(results[0].outcome.error_message().is_some());
}
}