esk 0.8.0

Encrypted Secrets Keeper with multi-target deploy
Documentation
//! .env file target — writes secrets to a local `.env` file.
//!
//! Not a cloud service — generates standard dotenv files consumed by most
//! frameworks and runtimes (Node.js, Python, Ruby, etc.).
//!
//! Operates in **batch mode**: the entire file is regenerated atomically on
//! every deploy via temp-file-then-rename. Deletions are handled implicitly
//! by omitting the key from the next write. Values containing newlines are
//! rejected by `validate_dotenv_value` before formatting.

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};

/// Format a value for safe inclusion in a .env file.
///
/// If the value contains characters that could cause parsing issues (double
/// quotes, backslashes, spaces, `#`, or starts with `=`), wraps it in double
/// quotes with proper escaping. Newlines are rejected earlier by
/// `validate_dotenv_value` and never reach this function.
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<()> {
        // Env target always uses deploy_batch — individual deploy is a no-op
        // because we need to regenerate the entire file atomically
        Ok(())
    }

    /// Override: regenerate the entire env file for this (app, env) pair.
    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)?;

        // Group secrets by group, maintaining sorted order
        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));
            }
        }

        // Ensure parent directory exists
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("failed to create directory {}", parent.display()))?;
        }

        // Atomic write
        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()))?;

        // Mark read-only to discourage manual edits
        #[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();
        // Don't pre-create apps/web — target should create it
        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();
        // Config with bad pattern that will fail to resolve
        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();
        // Point root to a read-only location to force write failure
        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());
    }
}