open-envault 0.3.2

Local-first SOPS-compatible secrets for Git and Rust
Documentation
use std::{fs, path::Path, process::Command};

const IDENTITY: &str = include_str!("fixtures/age-identity-dev.txt");

fn binary() -> &'static Path {
    Path::new(env!("CARGO_BIN_EXE_oenv"))
}

fn recipient() -> String {
    IDENTITY
        .lines()
        .find_map(|line| line.strip_prefix("# public key: "))
        .unwrap()
        .to_owned()
}

fn identity_line() -> &'static str {
    IDENTITY
        .lines()
        .find(|line| line.starts_with("AGE-SECRET-KEY-"))
        .unwrap()
}

fn project(dir: &Path) {
    fs::create_dir_all(dir.join("config")).unwrap();
    fs::create_dir_all(dir.join("secrets")).unwrap();
    fs::write(
        dir.join("open-envault.yaml"),
        format!(
            "project: test\nenvironments:\n  dev:\n    file: secrets/dev.env.enc\n    schema: config/env.schema.yaml\n    recipients: [{}]\n  prd:\n    file: secrets/prd.env.enc\n    schema: config/env.schema.yaml\n    recipients: [{}]\n",
            recipient()
            , recipient()
        ),
    )
    .unwrap();
    fs::write(
        dir.join("config/env.schema.yaml"),
        "variables:\n  API_KEY:\n    type: string\n    required: true\n  EXTRA:\n    type: string\n",
    )
    .unwrap();
}

fn command(dir: &Path, args: &[&str]) -> Command {
    let mut command = Command::new(binary());
    command
        .current_dir(dir)
        .env("SOPS_AGE_KEY", identity_line());
    command.args(args);
    command
}

#[test]
fn import_json_merges_and_filters_without_printing_values() {
    let dir = tempfile::tempdir().unwrap();
    project(dir.path());
    let mut first_command = command(
        dir.path(),
        &["import", "dev", "--format", "json", "--merge"],
    );
    first_command.env("UNUSED", "ignored");
    let first = first_command.output_with_stdin(br#"{"API_KEY":"first","EXTRA":"old"}"#);
    assert!(
        first.status.success(),
        "{}",
        String::from_utf8_lossy(&first.stderr)
    );
    assert!(!String::from_utf8_lossy(&first.stdout).contains("first"));

    let allowlist = dir.path().join("allowlist.txt");
    fs::write(&allowlist, "API_KEY\n").unwrap();
    let mut second_command = command(
        dir.path(),
        &[
            "import",
            "dev",
            "--format",
            "dotenv",
            "--merge",
            "--allowlist",
            allowlist.to_str().unwrap(),
        ],
    );
    let second = second_command.output_with_stdin(b"API_KEY=second\nEXTRA=new\n");
    assert!(
        second.status.success(),
        "{}",
        String::from_utf8_lossy(&second.stderr)
    );

    let check = command(dir.path(), &["check", "dev", "--format", "json"])
        .output()
        .unwrap();
    assert!(
        check.status.success(),
        "{}",
        String::from_utf8_lossy(&check.stderr)
    );
}

#[test]
fn force_controls_precedence_over_inherited_environment() {
    let dir = tempfile::tempdir().unwrap();
    project(dir.path());
    let mut import_command = command(
        dir.path(),
        &["import", "dev", "--format", "dotenv", "--replace"],
    );
    let imported = import_command.output_with_stdin(b"API_KEY=from-file\n");
    assert!(
        imported.status.success(),
        "{}",
        String::from_utf8_lossy(&imported.stderr)
    );

    for (args, expected) in [
        (
            vec!["exec", "dev", "--", "sh", "-c", "printf %s $API_KEY"],
            "inherited",
        ),
        (
            vec![
                "exec",
                "dev",
                "--force",
                "--",
                "sh",
                "-c",
                "printf %s $API_KEY",
            ],
            "from-file",
        ),
    ] {
        let mut run = command(dir.path(), &args);
        run.env("API_KEY", "inherited");
        let output = run.output().unwrap();
        assert!(output.status.success());
        assert_eq!(String::from_utf8(output.stdout).unwrap(), expected);
    }
}

#[test]
fn diff_is_redacted_and_rotate_preserves_decryptability() {
    let dir = tempfile::tempdir().unwrap();
    project(dir.path());
    let mut dev_import = command(
        dir.path(),
        &["import", "dev", "--format", "dotenv", "--replace"],
    );
    assert!(
        dev_import
            .output_with_stdin(b"API_KEY=dev\nEXTRA=same\n")
            .status
            .success()
    );
    let mut prd_import = command(
        dir.path(),
        &["import", "prd", "--format", "dotenv", "--replace"],
    );
    assert!(
        prd_import
            .output_with_stdin(b"API_KEY=prd\n")
            .status
            .success()
    );

    let mut diff = command(dir.path(), &["diff", "dev", "prd", "--format", "json"]);
    diff.env("OPENENVAULT_DIFF_PEPPER", "fixture-pepper");
    let output = diff.output().unwrap();
    assert_eq!(output.status.code(), Some(4));
    let text = String::from_utf8(output.stdout).unwrap();
    assert!(text.contains("\"changed\""));
    assert!(!text.contains("dev") || !text.contains("prd"));

    let before = fs::read(dir.path().join("secrets/prd.env.enc")).unwrap();
    let rotated = command(dir.path(), &["rotate", "prd"]).output().unwrap();
    assert!(
        rotated.status.success(),
        "{}",
        String::from_utf8_lossy(&rotated.stderr)
    );
    let after = fs::read(dir.path().join("secrets/prd.env.enc")).unwrap();
    assert_ne!(before, after);
    let check = command(dir.path(), &["check", "prd", "--format", "json"])
        .output()
        .unwrap();
    assert!(check.status.success());
}

#[test]
fn check_reports_extra_variables_and_doctor_reports_profile_health() {
    let dir = tempfile::tempdir().unwrap();
    project(dir.path());
    let mut import_command = command(
        dir.path(),
        &["import", "dev", "--format", "dotenv", "--replace"],
    );
    assert!(
        import_command
            .output_with_stdin(b"API_KEY=ok\nEXTRA=ok\nUNDECLARED=hidden\n")
            .status
            .success()
    );

    let check = command(dir.path(), &["check", "dev", "--format", "json"])
        .output()
        .unwrap();
    assert_eq!(check.status.code(), Some(4));
    let check_text = String::from_utf8(check.stdout).unwrap();
    assert!(check_text.contains("\"extra\""));
    assert!(check_text.contains("UNDECLARED"));

    let doctor = command(dir.path(), &["doctor", "--format", "json"])
        .output()
        .unwrap();
    assert_eq!(doctor.status.code(), Some(4));
    let doctor_text = String::from_utf8(doctor.stdout).unwrap();
    assert!(doctor_text.contains("\"dev\""));
    assert!(doctor_text.contains("\"decrypt\": \"ok\""));
    assert!(doctor_text.contains("prd: encrypted profile is missing"));
}

#[test]
fn set_rejects_invalid_variable_names() {
    let dir = tempfile::tempdir().unwrap();
    project(dir.path());
    let mut set = command(dir.path(), &["set", "dev", "not-valid"]);
    let output = set.output_with_stdin(b"value\n");
    assert!(!output.status.success());
    assert!(!dir.path().join("secrets/dev.env.enc").exists());
}

trait CommandStdin {
    fn output_with_stdin(&mut self, input: &[u8]) -> std::process::Output;
}

impl CommandStdin for Command {
    fn output_with_stdin(&mut self, input: &[u8]) -> std::process::Output {
        use std::io::Write;
        let mut child = self.stdin(std::process::Stdio::piped()).spawn().unwrap();
        child.stdin.take().unwrap().write_all(input).unwrap();
        child.wait_with_output().unwrap()
    }
}