secure-env 0.2.2

Encrypted environment variables manager for your shell
Documentation
use anyhow::{Context, Result};
use clap::Parser;
use secrecy::SecretString;
use secure_env::{crypto, inject, store, tui};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

/// Encrypted environment variables manager.
#[derive(Parser)]
#[command(name = "secure-env", version, about)]
struct Cli {
    /// File holding the encrypted environment variables.
    #[arg(long)]
    file: Option<PathBuf>,

    /// Variables to set, as `setname.key=value` pairs.
    #[arg(long, num_args = 1..)]
    setenv: Vec<String>,

    /// Inject only this named set.
    #[arg(long)]
    set: Option<String>,

    /// Overwrite the file instead of merging with existing sets.
    #[arg(long)]
    force: bool,

    /// Open the encrypted file in an interactive editor.
    #[arg(long, conflicts_with_all = ["setenv", "set"])]
    open: bool,
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    let file = cli.file.clone().unwrap_or_else(default_file);

    if !cli.setenv.is_empty() && cli.set.is_some() {
        anyhow::bail!("--setenv and --set cannot be combined");
    }

    if cli.open {
        open_editor(&file)
    } else if !cli.setenv.is_empty() {
        setenv(&cli, &file)
    } else {
        inject(&cli, &file)
    }
}

fn open_editor(file: &Path) -> Result<()> {
    let passphrase = prompt_password("Password to decrypt environment variables: ")?;
    let sets = store::read_plain(file, passphrase.clone())?.unwrap_or_default();
    tui::run(sets, passphrase, file)?;
    eprintln!("Saved encrypted variables to {}", file.display());
    Ok(())
}

fn default_file() -> PathBuf {
    std::env::var_os("HOME")
        .map(|home| {
            PathBuf::from(home)
                .join(".config")
                .join("secure-env")
                .join("secure-env.enc")
        })
        .unwrap_or_else(|| PathBuf::from("secure-env.enc"))
}

fn setenv(cli: &Cli, file: &Path) -> Result<()> {
    let mut new_sets: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
    for raw in &cli.setenv {
        let (set, key, value) = parse_var(raw)?;
        new_sets.entry(set).or_default().insert(key, value);
    }

    let passphrase = prompt_password("Password to encrypt environment variables: ")?;
    let mut all = if cli.force {
        BTreeMap::new()
    } else {
        store::read_plain(file, passphrase.clone())?.unwrap_or_default()
    };
    for (set, vars) in new_sets {
        all.entry(set).or_default().extend(vars);
    }

    let toml = store::to_toml(&all);
    let ciphertext = crypto::encrypt(toml.as_bytes(), passphrase)?;
    store::atomic_write(file, &ciphertext)?;
    if cli.force {
        eprintln!("Overwrote {} set(s) in {}", all.len(), file.display());
    } else {
        eprintln!("Encrypted {} set(s) into {}", all.len(), file.display());
    }
    Ok(())
}

fn inject(cli: &Cli, file: &Path) -> Result<()> {
    let passphrase = prompt_password("Password to decrypt environment variables: ")?;
    let sets = store::read_plain(file, passphrase)?
        .with_context(|| format!("no encrypted variables found in {}", file.display()))?;

    let selected: Vec<&BTreeMap<String, String>> = match &cli.set {
        Some(name) => {
            let set = sets
                .get(name)
                .with_context(|| format!("unknown set: {name}"))?;
            vec![set]
        }
        None => sets.values().collect(),
    };

    for set in selected {
        print!("{}", inject::export_lines(set));
    }
    Ok(())
}

fn parse_var(raw: &str) -> Result<(String, String, String)> {
    let (set_key, value) = raw
        .split_once('=')
        .with_context(|| format!("expected `set.key=value`, got `{raw}`"))?;
    let (set, key) = set_key
        .split_once('.')
        .with_context(|| format!("expected `set.key=value`, got `{raw}`"))?;

    let set = set.trim();
    let key = key.trim();
    if set.is_empty() || key.is_empty() {
        anyhow::bail!("set name and key must not be empty in `{raw}`");
    }
    if !store::is_valid_env_name(key) {
        anyhow::bail!("invalid environment variable name `{key}`");
    }
    Ok((set.to_string(), key.to_string(), value.to_string()))
}

fn prompt_password(prompt: &str) -> Result<SecretString> {
    let password = rpassword::prompt_password(prompt).context("failed to read password")?;
    Ok(SecretString::from(password))
}