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};
#[derive(Parser)]
#[command(name = "secure-env", version, about)]
struct Cli {
#[arg(long)]
file: Option<PathBuf>,
#[arg(long, num_args = 1..)]
setenv: Vec<String>,
#[arg(long)]
set: Option<String>,
#[arg(long)]
force: bool,
#[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))
}