secure-env 0.1.0

Encrypted environment variables manager for your shell
Documentation
use anyhow::{Context, Result};
use clap::Parser;
use secure_env::{crypto, inject, store};
use secrecy::SecretString;
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,
}

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.setenv.is_empty() {
        setenv(&cli, &file)
    } else {
        inject(&cli, &file)
    }
}

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 !is_valid_env_name(key) {
        anyhow::bail!("invalid environment variable name `{key}`");
    }
    Ok((set.to_string(), key.to_string(), value.to_string()))
}

fn is_valid_env_name(name: &str) -> bool {
    let mut chars = name.chars();
    match chars.next() {
        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
        _ => return false,
    }
    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}

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