pub mod commands;
pub mod env_parser;
pub mod gitignore;
pub mod output;
use clap::Parser;
use zeroize::Zeroizing;
use crate::errors::{EnvVaultError, Result};
const MIN_PASSWORD_LEN: usize = 8;
#[derive(Parser)]
#[command(
name = "envvault",
about = "Encrypted environment variable manager",
version
)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
#[arg(short, long, default_value = "dev", global = true)]
pub env: String,
#[arg(long, default_value = ".envvault", global = true)]
pub vault_dir: String,
#[arg(long, global = true)]
pub keyfile: Option<String>,
}
#[derive(clap::Subcommand)]
pub enum Commands {
Init,
Set {
key: String,
value: Option<String>,
#[arg(short, long)]
force: bool,
},
Get {
key: String,
#[arg(short = 'c', long)]
clipboard: bool,
},
List,
Delete {
key: String,
#[arg(short, long)]
force: bool,
},
Run {
#[arg(trailing_var_arg = true, required = true)]
command: Vec<String>,
#[arg(long)]
clean_env: bool,
#[arg(long, value_delimiter = ',')]
only: Option<Vec<String>>,
#[arg(long, value_delimiter = ',')]
exclude: Option<Vec<String>>,
#[arg(long)]
redact_output: bool,
#[arg(long, value_delimiter = ',')]
allowed_commands: Option<Vec<String>>,
},
RotateKey {
#[arg(long)]
new_keyfile: Option<String>,
},
Export {
#[arg(short, long, default_value = "env")]
format: String,
#[arg(short, long)]
output: Option<String>,
},
Import {
file: String,
#[arg(short, long)]
format: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
skip_existing: bool,
},
Auth {
#[command(subcommand)]
action: AuthAction,
},
Env {
#[command(subcommand)]
action: EnvAction,
},
Diff {
target_env: String,
#[arg(long)]
show_values: bool,
},
Edit,
Version,
Update,
Completions {
shell: String,
},
Scan {
#[arg(long)]
ci: bool,
#[arg(long)]
dir: Option<String>,
#[arg(long)]
gitleaks_config: Option<String>,
},
Search {
pattern: String,
},
Audit {
#[command(subcommand)]
action: Option<AuditAction>,
#[arg(long, default_value = "50")]
last: usize,
#[arg(long)]
since: Option<String>,
},
}
#[derive(clap::Subcommand)]
pub enum AuditAction {
Export {
#[arg(long, default_value = "json")]
format: String,
#[arg(short, long)]
output: Option<String>,
},
Purge {
#[arg(long)]
older_than: String,
},
}
#[derive(clap::Subcommand)]
pub enum AuthAction {
Keyring {
#[arg(long)]
delete: bool,
},
KeyfileGenerate {
path: Option<String>,
},
}
#[derive(clap::Subcommand)]
pub enum EnvAction {
List,
Clone {
target: String,
#[arg(long)]
new_password: bool,
},
Delete {
name: String,
#[arg(short, long)]
force: bool,
},
}
pub fn prompt_password() -> Result<Zeroizing<String>> {
prompt_password_for_vault(None)
}
pub fn prompt_password_for_vault(vault_id: Option<&str>) -> Result<Zeroizing<String>> {
if let Ok(pw) = std::env::var("ENVVAULT_PASSWORD") {
if !pw.is_empty() {
return Ok(Zeroizing::new(pw));
}
}
#[cfg(feature = "keyring-store")]
if let Some(id) = vault_id {
match crate::keyring::get_password(id) {
Ok(Some(pw)) => return Ok(Zeroizing::new(pw)),
Ok(None) => {} Err(_) => {} }
}
#[cfg(not(feature = "keyring-store"))]
let _ = vault_id;
let pw = dialoguer::Password::new()
.with_prompt("Enter vault password")
.interact()
.map_err(|e| EnvVaultError::CommandFailed(format!("password prompt: {e}")))?;
Ok(Zeroizing::new(pw))
}
pub fn prompt_new_password() -> Result<Zeroizing<String>> {
if let Ok(pw) = std::env::var("ENVVAULT_PASSWORD") {
if !pw.is_empty() {
if pw.len() < MIN_PASSWORD_LEN {
return Err(EnvVaultError::CommandFailed(format!(
"password must be at least {MIN_PASSWORD_LEN} characters"
)));
}
return Ok(Zeroizing::new(pw));
}
}
loop {
let password = dialoguer::Password::new()
.with_prompt("Choose vault password")
.with_confirmation(
"Confirm vault password",
"Passwords do not match, try again",
)
.interact()
.map_err(|e| EnvVaultError::CommandFailed(format!("password prompt: {e}")))?;
if password.len() < MIN_PASSWORD_LEN {
output::warning(&format!(
"Password must be at least {MIN_PASSWORD_LEN} characters. Try again."
));
continue;
}
return Ok(Zeroizing::new(password));
}
}
pub fn vault_path(cli: &Cli) -> Result<std::path::PathBuf> {
let cwd = std::env::current_dir()?;
let env = &cli.env;
Ok(cwd.join(&cli.vault_dir).join(format!("{env}.vault")))
}
pub fn load_keyfile(cli: &Cli) -> Result<Option<Vec<u8>>> {
if let Some(path) = &cli.keyfile {
let bytes = crate::crypto::keyfile::load_keyfile(std::path::Path::new(path))?;
return Ok(Some(bytes));
}
if let Ok(cwd) = std::env::current_dir() {
let settings = crate::config::Settings::load(&cwd).unwrap_or_default();
if let Some(ref path) = settings.keyfile_path {
let bytes = crate::crypto::keyfile::load_keyfile(std::path::Path::new(path))?;
return Ok(Some(bytes));
}
}
let global = crate::config::GlobalConfig::load();
if let Some(ref path) = global.keyfile_path {
let bytes = crate::crypto::keyfile::load_keyfile(std::path::Path::new(path))?;
return Ok(Some(bytes));
}
Ok(None)
}
pub fn validate_env_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(EnvVaultError::ConfigError(
"environment name cannot be empty".into(),
));
}
if name.len() > 64 {
return Err(EnvVaultError::ConfigError(
"environment name cannot exceed 64 characters".into(),
));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(EnvVaultError::ConfigError(format!(
"environment name '{name}' is invalid — only lowercase letters, digits, and hyphens are allowed"
)));
}
if name.starts_with('-') || name.ends_with('-') {
return Err(EnvVaultError::ConfigError(format!(
"environment name '{name}' cannot start or end with a hyphen"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_env_names() {
assert!(validate_env_name("dev").is_ok());
assert!(validate_env_name("staging").is_ok());
assert!(validate_env_name("prod").is_ok());
assert!(validate_env_name("us-east-1").is_ok());
assert!(validate_env_name("v2").is_ok());
}
#[test]
fn rejects_empty_name() {
assert!(validate_env_name("").is_err());
}
#[test]
fn rejects_uppercase() {
assert!(validate_env_name("Dev").is_err());
assert!(validate_env_name("PROD").is_err());
}
#[test]
fn rejects_special_chars() {
assert!(validate_env_name("dev.test").is_err());
assert!(validate_env_name("dev/test").is_err());
assert!(validate_env_name("dev test").is_err());
assert!(validate_env_name("dev_test").is_err());
}
#[test]
fn rejects_leading_trailing_hyphens() {
assert!(validate_env_name("-dev").is_err());
assert!(validate_env_name("dev-").is_err());
}
#[test]
fn rejects_too_long_name() {
let long_name = "a".repeat(65);
assert!(validate_env_name(&long_name).is_err());
}
}