use std::path::Path;
use crate::cli::error::CliError;
use crate::cli::format::{FormatError, OutputData, OutputFormat, print_output};
use crate::core::CoreError;
use crate::db::{ProjectId, Vault};
fn display_env(env: &str) -> &str {
if env.is_empty() {
crate::core::DEFAULT_ENV
} else {
env
}
}
fn audit_best_effort(
vault: &Vault,
project_id: &ProjectId,
env: &str,
action: crate::core::AuditAction,
key: Option<&str>,
) {
if let Err(e) = crate::core::record_audit(vault, project_id, env, action, key) {
eprintln!("warning: could not write audit log entry: {e}");
}
}
pub(super) fn cmd_init() -> Result<(), CliError> {
let cwd = std::env::current_dir()
.map_err(|e| CliError::VaultOpen(format!("cannot determine current directory: {e}")))?;
match crate::core::find_manifest(&cwd) {
Ok((_, found_dir)) if found_dir == cwd => {
return Err(CliError::AlreadyInitialised);
}
Ok((_, _found_dir)) => {
}
Err(CoreError::ManifestNotFound) => {
}
Err(e) => {
return Err(CliError::VaultOpen(e.to_string()));
}
}
let master_key = crate::crypto::get_or_create_master_key()
.map_err(|e| CliError::VaultOpen(e.to_string()))?;
let vault_path = super::vault_path();
let vault = Vault::open(&vault_path, master_key.as_ref())
.map_err(|e| CliError::VaultOpen(e.to_string()))?;
let project_name = cwd
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed");
let project_id: ProjectId = vault
.create_project(project_name)
.map_err(|e| CliError::VaultOpen(e.to_string()))?;
crate::core::create_manifest(&cwd, project_id.as_str())
.map_err(|e| CliError::VaultOpen(e.to_string()))?;
println!("✓ Initialised envy project {}.", project_id.as_str());
Ok(())
}
pub(super) fn cmd_set(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env: &str,
key: &str,
value: &str,
) -> Result<(), CoreError> {
crate::core::set_secret(vault, master_key, project_id, env, key, value)?;
audit_best_effort(
vault,
project_id,
env,
crate::core::AuditAction::Set,
Some(key),
);
println!("✓ Set {} in {}.", key, display_env(env));
Ok(())
}
pub(super) fn cmd_get(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env: &str,
key: &str,
format: OutputFormat,
) -> Result<(), CliError> {
use std::io::stdout;
match crate::core::get_secret(vault, master_key, project_id, env, key) {
Ok(value) => {
audit_best_effort(
vault,
project_id,
env,
crate::core::AuditAction::Get,
Some(key),
);
if format == OutputFormat::Table {
println!("{}", *value);
} else {
print_output(
format,
OutputData::SecretItem {
key,
value: value.as_str(),
},
&mut stdout(),
)
.map_err(|e: FormatError| CliError::Output(e.to_string()))?;
}
Ok(())
}
Err(e) => {
if format != OutputFormat::Table {
let _ = print_output(format, OutputData::NotFound { key }, &mut stdout());
}
Err(CliError::Core(e))
}
}
}
pub(super) fn cmd_list(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env: &str,
format: OutputFormat,
) -> Result<(), CliError> {
use crate::cli::format::OutputFormat;
use std::io::stdout;
if format == OutputFormat::Table {
let keys = crate::core::list_secret_keys(vault, project_id, env).map_err(CliError::Core)?;
if keys.is_empty() {
eprintln!("(no secrets in {})", display_env(env));
} else {
for k in &keys {
println!("{k}");
}
}
return Ok(());
}
let secrets = crate::core::list_secrets_with_values(vault, master_key, project_id, env)
.map_err(CliError::Core)?;
print_output(
format,
OutputData::SecretList {
env: display_env(env),
secrets: &secrets,
},
&mut stdout(),
)
.map_err(|e: FormatError| CliError::Output(e.to_string()))
}
pub(super) fn cmd_export(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env: &str,
format: OutputFormat,
) -> Result<(), CliError> {
use std::io::stdout;
let effective = if format == OutputFormat::Table {
OutputFormat::Dotenv
} else {
format
};
let secrets = crate::core::list_secrets_with_values(vault, master_key, project_id, env)
.map_err(CliError::Core)?;
print_output(
effective,
OutputData::ExportList {
env: display_env(env),
secrets: &secrets,
},
&mut stdout(),
)
.map_err(|e: FormatError| CliError::Output(e.to_string()))
}
pub(super) fn cmd_rm(
vault: &Vault,
project_id: &ProjectId,
env: &str,
key: &str,
) -> Result<(), CoreError> {
crate::core::delete_secret(vault, project_id, env, key)?;
audit_best_effort(
vault,
project_id,
env,
crate::core::AuditAction::Rm,
Some(key),
);
println!("✓ Deleted {} from {}.", key, display_env(env));
Ok(())
}
pub(super) fn cmd_run(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env: &str,
command: &[String],
) -> i32 {
let secrets = match crate::core::get_env_secrets(vault, master_key, project_id, env) {
Ok(s) => s,
Err(e) => {
eprintln!("{}", crate::cli::error::format_core_error(&e));
return crate::cli::error::core_exit_code(&e);
}
};
if !secrets.is_empty() {
audit_best_effort(vault, project_id, env, crate::core::AuditAction::Run, None);
}
let (bin, args) = command
.split_first()
.expect("clap guarantees at least one element after --");
match std::process::Command::new(bin)
.args(args)
.envs(secrets.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.status()
{
Ok(status) => {
status.code().unwrap_or(1)
}
Err(e) => {
eprintln!("error: failed to execute `{}`: {}", bin, e);
127 }
}
}
pub(super) fn cmd_migrate(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env: &str,
file: &Path,
) -> Result<(), CliError> {
let content = std::fs::read_to_string(file)
.map_err(|e| CliError::FileNotFound(file.display().to_string(), e.to_string()))?;
let mut imported = 0usize;
let mut warnings = 0usize;
for (line_no, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
match trimmed.split_once('=') {
Some((key, value)) => {
crate::core::set_secret(vault, master_key, project_id, env, key.trim(), value)
.map_err(|e| CliError::VaultOpen(e.to_string()))?;
imported += 1;
}
None => {
eprintln!(
"warning: line {}: skipping malformed entry: {:?}",
line_no + 1,
trimmed
);
warnings += 1;
}
}
}
let warnings_suffix = if warnings > 0 {
format!(" ({warnings} warning(s))")
} else {
String::new()
};
println!(
"✓ Imported {} secret(s) into {}{}.",
imported,
display_env(env),
warnings_suffix,
);
Ok(())
}
fn resolve_passphrase_for_env(
env_name: &str,
confirm: bool,
_suggested: Option<&str>,
) -> Result<Option<zeroize::Zeroizing<String>>, CliError> {
let var_name = format!(
"ENVY_PASSPHRASE_{}",
env_name.to_uppercase().replace('-', "_")
);
if let Ok(val) = std::env::var(&var_name) {
if !val.trim().is_empty() {
return Ok(Some(zeroize::Zeroizing::new(val)));
}
return Err(CliError::PassphraseInput(format!(
"{var_name} is set but contains only whitespace"
)));
}
if let Ok(val) = std::env::var("ENVY_PASSPHRASE") {
if !val.trim().is_empty() {
return Ok(Some(zeroize::Zeroizing::new(val)));
}
return Err(CliError::PassphraseInput(
"ENVY_PASSPHRASE is set but contains only whitespace".into(),
));
}
let theme = dialoguer::theme::ColorfulTheme::default();
if let Some(suggested) = _suggested {
let prompt = format!("Passphrase for '{env_name}' (press Enter to accept: {suggested})");
let raw: String = match dialoguer::Password::with_theme(&theme)
.with_prompt(&prompt)
.allow_empty_password(true)
.interact()
{
Ok(v) => v,
Err(_) => return Ok(None), };
if raw.is_empty() {
return Ok(Some(zeroize::Zeroizing::new(suggested.to_string())));
}
if raw.trim().is_empty() {
return Err(CliError::PassphraseInput(
"passphrase must not be empty".into(),
));
}
if confirm {
let confirmed: String = match dialoguer::Password::with_theme(&theme)
.with_prompt(format!("Confirm passphrase for '{env_name}'"))
.interact()
{
Ok(v) => v,
Err(_) => return Ok(None),
};
if confirmed != raw {
return Err(CliError::PassphraseInput(
"Passphrases do not match.".into(),
));
}
}
if confirm {
print_passphrase_strength_hint(&raw);
}
return Ok(Some(zeroize::Zeroizing::new(raw)));
}
let prompt = format!("Passphrase for '{env_name}'");
let result: Result<String, _> = if confirm {
dialoguer::Password::with_theme(&theme)
.with_prompt(&prompt)
.with_confirmation("Confirm passphrase", "Passphrases do not match.")
.interact()
} else {
dialoguer::Password::with_theme(&theme)
.with_prompt(&prompt)
.interact()
};
match result {
Ok(raw) => {
if raw.trim().is_empty() {
return Err(CliError::PassphraseInput(
"passphrase must not be empty".into(),
));
}
if confirm {
print_passphrase_strength_hint(&raw);
}
Ok(Some(zeroize::Zeroizing::new(raw)))
}
Err(_) => Ok(None), }
}
fn is_headless_mode(env_names: &[String]) -> bool {
if let Ok(val) = std::env::var("ENVY_PASSPHRASE") {
if !val.trim().is_empty() {
return true;
}
}
for env_name in env_names {
let var_name = format!(
"ENVY_PASSPHRASE_{}",
env_name.to_uppercase().replace('-', "_")
);
if let Ok(val) = std::env::var(&var_name) {
if !val.trim().is_empty() {
return true;
}
}
}
false
}
fn print_passphrase_strength_hint(passphrase: &str) {
use crate::crypto::strength::{classify, estimate_entropy_bits};
let bits = estimate_entropy_bits(passphrase);
let level = classify(bits);
let (label, ansi) = match level {
crate::crypto::StrengthLevel::VeryWeak => ("very weak", "31"),
crate::crypto::StrengthLevel::Weak => ("weak", "31"),
crate::crypto::StrengthLevel::Fair => ("fair", "33"),
crate::crypto::StrengthLevel::Strong => ("strong", "32"),
crate::crypto::StrengthLevel::VeryStrong => ("very strong", "32"),
};
eprintln!(
" \u{2139} passphrase strength: {} (~{:.0} bits estimated)",
colorize(label, ansi),
bits
);
if level.is_weak() {
eprintln!(
" hint: press Enter on an empty prompt next time to accept envy's suggested Diceware phrase."
);
}
}
fn print_diceware_banner(passphrase: &str) {
eprintln!(
"\n {}\n\n {}\n\n {}\n",
dialoguer::console::style("╔══════════════════════════════════════╗")
.yellow()
.bold(),
dialoguer::console::style(passphrase).yellow().bold(),
dialoguer::console::style("SAVE THIS PASSPHRASE NOW. You will not be shown it again.")
.yellow()
.bold(),
);
}
fn resolve_passphrase(prompt: &str, confirm: bool) -> Result<zeroize::Zeroizing<String>, CliError> {
if let Ok(val) = std::env::var("ENVY_PASSPHRASE") {
if !val.trim().is_empty() {
return Ok(zeroize::Zeroizing::new(val));
}
return Err(CliError::PassphraseInput(
"ENVY_PASSPHRASE is set but contains only whitespace".into(),
));
}
let theme = dialoguer::theme::ColorfulTheme::default();
let raw: String = if confirm {
dialoguer::Password::with_theme(&theme)
.with_prompt(prompt)
.with_confirmation("Confirm passphrase", "Passphrases do not match.")
.interact()
.map_err(|e| CliError::PassphraseInput(e.to_string()))?
} else {
dialoguer::Password::with_theme(&theme)
.with_prompt(prompt)
.interact()
.map_err(|e| CliError::PassphraseInput(e.to_string()))?
};
if raw.trim().is_empty() {
return Err(CliError::PassphraseInput(
"passphrase must not be empty".into(),
));
}
Ok(zeroize::Zeroizing::new(raw))
}
pub(super) fn cmd_encrypt(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
artifact_path: &std::path::Path,
env_filter: Option<&str>,
) -> Result<(), CliError> {
if let Ok(val) = std::env::var("ENVY_PASSPHRASE") {
if val.trim().is_empty() {
return Err(CliError::PassphraseInput(
"ENVY_PASSPHRASE is set but contains only whitespace".into(),
));
}
}
let all_envs: Vec<String> = vault
.list_environments(project_id)
.map_err(|e| CliError::VaultOpen(e.to_string()))?
.into_iter()
.map(|e| e.name)
.collect();
if all_envs.is_empty() {
println!("No environments found. Use 'envy set' to add secrets first.");
return Ok(());
}
let selected_envs: Vec<String> = match env_filter {
Some(f) => vec![f.to_string()],
None => all_envs,
};
let headless = is_headless_mode(&selected_envs);
let envs_to_seal: Vec<String> = if headless {
selected_envs
} else if env_filter.is_some() {
selected_envs
} else {
let theme = dialoguer::theme::ColorfulTheme::default();
let indices = dialoguer::MultiSelect::with_theme(&theme)
.with_prompt("Select environments to encrypt")
.items(&selected_envs)
.interact()
.map_err(|e| CliError::PassphraseInput(e.to_string()))?;
if indices.is_empty() {
println!("Nothing to encrypt.");
return Ok(());
}
indices
.into_iter()
.map(|i| selected_envs[i].clone())
.collect()
};
let mut artifact = match crate::core::read_artifact(artifact_path) {
Ok(a) => a,
Err(crate::core::SyncError::FileNotFound(_)) => crate::core::new_empty_artifact(),
Err(e) => return Err(CliError::VaultOpen(e.to_string())),
};
let mut sealed_envs: Vec<String> = Vec::new();
for env_name in &envs_to_seal {
let is_new_env = !artifact.environments.contains_key(env_name);
let diceware_suggestion: Option<String> = if !headless && is_new_env {
Some(crate::crypto::suggest_passphrase(4))
} else {
None
};
let secret_keys =
crate::core::list_secret_keys(vault, project_id, env_name).unwrap_or_default(); if secret_keys.is_empty() {
eprintln!(
" {} environment '{}' has 0 secrets, skipping",
dialoguer::console::style("\u{26a0}").yellow(),
env_name
);
continue;
}
let passphrase = match resolve_passphrase_for_env(
env_name,
!headless && is_new_env, diceware_suggestion.as_deref(),
)? {
Some(p) => p,
None => continue, };
if let Some(existing_envelope) = artifact.environments.get(env_name) {
if !crate::core::check_envelope_passphrase(
passphrase.as_ref(),
env_name,
existing_envelope,
) {
return Err(CliError::PassphraseInput(
"passphrase does not match the existing envelope.\nhint: use `envy rotate -e ENV` to change the envelope's passphrase."
.to_string(),
));
}
}
if let Some(ref suggestion) = diceware_suggestion {
if *passphrase == *suggestion {
print_diceware_banner(suggestion);
}
}
let envelope =
crate::core::seal_env(vault, master_key, project_id, env_name, passphrase.as_ref())
.map_err(|e| CliError::VaultOpen(e.to_string()))?;
artifact.environments.insert(env_name.clone(), envelope);
sealed_envs.push(env_name.clone());
}
crate::core::write_artifact_atomic(&artifact, artifact_path)
.map_err(|e| CliError::VaultOpen(e.to_string()))?;
for env_name in &sealed_envs {
crate::core::mark_env_sealed(vault, project_id, env_name)
.map_err(|e| CliError::VaultOpen(e.to_string()))?;
}
println!(
"Sealed {} environment(s) \u{2192} {}",
sealed_envs.len(),
artifact_path.display()
);
for env_name in &sealed_envs {
println!(
" {} {}",
dialoguer::console::style("\u{2713}").green(),
env_name
);
}
Ok(())
}
pub(super) fn cmd_decrypt(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
artifact_path: &std::path::Path,
) -> Result<(), CliError> {
let artifact = crate::core::read_artifact(artifact_path).map_err(|e| match e {
crate::core::SyncError::FileNotFound(path) => {
CliError::FileNotFound(path, "envy.enc not found".into())
}
other => CliError::VaultOpen(other.to_string()),
})?;
let env_names: Vec<String> = artifact.environments.keys().cloned().collect();
let (imported, skipped) = if is_headless_mode(&env_names) {
let mut imp: std::collections::BTreeMap<
String,
std::collections::BTreeMap<String, zeroize::Zeroizing<String>>,
> = std::collections::BTreeMap::new();
let mut skp: Vec<String> = Vec::new();
for env_name in &env_names {
let passphrase = match resolve_passphrase_for_env(env_name, false, None)? {
Some(p) => p,
None => {
skp.push(env_name.clone());
continue;
}
};
match crate::core::unseal_env(&artifact, env_name, passphrase.as_ref())
.map_err(|e| CliError::VaultOpen(e.to_string()))?
{
Some(secrets) => {
imp.insert(env_name.clone(), secrets);
}
None => {
skp.push(env_name.clone());
}
}
}
(imp, skp)
} else {
let passphrase = resolve_passphrase("Enter passphrase", false)?;
let result = crate::core::unseal_artifact(&artifact, passphrase.as_ref())
.map_err(|e| CliError::VaultOpen(e.to_string()))?;
(result.imported, result.skipped)
};
if imported.is_empty() {
return Err(CliError::NothingImported);
}
let mut upserted_counts: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for (env_name, secrets) in &imported {
let mut ok = 0usize;
for (key, value) in secrets {
match crate::core::set_secret(
vault,
master_key,
project_id,
env_name,
key,
value.as_ref(),
) {
Ok(()) => ok += 1,
Err(e) => eprintln!("warning: failed to upsert {env_name}/{key}: {e}"),
}
}
upserted_counts.insert(env_name.clone(), ok);
}
println!("Imported {} environment(s) from envy.enc", imported.len());
for env_name in imported.keys() {
let ok = upserted_counts.get(env_name).copied().unwrap_or(0);
println!(
" {} {} ({} secret(s) upserted)",
dialoguer::console::style("\u{2713}").green(),
env_name,
ok
);
}
for env_name in &skipped {
println!(
" {} {} skipped \u{2014} different passphrase or key",
dialoguer::console::style("\u{26a0}").yellow().dim(),
env_name
);
}
Ok(())
}
pub(super) fn cmd_rotate(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
artifact_path: &std::path::Path,
env_filter: Option<&str>,
) -> Result<(), CliError> {
let mut artifact = match crate::core::read_artifact(artifact_path) {
Ok(a) => a,
Err(crate::core::SyncError::FileNotFound(p)) => {
return Err(CliError::FileNotFound(
p,
"envy.enc not found; run 'envy encrypt -e ENV' first to create an envelope".into(),
));
}
Err(e) => return Err(CliError::VaultOpen(e.to_string())),
};
let selected_envs: Vec<String> = match env_filter {
Some(f) => vec![f.to_string()],
None => {
let env_names: Vec<String> = artifact.environments.keys().cloned().collect();
if env_names.is_empty() {
return Err(CliError::FileNotFound(
artifact_path.display().to_string(),
"no envelopes in envy.enc to rotate".into(),
));
}
let theme = dialoguer::theme::ColorfulTheme::default();
let indices = dialoguer::MultiSelect::with_theme(&theme)
.with_prompt("Select environments to rotate")
.items(&env_names)
.interact()
.map_err(|e| CliError::PassphraseInput(e.to_string()))?;
if indices.is_empty() {
println!("Nothing to rotate.");
return Ok(());
}
indices.into_iter().map(|i| env_names[i].clone()).collect()
}
};
let mut rotated_envs = Vec::new();
for env_name in &selected_envs {
let secret_keys =
crate::core::list_secret_keys(vault, project_id, env_name).unwrap_or_default();
if secret_keys.is_empty() {
eprintln!(
" {} environment '{}' has 0 secrets, skipping",
dialoguer::console::style("\u{26a0}").yellow(),
env_name
);
continue;
}
let (current, new_pass) = resolve_rotate_passphrases(env_name)?;
if current.as_str() == new_pass.as_str() {
return Err(CliError::PassphraseInput(
"new passphrase must differ from the current passphrase".into(),
));
}
crate::core::rotate_env(
vault,
master_key,
project_id,
&mut artifact,
env_name,
current.as_ref(),
new_pass.as_ref(),
)
.map_err(|e| match e {
crate::core::SyncError::Artifact(
crate::crypto::artifact::ArtifactError::MalformedArtifact(_),
) => CliError::EnvNotFound(env_name.clone()),
crate::core::SyncError::Artifact(
crate::crypto::artifact::ArtifactError::MalformedEnvelope(_, _),
) => CliError::PassphraseInput(
"current passphrase does not match the existing envelope".into(),
),
crate::core::SyncError::Artifact(
crate::crypto::artifact::ArtifactError::WeakPassphrase,
) => CliError::PassphraseInput("new passphrase must not be empty or whitespace".into()),
other => CliError::VaultOpen(other.to_string()),
})?;
rotated_envs.push(env_name.clone());
println!(
" {} '{}' rotated. Passphrase changed.",
dialoguer::console::style("\u{2713}").green(),
env_name
);
println!(" Previous passphrase can no longer decrypt this artifact.");
}
crate::core::write_artifact_atomic(&artifact, artifact_path)
.map_err(|e| CliError::VaultOpen(e.to_string()))?;
for env_name in &rotated_envs {
crate::core::mark_env_sealed(vault, project_id, env_name)
.map_err(|e| CliError::VaultOpen(e.to_string()))?;
}
Ok(())
}
fn resolve_rotate_passphrases(
env_name: &str,
) -> Result<(zeroize::Zeroizing<String>, zeroize::Zeroizing<String>), CliError> {
let var_name = format!(
"ENVY_PASSPHRASE_{}",
env_name.to_uppercase().replace('-', "_")
);
let new_var_name = format!("{var_name}_NEW");
let current_env = std::env::var(&var_name).ok();
let new_env = std::env::var(&new_var_name).ok();
let has_current = current_env
.as_ref()
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
let has_new = new_env
.as_ref()
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
if has_current && has_new {
let current = zeroize::Zeroizing::new(current_env.unwrap());
let new_pass = zeroize::Zeroizing::new(new_env.unwrap());
return Ok((current, new_pass));
}
let stdin_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdin());
if !stdin_is_tty {
return Err(CliError::PassphraseInput(format!(
"envy rotate requires either a TTY or {var_name} + {new_var_name}"
)));
}
let theme = dialoguer::theme::ColorfulTheme::default();
let current_raw: String = dialoguer::Password::with_theme(&theme)
.with_prompt(format!("Passphrase for '{env_name}'"))
.interact()
.map_err(|e| CliError::PassphraseInput(e.to_string()))?;
if current_raw.trim().is_empty() {
return Err(CliError::PassphraseInput(
"passphrase must not be empty".into(),
));
}
let new_raw: String = dialoguer::Password::with_theme(&theme)
.with_prompt(format!("New passphrase for '{env_name}'"))
.with_confirmation("Confirm new passphrase", "Passphrases do not match.")
.interact()
.map_err(|e| CliError::PassphraseInput(e.to_string()))?;
if new_raw.trim().is_empty() {
return Err(CliError::PassphraseInput(
"new passphrase must not be empty or whitespace".into(),
));
}
print_passphrase_strength_hint(&new_raw);
Ok((
zeroize::Zeroizing::new(current_raw),
zeroize::Zeroizing::new(new_raw),
))
}
#[derive(serde::Serialize)]
struct EnvStatusJson {
name: String,
secret_count: i64,
last_modified_at: Option<String>,
status: &'static str,
stale_secrets: Vec<String>,
}
#[derive(serde::Serialize)]
struct ArtifactJson {
found: bool,
path: String,
last_modified_at: Option<String>,
environments: Vec<String>,
}
#[derive(serde::Serialize)]
struct StatusJson {
environments: Vec<EnvStatusJson>,
artifact: ArtifactJson,
}
struct ArtifactMetadata {
found: bool,
malformed: bool,
last_modified_at: Option<i64>,
environments: Vec<String>,
}
fn humanize_timestamp(epoch: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let delta = now - epoch;
if delta <= 0 {
return "unknown".to_string();
}
if delta < 60 {
return format!("{delta} seconds ago");
}
if delta < 3_600 {
let mins = delta / 60;
return format!("{mins} minutes ago");
}
if delta < 86_400 {
let hours = delta / 3_600;
return format!("{hours} hours ago");
}
if delta < 30 * 86_400 {
let days = delta / 86_400;
return format!("{days} days ago");
}
epoch_to_iso8601(epoch)[..10].to_string()
}
fn epoch_to_iso8601(secs: i64) -> String {
let sec_of_day = secs % 86_400;
let h = sec_of_day / 3_600;
let mi = (sec_of_day % 3_600) / 60;
let s = sec_of_day % 60;
let z = secs / 86_400 + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
fn read_artifact_metadata(artifact_path: &Path) -> ArtifactMetadata {
let last_modified_at = std::fs::metadata(artifact_path).ok().and_then(|m| {
m.modified().ok().and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64)
})
});
match crate::core::read_artifact(artifact_path) {
Ok(artifact) => ArtifactMetadata {
found: true,
malformed: false,
last_modified_at,
environments: artifact.environments.keys().cloned().collect(),
},
Err(crate::core::SyncError::FileNotFound(_)) => ArtifactMetadata {
found: false,
malformed: false,
last_modified_at: None,
environments: vec![],
},
Err(_) => ArtifactMetadata {
found: false,
malformed: true,
last_modified_at,
environments: vec![],
},
}
}
fn build_status_json(rows: &[crate::core::StatusRow], artifact_path: &Path) -> StatusJson {
use crate::core::SyncStatus;
let environments: Vec<EnvStatusJson> = rows
.iter()
.map(|row| {
let status = match row.sync_status {
SyncStatus::InSync => "in_sync",
SyncStatus::Modified => "modified",
SyncStatus::NeverSealed => "never_sealed",
};
EnvStatusJson {
name: row.name.clone(),
secret_count: row.secret_count,
last_modified_at: row.last_modified_at.map(epoch_to_iso8601),
status,
stale_secrets: row.stale_secrets.clone(),
}
})
.collect();
let meta = read_artifact_metadata(artifact_path);
let artifact = ArtifactJson {
found: meta.found,
path: artifact_path.display().to_string(),
last_modified_at: meta.last_modified_at.map(epoch_to_iso8601),
environments: meta.environments,
};
StatusJson {
environments,
artifact,
}
}
fn write_status_json(
rows: &[crate::core::StatusRow],
artifact_path: &Path,
writer: &mut impl std::io::Write,
) -> Result<(), CliError> {
let output = build_status_json(rows, artifact_path);
serde_json::to_writer(&mut *writer, &output).map_err(|e| CliError::Output(e.to_string()))?;
writeln!(writer).map_err(|e| CliError::Output(e.to_string()))?;
Ok(())
}
pub(super) fn cmd_status(
vault: &Vault,
project_id: &ProjectId,
artifact_path: &Path,
format: OutputFormat,
rotation_reminder_days: u32,
) -> Result<(), CliError> {
use crate::core::SyncStatus;
let rows = crate::core::get_status_report(vault, project_id, rotation_reminder_days)
.map_err(CliError::Core)?;
if format == OutputFormat::Json {
return write_status_json(&rows, artifact_path, &mut std::io::stdout());
}
if rows.is_empty() {
println!("No environments found. Use 'envy set' to add secrets first.");
return Ok(());
}
let mut table = comfy_table::Table::new();
table.set_header(vec![
"Environment",
"Secrets",
"Last Modified",
"Status",
"Rotation",
]);
for row in &rows {
let last_modified = if row.secret_count == 0 {
"No secrets".to_string()
} else {
row.last_modified_at
.map(humanize_timestamp)
.unwrap_or_else(|| "No secrets".to_string())
};
let (status_text, status_color) = match row.sync_status {
SyncStatus::InSync => ("\u{2713} In Sync", comfy_table::Color::Green),
SyncStatus::Modified => ("\u{26a0} Modified", comfy_table::Color::Yellow),
SyncStatus::NeverSealed => ("\u{2717} Never Sealed", comfy_table::Color::Red),
};
let (rotation_text, rotation_color) = if row.stale_secrets.is_empty() {
("\u{2713} Fresh".to_string(), comfy_table::Color::Green)
} else {
(
format!("\u{26a0} {} due", row.stale_secrets.len()),
comfy_table::Color::Yellow,
)
};
table.add_row(vec![
comfy_table::Cell::new(&row.name),
comfy_table::Cell::new(row.secret_count.to_string()),
comfy_table::Cell::new(&last_modified),
comfy_table::Cell::new(status_text)
.fg(status_color)
.add_attribute(comfy_table::Attribute::Bold),
comfy_table::Cell::new(rotation_text).fg(rotation_color),
]);
}
println!("{table}");
let stale_by_env: Vec<(&str, &Vec<String>)> = rows
.iter()
.filter(|r| !r.stale_secrets.is_empty())
.map(|r| (r.name.as_str(), &r.stale_secrets))
.collect();
if !stale_by_env.is_empty() {
println!(
"\n\u{26a0} Rotation reminder (no changes in over {rotation_reminder_days} days):"
);
for (env_name, keys) in stale_by_env {
println!(" {env_name}: {}", keys.join(", "));
}
}
let meta = read_artifact_metadata(artifact_path);
let path_display = artifact_path.display();
if meta.found {
let mtime = meta
.last_modified_at
.map(humanize_timestamp)
.unwrap_or_else(|| "unknown".to_string());
let envs = meta.environments.join(", ");
println!("\nArtifact: {path_display} (last written: {mtime})");
println!(" Sealed environments: {envs}");
} else if meta.malformed {
println!("\nArtifact: {path_display} \u{2014} unreadable (malformed JSON)");
} else {
println!("\nArtifact: {path_display} \u{2014} not found");
}
Ok(())
}
pub(super) fn cmd_audit(
vault: &Vault,
project_id: &ProjectId,
env_filter: Option<&str>,
limit: i64,
format: OutputFormat,
) -> Result<(), CliError> {
let entries =
crate::core::list_audit(vault, project_id, env_filter, limit).map_err(CliError::Core)?;
if format == OutputFormat::Json {
return write_audit_json(&entries, &mut std::io::stdout());
}
if entries.is_empty() {
println!("No audit history yet. Actions are recorded on set/get/rm/run.");
return Ok(());
}
let mut table = comfy_table::Table::new();
table.set_header(vec!["Time", "Environment", "Action", "Key"]);
for e in &entries {
table.add_row(vec![
comfy_table::Cell::new(humanize_timestamp(e.created_at)),
comfy_table::Cell::new(&e.environment),
comfy_table::Cell::new(&e.action),
comfy_table::Cell::new(e.key.as_deref().unwrap_or("-")),
]);
}
println!("{table}");
Ok(())
}
fn write_audit_json(
entries: &[crate::core::AuditEntry],
writer: &mut impl std::io::Write,
) -> Result<(), CliError> {
let items: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
serde_json::json!({
"environment": e.environment,
"action": e.action,
"key": e.key,
"created_at": epoch_to_iso8601(e.created_at),
})
})
.collect();
serde_json::to_writer_pretty(&mut *writer, &items)
.map_err(|e| CliError::Output(e.to_string()))?;
writeln!(writer).map_err(|e| CliError::Output(e.to_string()))?;
Ok(())
}
pub(super) fn cmd_scan(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env: Option<&str>,
root: &Path,
format: OutputFormat,
reveal: bool,
) -> Result<bool, CliError> {
let matches = crate::core::scan_for_leaks(vault, master_key, project_id, env, root, reveal)
.map_err(CliError::Core)?;
if format == OutputFormat::Json {
write_scan_json(&matches, &mut std::io::stdout())?;
return Ok(!matches.is_empty());
}
if matches.is_empty() {
println!("envy scan: no leaked secrets found.");
return Ok(false);
}
if reveal {
eprintln!("\u{26a0} Warning: secret values are visible in the output below.");
eprintln!();
}
println!("envy scan: found {} potential leak(s)\n", matches.len());
for m in &matches {
println!(
" {}:{} {} ({})",
m.path.display(),
m.line,
m.key,
m.environment
);
if let Some(v) = &m.value {
println!(" value: {}", **v);
}
}
println!(
"\n{} leak(s) found. Rotate the affected secret(s) and remove the plaintext copy.",
matches.len()
);
Ok(true)
}
fn write_scan_json(
matches: &[crate::core::ScanMatch],
writer: &mut impl std::io::Write,
) -> Result<(), CliError> {
let items: Vec<serde_json::Value> = matches
.iter()
.map(|m| {
let mut v = serde_json::json!({
"key": m.key,
"environment": m.environment,
"path": m.path.display().to_string(),
"line": m.line,
});
if let Some(val) = &m.value {
v["value"] = serde_json::Value::String(val.to_string());
}
v
})
.collect();
serde_json::to_writer_pretty(&mut *writer, &items)
.map_err(|e| CliError::Output(e.to_string()))?;
writeln!(writer).map_err(|e| CliError::Output(e.to_string()))?;
Ok(())
}
const HOOK_MARKER: &str = "# envy-hook: pre-commit v1";
fn pre_commit_hook_script() -> String {
format!(
r#"#!/bin/sh
{marker}
# Installed by `envy hooks install`. Do not edit by hand -- re-run
# `envy hooks install --force` to update it instead.
#
# Skip once: git commit --no-verify
# Uninstall: delete this file
if ! command -v envy >/dev/null 2>&1; then
echo "envy: 'envy' not found on PATH -- skipping pre-commit checks" >&2
exit 0
fi
envy scan
scan_exit=$?
if [ "$scan_exit" -eq 1 ]; then
echo "" >&2
echo "envy: blocked -- a vault secret's plaintext value was found in a file" >&2
echo "you're about to commit. Run 'envy scan --reveal' for details." >&2
echo "Bypass once with: git commit --no-verify" >&2
exit 1
elif [ "$scan_exit" -ge 2 ]; then
echo "envy: 'envy scan' failed to run (exit $scan_exit) -- not blocking commit" >&2
fi
status_json=$(envy status --format json 2>/dev/null)
if printf '%s' "$status_json" | grep -q '"modified"'; then
echo "" >&2
echo "envy: warning -- one or more environments have unsealed changes." >&2
echo "Run 'envy status' then 'envy encrypt' if teammates should see them too." >&2
fi
exit 0
"#,
marker = HOOK_MARKER
)
}
fn find_git_hooks_dir(start: &Path) -> Option<std::path::PathBuf> {
let mut dir = start.to_path_buf();
loop {
let candidate = dir.join(".git");
if candidate.is_dir() {
return Some(candidate.join("hooks"));
}
if candidate.is_file() {
let content = std::fs::read_to_string(&candidate).ok()?;
let gitdir_line = content.lines().find_map(|l| l.strip_prefix("gitdir: "))?;
let gitdir = dir.join(gitdir_line.trim());
return Some(gitdir.join("hooks"));
}
dir = dir.parent()?.to_path_buf();
}
}
pub(super) fn cmd_hooks_install(project_root: &Path, force: bool) -> Result<(), CliError> {
let hooks_dir = find_git_hooks_dir(project_root).ok_or(CliError::GitRepoNotFound)?;
std::fs::create_dir_all(&hooks_dir).map_err(|e| CliError::Output(e.to_string()))?;
let hook_path = hooks_dir.join("pre-commit");
if hook_path.exists() {
let existing =
std::fs::read_to_string(&hook_path).map_err(|e| CliError::Output(e.to_string()))?;
let is_envy_managed = existing.contains(HOOK_MARKER);
if !is_envy_managed && !force {
return Err(CliError::HookConflict(hook_path.display().to_string()));
}
if !is_envy_managed || force {
let backup_path = hooks_dir.join("pre-commit.envy-backup");
std::fs::write(&backup_path, &existing).map_err(|e| CliError::Output(e.to_string()))?;
println!(" existing hook backed up to {}", backup_path.display());
}
}
std::fs::write(&hook_path, pre_commit_hook_script())
.map_err(|e| CliError::Output(e.to_string()))?;
set_executable(&hook_path).map_err(|e| CliError::Output(e.to_string()))?;
println!(
"\u{2713} Installed pre-commit hook at {}.",
hook_path.display()
);
println!(" It runs `envy scan` on every commit and blocks it if a vault secret leaks.");
Ok(())
}
#[cfg(unix)]
fn set_executable(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(perms.mode() | 0o111);
std::fs::set_permissions(path, perms)
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> std::io::Result<()> {
Ok(())
}
fn is_color_enabled() -> bool {
use std::io::IsTerminal;
std::env::var("NO_COLOR").is_err() && std::io::stdout().is_terminal()
}
fn colorize(text: &str, ansi: &str) -> String {
if is_color_enabled() {
format!("\x1b[{ansi}m{text}\x1b[0m")
} else {
text.to_string()
}
}
fn render_diff_table(
report: &crate::core::DiffReport,
reveal: bool,
artifact_missing: bool,
env_not_in_artifact: bool,
) {
use crate::core::ChangeType;
if !report.has_differences() {
println!("envy diff: {} \u{2014} no differences", report.env_name);
return;
}
println!("envy diff: {} (vault \u{2194} envy.enc)", report.env_name);
if artifact_missing {
println!("Note: envy.enc not found \u{2014} all vault secrets shown as additions.");
} else if env_not_in_artifact {
println!(
"Note: environment '{}' not found in envy.enc \u{2014} all vault secrets shown as additions.",
report.env_name
);
}
println!();
for entry in &report.entries {
let (symbol, ansi) = match entry.change {
ChangeType::Added => ("+", "32"),
ChangeType::Removed => ("-", "31"),
ChangeType::Modified => ("~", "33"),
};
println!(" {}", colorize(&format!("{symbol} {}", entry.key), ansi));
if reveal {
match entry.change {
ChangeType::Added => {
if let Some(ref v) = entry.new_value {
println!(" vault: {}", **v);
}
}
ChangeType::Removed => {
if let Some(ref v) = entry.old_value {
println!(" artifact: {}", **v);
}
}
ChangeType::Modified => {
if let Some(ref v) = entry.old_value {
println!(" artifact: {}", **v);
}
if let Some(ref v) = entry.new_value {
println!(" vault: {}", **v);
}
}
}
println!();
}
}
let total = report.total();
let label = if total == 1 { "change" } else { "changes" };
println!(
"{total} {label}: {} added, {} removed, {} modified",
report.added, report.removed, report.modified
);
}
fn write_diff_json(
report: &crate::core::DiffReport,
env_name: &str,
reveal: bool,
writer: &mut impl std::io::Write,
) -> Result<(), CliError> {
use crate::core::ChangeType;
let changes: Vec<serde_json::Value> = report
.entries
.iter()
.map(|e| {
let type_str = match e.change {
ChangeType::Added => "added",
ChangeType::Removed => "removed",
ChangeType::Modified => "modified",
};
let mut entry = serde_json::json!({
"key": e.key,
"type": type_str,
});
if reveal {
entry["old_value"] = match &e.old_value {
Some(v) => serde_json::Value::String(v.to_string()),
None => serde_json::Value::Null,
};
entry["new_value"] = match &e.new_value {
Some(v) => serde_json::Value::String(v.to_string()),
None => serde_json::Value::Null,
};
}
entry
})
.collect();
let output = serde_json::json!({
"environment": env_name,
"has_differences": report.has_differences(),
"summary": {
"added": report.added,
"removed": report.removed,
"modified": report.modified,
"total": report.total(),
},
"changes": changes,
});
serde_json::to_writer_pretty(&mut *writer, &output)
.map_err(|e| CliError::Output(e.to_string()))?;
writeln!(writer).map_err(|e| CliError::Output(e.to_string()))?;
Ok(())
}
pub(super) fn cmd_diff(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env_name: &str,
artifact_path: &Path,
format: OutputFormat,
reveal: bool,
) -> Result<bool, CliError> {
use std::collections::BTreeMap;
let vault_map: BTreeMap<String, zeroize::Zeroizing<String>> =
crate::core::get_env_secrets(vault, master_key, project_id, env_name)
.map_err(CliError::Core)?
.into_iter()
.collect();
let mut artifact_map: BTreeMap<String, zeroize::Zeroizing<String>> = BTreeMap::new();
let mut artifact_missing = false;
let mut env_not_in_artifact = false;
match crate::core::read_artifact(artifact_path) {
Err(crate::core::SyncError::FileNotFound(_)) => {
artifact_missing = true;
}
Err(e) => {
return Err(CliError::ArtifactUnreadable(e.to_string()));
}
Ok(artifact) => {
if artifact.environments.contains_key(env_name) {
let passphrase = match resolve_passphrase_for_env(env_name, false, None)? {
Some(p) => p,
None => {
return Err(CliError::PassphraseInput(
"no passphrase available (no TTY and no ENVY_PASSPHRASE* env var)"
.into(),
));
}
};
match crate::core::unseal_env(&artifact, env_name, passphrase.as_ref())
.map_err(|e| CliError::VaultOpen(e.to_string()))?
{
Some(secrets) => {
artifact_map = secrets;
}
None => {
return Err(CliError::PassphraseInput(format!(
"incorrect passphrase for environment '{env_name}'"
)));
}
}
} else {
env_not_in_artifact = true;
}
}
}
if vault_map.is_empty() && artifact_map.is_empty() && !artifact_missing && !env_not_in_artifact
{
return Err(CliError::EnvNotFound(env_name.to_string()));
}
let report = crate::core::compute_diff(env_name, vault_map, artifact_map);
if reveal {
eprintln!("\u{26a0} Warning: secret values are visible in the output below.");
eprintln!();
}
if format == OutputFormat::Json {
write_diff_json(&report, env_name, reveal, &mut std::io::stdout())?;
} else {
render_diff_table(&report, reveal, artifact_missing, env_not_in_artifact);
}
Ok(report.has_differences())
}
#[cfg(test)]
mod tests {
use super::{Vault, cmd_decrypt, cmd_encrypt, cmd_rotate};
use crate::cli::error::{CliError, cli_exit_code};
use crate::cli::format::OutputFormat;
#[test]
fn parse_assignment_basic() {
assert_eq!("KEY=VALUE".split_once('='), Some(("KEY", "VALUE")));
}
#[test]
fn parse_assignment_value_contains_equals() {
assert_eq!("TOKEN=abc=def".split_once('='), Some(("TOKEN", "abc=def")));
}
#[test]
fn parse_assignment_no_equals() {
assert_eq!("NOVALUE".split_once('='), None);
}
#[test]
fn parse_assignment_empty_key() {
assert_eq!("=VALUE".split_once('='), Some(("", "VALUE")));
}
fn parse_env_lines(input: &str) -> (Vec<(&str, &str)>, usize) {
let mut valid: Vec<(&str, &str)> = Vec::new();
let mut malformed = 0usize;
for line in input.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
match trimmed.split_once('=') {
Some((k, v)) => valid.push((k, v)),
None => malformed += 1,
}
}
(valid, malformed)
}
#[test]
fn migrate_skips_comments_and_blanks() {
let input = "KEY1=value1\n# this is a comment\n\nKEY2=value2\n";
let (valid, malformed) = parse_env_lines(input);
assert_eq!(valid.len(), 2, "must produce exactly 2 valid pairs");
assert_eq!(malformed, 0, "must report 0 malformed lines");
assert_eq!(valid[0], ("KEY1", "value1"));
assert_eq!(valid[1], ("KEY2", "value2"));
}
#[test]
fn migrate_warns_on_malformed() {
let input = "GOOD_KEY=good_value\nBADLINE\n";
let (valid, malformed) = parse_env_lines(input);
assert_eq!(valid.len(), 1, "must produce exactly 1 valid pair");
assert_eq!(malformed, 1, "must detect exactly 1 malformed line");
assert_eq!(valid[0], ("GOOD_KEY", "good_value"));
}
#[test]
fn cmd_set_records_audit_entry() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
super::cmd_set(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"API_KEY",
"v1",
)
.expect("cmd_set must succeed");
let entries = crate::core::list_audit(&vault, &pid, None, 10).expect("list_audit");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].action, "set");
assert_eq!(entries[0].key.as_deref(), Some("API_KEY"));
assert_eq!(entries[0].environment, "development");
}
#[test]
fn cmd_get_records_audit_entry_only_on_success() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
super::cmd_set(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "v1")
.expect("cmd_set must succeed");
let _ = super::cmd_get(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"MISSING",
OutputFormat::Table,
);
super::cmd_get(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"KEY",
OutputFormat::Table,
)
.expect("cmd_get must succeed");
let entries = crate::core::list_audit(&vault, &pid, None, 10).expect("list_audit");
let get_entries: Vec<_> = entries.iter().filter(|e| e.action == "get").collect();
assert_eq!(
get_entries.len(),
1,
"only the successful get must be recorded"
);
assert_eq!(get_entries[0].key.as_deref(), Some("KEY"));
}
#[test]
fn cmd_rm_records_audit_entry() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
super::cmd_set(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "v1")
.expect("cmd_set must succeed");
super::cmd_rm(&vault, &pid, "development", "KEY").expect("cmd_rm must succeed");
let entries = crate::core::list_audit(&vault, &pid, None, 10).expect("list_audit");
let rm_entry = entries
.iter()
.find(|e| e.action == "rm")
.expect("an 'rm' entry must exist");
assert_eq!(rm_entry.key.as_deref(), Some("KEY"));
}
#[test]
fn cmd_audit_json_output_is_valid_and_masks_nothing_but_values() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
super::cmd_set(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"KEY",
"super-secret-value",
)
.expect("cmd_set must succeed");
let mut buf: Vec<u8> = Vec::new();
let entries = crate::core::list_audit(&vault, &pid, None, 50).expect("list_audit");
super::write_audit_json(&entries, &mut buf).expect("write_audit_json must succeed");
let json_str = String::from_utf8(buf).expect("valid UTF-8");
assert!(
!json_str.contains("super-secret-value"),
"audit JSON must never contain the secret value"
);
let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("valid JSON");
assert_eq!(parsed[0]["key"], "KEY");
assert_eq!(parsed[0]["action"], "set");
}
#[test]
fn cmd_audit_env_filter_restricts_report() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
super::cmd_set(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"DEV_KEY",
"v1",
)
.expect("cmd_set dev");
super::cmd_set(
&vault,
&TEST_MASTER_KEY,
&pid,
"production",
"PROD_KEY",
"v1",
)
.expect("cmd_set prod");
let entries =
crate::core::list_audit(&vault, &pid, Some("production"), 50).expect("list_audit");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].key.as_deref(), Some("PROD_KEY"));
}
#[test]
fn find_git_hooks_dir_locates_dot_git_directory() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".git")).expect("mkdir .git");
let nested = tmp.path().join("a").join("b");
std::fs::create_dir_all(&nested).expect("mkdir nested");
let hooks_dir = super::find_git_hooks_dir(&nested).expect("must find .git");
assert_eq!(hooks_dir, tmp.path().join(".git").join("hooks"));
}
#[test]
fn find_git_hooks_dir_resolves_worktree_gitfile() {
let tmp = tempfile::tempdir().expect("tempdir");
let real_gitdir = tmp.path().join("real-git-common-dir");
std::fs::create_dir_all(&real_gitdir).expect("mkdir real gitdir");
let worktree = tmp.path().join("worktree");
std::fs::create_dir_all(&worktree).expect("mkdir worktree");
std::fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", real_gitdir.display()),
)
.expect("write .git file");
let hooks_dir = super::find_git_hooks_dir(&worktree).expect("must resolve gitfile");
assert_eq!(hooks_dir, real_gitdir.join("hooks"));
}
#[test]
fn find_git_hooks_dir_returns_none_outside_any_repo() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(super::find_git_hooks_dir(tmp.path()).is_none());
}
#[test]
fn cmd_hooks_install_writes_marked_hook() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".git")).expect("mkdir .git");
super::cmd_hooks_install(tmp.path(), false).expect("install must succeed");
let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
let content = std::fs::read_to_string(&hook_path).expect("read hook");
assert!(content.contains(super::HOOK_MARKER));
assert!(content.contains("envy scan"));
}
#[test]
fn cmd_hooks_install_is_idempotent_over_its_own_hook() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".git")).expect("mkdir .git");
super::cmd_hooks_install(tmp.path(), false).expect("first install");
super::cmd_hooks_install(tmp.path(), false).expect("second install must not conflict");
}
#[test]
fn cmd_hooks_install_refuses_foreign_hook_without_force() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".git").join("hooks")).expect("mkdir hooks");
let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
std::fs::write(&hook_path, "#!/bin/sh\necho custom user hook\n")
.expect("write foreign hook");
let result = super::cmd_hooks_install(tmp.path(), false);
assert!(
matches!(result, Err(CliError::HookConflict(_))),
"must refuse to overwrite a foreign hook without --force, got: {:?}",
result
);
let content = std::fs::read_to_string(&hook_path).expect("read hook");
assert_eq!(
content, "#!/bin/sh\necho custom user hook\n",
"foreign hook content must be untouched"
);
}
#[test]
fn cmd_hooks_install_backs_up_foreign_hook_with_force() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".git").join("hooks")).expect("mkdir hooks");
let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
std::fs::write(&hook_path, "#!/bin/sh\necho custom user hook\n")
.expect("write foreign hook");
super::cmd_hooks_install(tmp.path(), true).expect("install with --force must succeed");
let backup_path = tmp
.path()
.join(".git")
.join("hooks")
.join("pre-commit.envy-backup");
let backup_content = std::fs::read_to_string(&backup_path).expect("read backup");
assert_eq!(backup_content, "#!/bin/sh\necho custom user hook\n");
let new_content = std::fs::read_to_string(&hook_path).expect("read new hook");
assert!(new_content.contains(super::HOOK_MARKER));
}
#[test]
fn cmd_hooks_install_returns_git_repo_not_found_outside_a_repo() {
let tmp = tempfile::tempdir().expect("tempdir");
let result = super::cmd_hooks_install(tmp.path(), false);
assert!(matches!(result, Err(CliError::GitRepoNotFound)));
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
const TEST_MASTER_KEY: [u8; 32] = [0xABu8; 32];
fn open_test_vault(tmp: &tempfile::TempDir) -> (Vault, crate::db::ProjectId) {
let path = tmp.path().join("vault.db");
let vault = Vault::open(&path, &TEST_MASTER_KEY).expect("vault must open");
let pid = vault
.create_project("test-project")
.expect("project must be created");
(vault, pid)
}
#[test]
fn encrypt_writes_envy_enc_with_correct_environments() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"API_KEY",
"sk_test",
)
.expect("set_secret must succeed");
let artifact_path = tmp.path().join("envy.enc");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "test-passphrase") };
let result = cmd_encrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path, None);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
result.expect("cmd_encrypt must succeed");
assert!(artifact_path.exists(), "envy.enc must be written to disk");
let raw = std::fs::read_to_string(&artifact_path).expect("must read envy.enc");
assert!(
raw.contains("\"development\""),
"JSON must contain environment name"
);
assert!(
!raw.contains("sk_test"),
"secret value must NOT appear in plaintext"
);
assert!(
!raw.contains("API_KEY"),
"secret key must NOT appear in plaintext"
);
}
#[test]
fn encrypt_uses_envy_passphrase_env_var_no_prompt() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let artifact_path = tmp.path().join("envy.enc");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "headless-pass") };
let result = cmd_encrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path, None);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
result.expect("cmd_encrypt must use ENVY_PASSPHRASE without prompting");
assert!(artifact_path.exists(), "envy.enc must be written");
}
#[test]
fn encrypt_empty_envy_passphrase_returns_passphrase_input_error() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
unsafe { std::env::set_var("ENVY_PASSPHRASE", " ") }; let result = cmd_encrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path, None);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
assert!(
matches!(result, Err(CliError::PassphraseInput(_))),
"whitespace ENVY_PASSPHRASE must return PassphraseInput, got: {:?}",
result.err()
);
assert!(
!artifact_path.exists(),
"envy.enc must NOT be written on error"
);
}
#[test]
fn encrypt_env_filter_seals_only_named_environment() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"DEV_KEY",
"dev",
)
.expect("set_secret must succeed");
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "staging", "STG_KEY", "stg")
.expect("set_secret must succeed");
let artifact_path = tmp.path().join("envy.enc");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "filter-pass") };
let result = cmd_encrypt(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("staging"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
result.expect("cmd_encrypt with env_filter must succeed");
let raw = std::fs::read_to_string(&artifact_path).expect("must read envy.enc");
assert!(raw.contains("\"staging\""), "envy.enc must contain staging");
assert!(
!raw.contains("\"development\""),
"envy.enc must NOT contain development"
);
}
#[test]
fn encrypt_uses_per_env_passphrase_var() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let artifact_path = tmp.path().join("envy.enc");
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "dev-specific-pass") };
let result = cmd_encrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path, None);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
result.expect("cmd_encrypt must succeed with ENVY_PASSPHRASE_DEVELOPMENT");
assert!(artifact_path.exists(), "envy.enc must be written");
let raw = std::fs::read_to_string(&artifact_path).expect("must read envy.enc");
assert!(
raw.contains("\"development\""),
"envy.enc must contain development"
);
}
#[test]
fn encrypt_smart_merge_preserves_existing_envelopes() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"DEV_KEY",
"dev",
)
.expect("set_secret must succeed");
crate::core::set_secret(
&vault,
&TEST_MASTER_KEY,
&pid,
"production",
"PROD_KEY",
"prod",
)
.expect("set_secret must succeed");
let prod_artifact = crate::core::seal_artifact(
&vault,
&TEST_MASTER_KEY,
&pid,
"prod-pass",
Some(&["production"]),
)
.expect("seal_artifact must succeed");
let artifact_path = tmp.path().join("envy.enc");
crate::core::write_artifact(&prod_artifact, &artifact_path)
.expect("write_artifact must succeed");
let raw_before = std::fs::read_to_string(&artifact_path).expect("must read before");
let before: serde_json::Value =
serde_json::from_str(&raw_before).expect("must parse before");
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "dev-pass") };
let result = cmd_encrypt(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
result.expect("smart merge must succeed");
let raw_after = std::fs::read_to_string(&artifact_path).expect("must read after");
let after: serde_json::Value = serde_json::from_str(&raw_after).expect("must parse after");
assert!(
raw_after.contains("\"development\""),
"development must be present after merge"
);
assert!(
raw_after.contains("\"production\""),
"production must be preserved after merge"
);
assert_eq!(
before["environments"]["production"], after["environments"]["production"],
"production envelope must be byte-identical after smart merge"
);
}
#[test]
fn encrypt_aborts_on_malformed_envy_enc() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let artifact_path = tmp.path().join("envy.enc");
std::fs::write(&artifact_path, b"{{not valid json}}")
.expect("write stale content must succeed");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "pass") };
let result = cmd_encrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path, None);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
assert!(
result.is_err(),
"malformed envy.enc must cause an error (not a silent overwrite)"
);
let remaining = std::fs::read(&artifact_path).expect("file must still exist");
assert_eq!(
remaining, b"{{not valid json}}",
"malformed envy.enc must not be overwritten on error"
);
}
#[test]
fn encrypt_removes_stale_tmp_file_on_success() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let artifact_path = tmp.path().join("envy.enc");
let tmp_path = tmp.path().join("envy.enc.tmp");
std::fs::write(&tmp_path, b"stale-tmp-content").expect("write stale .tmp must succeed");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "pass") };
let result = cmd_encrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path, None);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
result.expect("cmd_encrypt must succeed even with a stale .tmp file");
assert!(
!tmp_path.exists(),
"envy.enc.tmp must be removed after success"
);
let raw = std::fs::read_to_string(&artifact_path).expect("must read envy.enc");
assert!(
raw.contains("\"development\""),
"envy.enc must contain development after success"
);
}
#[test]
fn check_envelope_passphrase_returns_false_for_rotation_detection() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "K", "v")
.expect("set_secret must succeed");
let artifact = crate::core::seal_artifact(&vault, &TEST_MASTER_KEY, &pid, "pass-A", None)
.expect("seal_artifact must succeed");
let envelope = artifact
.environments
.get("development")
.expect("development must be present");
assert!(
!crate::core::check_envelope_passphrase("pass-B", "development", envelope),
"wrong passphrase must return false (strict-fail path in cmd_encrypt)"
);
assert!(
crate::core::check_envelope_passphrase("pass-A", "development", envelope),
"correct passphrase must return true"
);
}
#[test]
fn decrypt_imports_all_secrets_with_correct_passphrase() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"API_KEY",
"sk_test",
)
.expect("set_secret must succeed");
let artifact =
crate::core::seal_artifact(&vault, &TEST_MASTER_KEY, &pid, "correct-pass", None)
.expect("seal_artifact must succeed");
let artifact_path = tmp.path().join("envy.enc");
crate::core::write_artifact(&artifact, &artifact_path)
.expect("write_artifact must succeed");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "correct-pass") };
let result = cmd_decrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
result.expect("cmd_decrypt must succeed with correct passphrase");
let val = crate::core::get_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "API_KEY")
.expect("get_secret must succeed after decrypt");
assert_eq!(
val.as_str(),
"sk_test",
"upserted value must match original"
);
}
#[test]
fn decrypt_returns_nothing_imported_when_all_envs_skipped() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let artifact =
crate::core::seal_artifact(&vault, &TEST_MASTER_KEY, &pid, "passphrase-a", None)
.expect("seal_artifact must succeed");
let artifact_path = tmp.path().join("envy.enc");
crate::core::write_artifact(&artifact, &artifact_path)
.expect("write_artifact must succeed");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "wrong-passphrase") };
let result = cmd_decrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
assert!(
matches!(result, Err(CliError::NothingImported)),
"wrong passphrase must return NothingImported, got: {:?}",
result.err()
);
}
#[test]
fn decrypt_exits_ok_and_shows_skipped_for_partial_access() {
use crate::crypto::artifact::{ARTIFACT_VERSION, ArtifactPayload, SyncArtifact};
use crate::crypto::seal_envelope;
use std::collections::BTreeMap;
use zeroize::Zeroizing;
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let mut dev_secrets = BTreeMap::new();
dev_secrets.insert(
"DEV_KEY".to_string(),
Zeroizing::new("dev-value".to_string()),
);
let dev_payload = ArtifactPayload {
secrets: dev_secrets,
};
let dev_envelope = seal_envelope("dev-pass", &dev_payload).expect("seal dev must succeed");
let mut prod_secrets = BTreeMap::new();
prod_secrets.insert(
"PROD_KEY".to_string(),
Zeroizing::new("prod-value".to_string()),
);
let prod_payload = ArtifactPayload {
secrets: prod_secrets,
};
let prod_envelope =
seal_envelope("prod-pass", &prod_payload).expect("seal prod must succeed");
let mut environments = BTreeMap::new();
environments.insert("development".to_string(), dev_envelope);
environments.insert("production".to_string(), prod_envelope);
let artifact = SyncArtifact {
version: ARTIFACT_VERSION,
environments,
};
let artifact_path = tmp.path().join("envy.enc");
crate::core::write_artifact(&artifact, &artifact_path)
.expect("write_artifact must succeed");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "dev-pass") };
let result = cmd_decrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
assert!(
result.is_ok(),
"partial access must return Ok(()), got: {:?}",
result.err()
);
let val = crate::core::get_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "DEV_KEY")
.expect("DEV_KEY must be upserted after partial decrypt");
assert_eq!(val.as_str(), "dev-value");
let prod_result =
crate::core::get_secret(&vault, &TEST_MASTER_KEY, &pid, "production", "PROD_KEY");
assert!(
prod_result.is_err(),
"PROD_KEY must NOT be in vault after partial decrypt"
);
}
#[test]
fn decrypt_returns_error_when_envy_enc_not_found() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let nonexistent = tmp.path().join("missing.enc");
let result = cmd_decrypt(&vault, &TEST_MASTER_KEY, &pid, &nonexistent);
let err = result.expect_err("missing envy.enc must return an error");
assert!(
matches!(err, CliError::FileNotFound(_, _)),
"missing envy.enc must return FileNotFound, got: {:?}",
err
);
assert_eq!(
cli_exit_code(&err),
1,
"FileNotFound must map to exit code 1"
);
}
#[test]
fn decrypt_returns_error_for_malformed_envy_enc() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let bad_path = tmp.path().join("bad.enc");
std::fs::write(&bad_path, b"this is not json").expect("write must succeed");
let result = cmd_decrypt(&vault, &TEST_MASTER_KEY, &pid, &bad_path);
let err = result.expect_err("malformed envy.enc must return an error");
assert_eq!(
cli_exit_code(&err),
4,
"malformed envy.enc must map to exit code 4, got: {:?}",
err
);
}
#[test]
fn encrypt_update_seal_matching_passphrase_succeeds() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "pass-A") };
let result = cmd_encrypt(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
result.expect("re-seal with matching passphrase must succeed");
}
#[test]
fn encrypt_update_seal_headless_matching_succeeds_and_byte_changes() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
let sha_before = sha256_of_file(&artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "pass-A") };
let result = cmd_encrypt(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
result.expect("re-seal with matching passphrase must succeed");
let sha_after = sha256_of_file(&artifact_path);
assert_ne!(
sha_before, sha_after,
"envelope must be byte-different after re-seal (fresh salt + nonce)"
);
unsafe { std::env::set_var("ENVY_PASSPHRASE", "pass-A") };
let dec = cmd_decrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
dec.expect("decrypt with original passphrase must still work after re-seal");
}
#[test]
fn encrypt_update_seal_mismatch_interactive_fails_exit_2() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "WRONG-pass") };
let result = cmd_encrypt(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
assert!(
matches!(result, Err(CliError::PassphraseInput(_))),
"mismatch must return PassphraseInput, got: {:?}",
result.err()
);
assert_eq!(
cli_exit_code(&result.err().unwrap()),
2,
"PassphraseInput must map to exit 2"
);
}
#[test]
fn encrypt_update_seal_mismatch_headless_fails_exit_2() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "WRONG-pass") };
let result = cmd_encrypt(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
assert!(
matches!(result, Err(CliError::PassphraseInput(_))),
"headless mismatch must return PassphraseInput (was: silent rotation), got: {:?}",
result.err()
);
}
#[test]
fn encrypt_update_seal_global_envy_passphrase_mismatch_fails() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE", "WRONG-pass") };
let result = cmd_encrypt(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
assert!(
matches!(result, Err(CliError::PassphraseInput(_))),
"global ENVY_PASSPHRASE mismatch must also fail (was: silent rotation), got: {:?}",
result.err()
);
}
#[test]
fn encrypt_mismatch_leaves_artifact_unchanged_sha256() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
let sha_before = sha256_of_file(&artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "WRONG-pass") };
let result = cmd_encrypt(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
assert!(result.is_err(), "mismatch must error, got: {:?}", result);
let sha_after = sha256_of_file(&artifact_path);
assert_eq!(
sha_before, sha_after,
"envy.enc must be byte-identical after a mismatch attempt (SC-003)"
);
}
#[test]
fn encrypt_update_seal_empty_vault_skips_with_warning() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
let sha_before = sha256_of_file(&artifact_path);
crate::core::delete_secret(&vault, &pid, "development", "API_KEY")
.expect("delete_secret must succeed");
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "pass-A") };
let result = cmd_encrypt(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
result.expect("cmd_encrypt must succeed (skip) when local vault is empty");
let sha_after = sha256_of_file(&artifact_path);
assert_eq!(
sha_before, sha_after,
"envy.enc must NOT be overwritten when local vault is empty"
);
}
#[test]
fn encrypt_skips_empty_env_with_warning() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
vault
.create_environment(&pid, "empty-env")
.expect("create_environment must succeed");
let artifact_path = tmp.path().join("envy.enc");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "any-pass") };
let result = cmd_encrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path, None);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
result.expect("cmd_encrypt must return Ok(()) even when all envs are skipped");
let raw = std::fs::read_to_string(&artifact_path).expect("envy.enc must exist");
assert!(
!raw.contains("\"empty-env\""),
"empty env must not appear in envy.enc after F1 skip"
);
}
fn seal_test_env(
vault: &Vault,
pid: &crate::db::ProjectId,
env: &str,
passphrase: &str,
artifact_path: &std::path::Path,
) {
crate::core::set_secret(vault, &TEST_MASTER_KEY, pid, env, "API_KEY", "secret")
.expect("set_secret must succeed");
let var = format!("ENVY_PASSPHRASE_{}", env.to_uppercase());
unsafe { std::env::set_var(&var, passphrase) };
let result = cmd_encrypt(vault, &TEST_MASTER_KEY, pid, artifact_path, Some(env));
unsafe { std::env::remove_var(&var) };
result.expect("cmd_encrypt must succeed");
}
fn sha256_of_file(path: &std::path::Path) -> String {
use sha2::{Digest, Sha256};
let bytes = std::fs::read(path).expect("file must be readable");
let mut hasher = Sha256::new();
hasher.update(&bytes);
format!("{:x}", hasher.finalize())
}
#[test]
fn rotate_happy_path_seals_then_unseals_with_new() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "pass-A") };
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW", "pass-B") };
let result = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW") };
result.expect("cmd_rotate must succeed with correct passphrases");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "pass-B") };
let dec_ok = cmd_decrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
dec_ok.expect("cmd_decrypt with new passphrase must succeed");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "pass-A") };
let dec_fail = cmd_decrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
assert!(
matches!(dec_fail, Err(CliError::NothingImported)),
"decrypt with old passphrase must return NothingImported, got: {:?}",
dec_fail.err()
);
}
#[test]
fn rotate_wrong_current_passphrase_leaves_artifact_unchanged() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
let sha_before = sha256_of_file(&artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "WRONG-pass") };
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW", "pass-B") };
let result = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW") };
assert!(
matches!(result, Err(CliError::PassphraseInput(_))),
"wrong current passphrase must return PassphraseInput, got: {:?}",
result.err()
);
let sha_after = sha256_of_file(&artifact_path);
assert_eq!(
sha_before, sha_after,
"envy.enc must be byte-identical after a wrong-pass attempt"
);
}
#[test]
fn rotate_new_equals_current_is_rejected() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
let sha_before = sha256_of_file(&artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "pass-A") };
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW", "pass-A") };
let result = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW") };
assert!(
matches!(result, Err(CliError::PassphraseInput(_))),
"new=current must return PassphraseInput, got: {:?}",
result.err()
);
let sha_after = sha256_of_file(&artifact_path);
assert_eq!(
sha_before, sha_after,
"envy.enc must be unchanged when new=current is rejected"
);
}
#[test]
fn rotate_confirmation_mismatch_is_rejected() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
let sha_before = sha256_of_file(&artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "pass-A") };
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW", "pass-B") };
let result = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW") };
assert!(
result.is_ok(),
"headless rotation with distinct passphrases must succeed, got: {:?}",
result.err()
);
let sha_after = sha256_of_file(&artifact_path);
assert_ne!(
sha_before, sha_after,
"envy.enc must change after a successful rotation"
);
}
#[test]
fn rotate_headless_with_env_vars_succeeds() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
let sha_before = sha256_of_file(&artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "pass-A") };
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW", "pass-B") };
let result = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW") };
result.expect("headless rotation with env vars must succeed");
let sha_after = sha256_of_file(&artifact_path);
assert_ne!(sha_before, sha_after, "envy.enc must change after rotation");
}
#[test]
fn rotate_no_tty_no_env_vars_returns_error() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW") };
let result = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
if let Err(CliError::PassphraseInput(_)) = &result {
} else if result.is_ok() {
} else {
panic!("unexpected error variant: {:?}", result.err());
}
}
#[test]
fn rotate_headless_wrong_current_passphrase_returns_error() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
let sha_before = sha256_of_file(&artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "WRONG-pass") };
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW", "pass-B") };
let result = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW") };
assert!(
matches!(result, Err(CliError::PassphraseInput(_))),
"headless wrong current must return PassphraseInput, got: {:?}",
result.err()
);
let sha_after = sha256_of_file(&artifact_path);
assert_eq!(
sha_before, sha_after,
"envy.enc must be unchanged after headless wrong-pass"
);
}
#[test]
fn rotate_does_not_honour_global_envy_passphrase() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE", "pass-A") };
let result = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
assert!(
matches!(result, Err(CliError::PassphraseInput(_))),
"global ENVY_PASSPHRASE must NOT be used; expected PassphraseInput, got: {:?}",
result.err()
);
}
#[test]
fn rotate_multi_environment_rotates_each() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "shared-pass", &artifact_path);
seal_test_env(&vault, &pid, "staging", "shared-pass", &artifact_path);
let sha_before = sha256_of_file(&artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "shared-pass") };
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW", "new-shared-pass") };
unsafe { std::env::set_var("ENVY_PASSPHRASE_STAGING", "shared-pass") };
unsafe { std::env::set_var("ENVY_PASSPHRASE_STAGING_NEW", "new-shared-pass") };
let r1 = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
let r2 = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("staging"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_STAGING") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_STAGING_NEW") };
r1.expect("rotate development must succeed");
r2.expect("rotate staging must succeed");
let sha_after = sha256_of_file(&artifact_path);
assert_ne!(sha_before, sha_after, "envy.enc must change");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "new-shared-pass") };
let dec = cmd_decrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
dec.expect("decrypt with new shared passphrase must succeed");
}
#[test]
fn rotate_multi_env_one_wrong_skips_others_continue() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
seal_test_env(&vault, &pid, "staging", "pass-X", &artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "pass-A") };
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW", "pass-B") };
let r1 = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("development"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT_NEW") };
r1.expect("rotate development must succeed");
unsafe { std::env::set_var("ENVY_PASSPHRASE_STAGING", "WRONG-pass") };
unsafe { std::env::set_var("ENVY_PASSPHRASE_STAGING_NEW", "pass-Y") };
let r2 = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("staging"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_STAGING") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_STAGING_NEW") };
assert!(
r2.is_err(),
"staging rotation with wrong current must fail, got: {:?}",
r2.err()
);
unsafe { std::env::set_var("ENVY_PASSPHRASE", "pass-B") };
let dec = cmd_decrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
dec.expect("decrypt must succeed (development OK, staging skipped is fine)");
}
#[test]
fn rotate_empty_env_skips_with_warning() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
vault
.create_environment(&pid, "empty-env")
.expect("create_environment must succeed");
let artifact_path = tmp.path().join("envy.enc");
seal_test_env(&vault, &pid, "development", "pass-A", &artifact_path);
let sha_before = sha256_of_file(&artifact_path);
unsafe { std::env::set_var("ENVY_PASSPHRASE_EMPTY-ENV", "pass-E") };
unsafe { std::env::set_var("ENVY_PASSPHRASE_EMPTY-ENV_NEW", "pass-F") };
let rot = cmd_rotate(
&vault,
&TEST_MASTER_KEY,
&pid,
&artifact_path,
Some("empty-env"),
);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_EMPTY-ENV") };
unsafe { std::env::remove_var("ENVY_PASSPHRASE_EMPTY-ENV_NEW") };
rot.expect("cmd_rotate must succeed when skipping empty env");
let sha_after = sha256_of_file(&artifact_path);
assert_eq!(
sha_before, sha_after,
"envy.enc must be unchanged after skipping empty env rotation"
);
}
#[test]
fn decrypt_uses_per_env_passphrase_var() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"DEV_SECRET",
"dev-value",
)
.expect("set_secret must succeed");
let artifact_path = tmp.path().join("envy.enc");
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "dev-pass") };
cmd_encrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path, None)
.expect("cmd_encrypt must succeed");
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
let env_id = vault
.get_environment_by_name(&pid, "development")
.expect("env must exist")
.id;
vault
.delete_secret(&env_id, "DEV_SECRET")
.expect("delete_secret must succeed");
unsafe { std::env::set_var("ENVY_PASSPHRASE_DEVELOPMENT", "dev-pass") };
let result = cmd_decrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path);
unsafe { std::env::remove_var("ENVY_PASSPHRASE_DEVELOPMENT") };
result.expect("cmd_decrypt must succeed with ENVY_PASSPHRASE_DEVELOPMENT");
let val =
crate::core::get_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "DEV_SECRET")
.expect("DEV_SECRET must be re-imported after decrypt");
assert_eq!(
val.as_str(),
"dev-value",
"re-imported value must match original"
);
}
#[test]
fn status_shows_never_sealed_for_new_environment() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let artifact_path = tmp.path().join("envy.enc");
let result = super::cmd_status(&vault, &pid, &artifact_path, OutputFormat::Table, 90);
assert!(
result.is_ok(),
"cmd_status must return Ok for never-sealed env: {:?}",
result.err()
);
}
#[test]
fn status_shows_in_sync_via_direct_db_marker() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let env_id = vault
.get_environment_by_name(&pid, "development")
.expect("env must exist")
.id;
vault
.upsert_sync_marker(&env_id, i64::MAX)
.expect("upsert_sync_marker must succeed");
let artifact_path = tmp.path().join("envy.enc");
let result = super::cmd_status(&vault, &pid, &artifact_path, OutputFormat::Table, 90);
assert!(
result.is_ok(),
"cmd_status must return Ok for in-sync env: {:?}",
result.err()
);
}
#[test]
fn status_empty_vault_returns_ok() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
let artifact_path = tmp.path().join("envy.enc");
let result = super::cmd_status(&vault, &pid, &artifact_path, OutputFormat::Table, 90);
assert!(
result.is_ok(),
"cmd_status on empty vault must return Ok: {:?}",
result.err()
);
}
#[test]
fn humanize_timestamp_seconds() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let result = super::humanize_timestamp(now - 30);
assert!(
result.contains("seconds ago"),
"30 seconds ago must produce '… seconds ago', got: {result}"
);
}
#[test]
fn humanize_timestamp_minutes() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let result = super::humanize_timestamp(now - 90);
assert!(
result.contains("minutes ago"),
"90 seconds ago must produce '… minutes ago', got: {result}"
);
}
#[test]
fn humanize_timestamp_days() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let result = super::humanize_timestamp(now - 3 * 86_400);
assert!(
result.contains("days ago"),
"3 days ago must produce '… days ago', got: {result}"
);
}
#[test]
fn status_shows_in_sync_after_encrypt() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let artifact_path = tmp.path().join("envy.enc");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "test-passphrase") };
let enc_result = cmd_encrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path, None);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
enc_result.expect("cmd_encrypt must succeed");
let result = super::cmd_status(&vault, &pid, &artifact_path, OutputFormat::Table, 90);
assert!(
result.is_ok(),
"cmd_status must return Ok after encrypt: {:?}",
result.err()
);
let rows = crate::core::get_status_report(&vault, &pid, 90).expect("get_status_report");
let dev = rows
.iter()
.find(|r| r.name == "development")
.expect("development must exist");
assert_eq!(
dev.sync_status,
crate::core::SyncStatus::InSync,
"development must be InSync after encrypt"
);
}
#[test]
fn status_shows_modified_after_set() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let artifact_path = tmp.path().join("envy.enc");
unsafe { std::env::set_var("ENVY_PASSPHRASE", "test-passphrase") };
let enc_result = cmd_encrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path, None);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
enc_result.expect("cmd_encrypt must succeed");
std::thread::sleep(std::time::Duration::from_secs(1));
crate::core::set_secret(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"NEW_KEY",
"new",
)
.expect("set_secret after encrypt must succeed");
let result = super::cmd_status(&vault, &pid, &artifact_path, OutputFormat::Table, 90);
assert!(
result.is_ok(),
"cmd_status must return Ok after set: {:?}",
result.err()
);
let rows = crate::core::get_status_report(&vault, &pid, 90).expect("get_status_report");
let dev = rows
.iter()
.find(|r| r.name == "development")
.expect("development must exist");
assert_eq!(
dev.sync_status,
crate::core::SyncStatus::Modified,
"development must be Modified after set"
);
}
#[test]
fn status_json_output_is_valid_json() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let artifact_path = tmp.path().join("envy.enc");
let rows = crate::core::get_status_report(&vault, &pid, 90).expect("get_status_report");
let mut buf: Vec<u8> = Vec::new();
super::write_status_json(&rows, &artifact_path, &mut buf)
.expect("write_status_json must succeed");
let json_str = String::from_utf8(buf).expect("must be valid UTF-8");
let parsed: serde_json::Value =
serde_json::from_str(&json_str).expect("output must be valid JSON");
let envs = parsed["environments"]
.as_array()
.expect("environments must be an array");
assert_eq!(
envs.len(),
1,
"must have exactly 1 environment, got: {}",
envs.len()
);
}
#[test]
fn status_json_status_strings_are_lowercase() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let env_id = vault
.get_environment_by_name(&pid, "development")
.expect("env must exist")
.id;
vault
.upsert_sync_marker(&env_id, 1_000) .expect("upsert_sync_marker must succeed");
let artifact_path = tmp.path().join("envy.enc");
let rows = crate::core::get_status_report(&vault, &pid, 90).expect("get_status_report");
let mut buf: Vec<u8> = Vec::new();
super::write_status_json(&rows, &artifact_path, &mut buf)
.expect("write_status_json must succeed");
let json_str = String::from_utf8(buf).expect("valid UTF-8");
let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("valid JSON");
let status = parsed["environments"][0]["status"]
.as_str()
.expect("status must be a string");
assert_eq!(
status, "modified",
"status must be lowercase 'modified', got: {status}"
);
}
#[test]
fn epoch_to_iso8601_known_value() {
assert_eq!(
super::epoch_to_iso8601(0),
"1970-01-01T00:00:00Z",
"epoch 0 must map to 1970-01-01T00:00:00Z"
);
assert_eq!(
super::epoch_to_iso8601(1_000_000_000),
"2001-09-09T01:46:40Z",
"epoch 1000000000 must map to 2001-09-09T01:46:40Z"
);
}
#[test]
fn status_artifact_not_found_renders_gracefully() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let nonexistent_artifact = tmp.path().join("no-such-file.enc");
let result =
super::cmd_status(&vault, &pid, &nonexistent_artifact, OutputFormat::Table, 90);
assert!(
result.is_ok(),
"cmd_status must return Ok when artifact not found: {:?}",
result.err()
);
}
#[test]
fn status_artifact_malformed_renders_gracefully() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let bad_artifact = tmp.path().join("bad.enc");
std::fs::write(&bad_artifact, b"not valid json").expect("write must succeed");
let result = super::cmd_status(&vault, &pid, &bad_artifact, OutputFormat::Table, 90);
assert!(
result.is_ok(),
"cmd_status must return Ok when artifact is malformed: {:?}",
result.err()
);
}
#[test]
fn status_json_artifact_found_false_when_missing() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let nonexistent = tmp.path().join("missing.enc");
let rows = crate::core::get_status_report(&vault, &pid, 90).expect("get_status_report");
let mut buf: Vec<u8> = Vec::new();
super::write_status_json(&rows, &nonexistent, &mut buf)
.expect("write_status_json must succeed");
let json_str = String::from_utf8(buf).expect("valid UTF-8");
let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("valid JSON");
assert_eq!(
parsed["artifact"]["found"],
serde_json::Value::Bool(false),
"artifact.found must be false when file is missing"
);
let envs = parsed["artifact"]["environments"]
.as_array()
.expect("environments must be array");
assert!(
envs.is_empty(),
"artifact.environments must be empty when file is missing"
);
}
#[test]
fn decrypt_returns_passphrase_input_error_for_empty_passphrase() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_MASTER_KEY, &pid, "development", "KEY", "val")
.expect("set_secret must succeed");
let artifact =
crate::core::seal_artifact(&vault, &TEST_MASTER_KEY, &pid, "valid-pass", None)
.expect("seal_artifact must succeed");
let artifact_path = tmp.path().join("envy.enc");
crate::core::write_artifact(&artifact, &artifact_path)
.expect("write_artifact must succeed");
unsafe { std::env::set_var("ENVY_PASSPHRASE", " ") };
let result = cmd_decrypt(&vault, &TEST_MASTER_KEY, &pid, &artifact_path);
unsafe { std::env::remove_var("ENVY_PASSPHRASE") };
assert!(
matches!(result, Err(CliError::PassphraseInput(_))),
"whitespace ENVY_PASSPHRASE must return PassphraseInput, got: {:?}",
result.err()
);
}
fn make_diff_report(
entries: Vec<(&str, crate::core::ChangeType, Option<&str>, Option<&str>)>,
) -> crate::core::DiffReport {
use crate::core::{DiffEntry, DiffReport};
use zeroize::Zeroizing;
let mut added = 0usize;
let mut removed = 0usize;
let mut modified = 0usize;
let diff_entries: Vec<DiffEntry> = entries
.into_iter()
.map(|(key, change, old, new)| {
match change {
crate::core::ChangeType::Added => added += 1,
crate::core::ChangeType::Removed => removed += 1,
crate::core::ChangeType::Modified => modified += 1,
}
DiffEntry {
key: key.to_string(),
change,
old_value: old.map(|v| Zeroizing::new(v.to_string())),
new_value: new.map(|v| Zeroizing::new(v.to_string())),
}
})
.collect();
DiffReport {
env_name: "development".to_string(),
entries: diff_entries,
added,
removed,
modified,
}
}
#[test]
fn diff_json_no_reveal() {
use crate::core::ChangeType;
let report = make_diff_report(vec![
("API_KEY", ChangeType::Added, None, Some("secret")),
("DB_URL", ChangeType::Modified, Some("old"), Some("new")),
]);
let mut buf = Vec::new();
super::write_diff_json(&report, "development", false, &mut buf).unwrap();
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert_eq!(json["has_differences"], true);
assert_eq!(json["summary"]["total"], 2);
for change in json["changes"].as_array().unwrap() {
assert!(
change.get("old_value").is_none(),
"old_value must be absent without --reveal"
);
assert!(
change.get("new_value").is_none(),
"new_value must be absent without --reveal"
);
}
}
#[test]
fn diff_json_with_reveal() {
use crate::core::ChangeType;
let report = make_diff_report(vec![
("API_KEY", ChangeType::Added, None, Some("secret")),
("DB_URL", ChangeType::Modified, Some("old"), Some("new")),
("OLD_KEY", ChangeType::Removed, Some("removed_val"), None),
]);
let mut buf = Vec::new();
super::write_diff_json(&report, "development", true, &mut buf).unwrap();
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
let changes = json["changes"].as_array().unwrap();
let added = changes.iter().find(|c| c["type"] == "added").unwrap();
assert_eq!(added["old_value"], serde_json::Value::Null);
assert_eq!(added["new_value"], "secret");
let modified = changes.iter().find(|c| c["type"] == "modified").unwrap();
assert_eq!(modified["old_value"], "old");
assert_eq!(modified["new_value"], "new");
let removed = changes.iter().find(|c| c["type"] == "removed").unwrap();
assert_eq!(removed["old_value"], "removed_val");
assert_eq!(removed["new_value"], serde_json::Value::Null);
}
#[test]
fn diff_json_no_differences() {
let report = make_diff_report(vec![]);
let mut buf = Vec::new();
super::write_diff_json(&report, "development", false, &mut buf).unwrap();
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert_eq!(json["has_differences"], false);
assert_eq!(json["changes"].as_array().unwrap().len(), 0);
assert_eq!(json["summary"]["added"], 0);
assert_eq!(json["summary"]["removed"], 0);
assert_eq!(json["summary"]["modified"], 0);
assert_eq!(json["summary"]["total"], 0);
}
#[test]
fn diff_json_type_strings() {
use crate::core::ChangeType;
let report = make_diff_report(vec![
("A", ChangeType::Added, None, Some("v")),
("B", ChangeType::Modified, Some("old"), Some("new")),
("C", ChangeType::Removed, Some("v"), None),
]);
let mut buf = Vec::new();
super::write_diff_json(&report, "development", false, &mut buf).unwrap();
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
let types: Vec<&str> = json["changes"]
.as_array()
.unwrap()
.iter()
.map(|c| c["type"].as_str().unwrap())
.collect();
assert!(types.contains(&"added"), "must contain 'added'");
assert!(types.contains(&"removed"), "must contain 'removed'");
assert!(types.contains(&"modified"), "must contain 'modified'");
}
#[test]
fn init_nested_project_logic_parent_not_blocker() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let parent = tmp.path();
let child = parent.join("child");
std::fs::create_dir(&child).expect("mkdir child");
std::fs::write(
parent.join("envy.toml"),
"project_id = \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\"\n",
)
.expect("write parent envy.toml");
let result = crate::core::find_manifest(&child);
assert!(
result.is_ok(),
"find_manifest from child must succeed (finds parent)"
);
let (_, found_dir) = result.unwrap();
assert_eq!(found_dir, parent, "found_dir must be the parent");
assert!(
found_dir != child,
"parent manifest in ancestor must NOT block init"
);
}
#[test]
fn init_already_initialised_logic_still_blocks() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let dir = tmp.path();
std::fs::write(
dir.join("envy.toml"),
"project_id = \"11111111-2222-3333-4444-555555555555\"\n",
)
.expect("write envy.toml");
let result = crate::core::find_manifest(dir);
assert!(result.is_ok(), "find_manifest from CWD must succeed");
let (_, found_dir) = result.unwrap();
assert_eq!(found_dir, *dir, "found_dir must be the CWD");
assert!(
found_dir == *dir,
"init in CWD with existing envy.toml must be blocked (regression guard)"
);
}
#[test]
fn cmd_scan_clean_repo_returns_false() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
super::cmd_set(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"KEY",
"value-123",
)
.expect("cmd_set");
let scan_root = tempfile::tempdir().expect("scan root");
std::fs::write(scan_root.path().join("file.txt"), "nothing here\n").expect("write");
let has_leaks = super::cmd_scan(
&vault,
&TEST_MASTER_KEY,
&pid,
None,
scan_root.path(),
OutputFormat::Table,
false,
)
.expect("cmd_scan");
assert!(!has_leaks, "a clean repo must return Ok(false)");
}
#[test]
fn cmd_scan_with_leak_returns_true() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
super::cmd_set(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"KEY",
"leaked-secret",
)
.expect("cmd_set");
let scan_root = tempfile::tempdir().expect("scan root");
std::fs::write(scan_root.path().join("leaked.txt"), "leaked-secret\n").expect("write");
let has_leaks = super::cmd_scan(
&vault,
&TEST_MASTER_KEY,
&pid,
None,
scan_root.path(),
OutputFormat::Table,
false,
)
.expect("cmd_scan");
assert!(has_leaks, "a repo with a leak must return Ok(true)");
}
#[test]
fn cmd_scan_json_output_is_valid_json() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
super::cmd_set(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"DB_URL",
"postgres://secret",
)
.expect("cmd_set");
let scan_root = tempfile::tempdir().expect("scan root");
std::fs::write(
scan_root.path().join("config.ts"),
"const url = 'postgres://secret';\n",
)
.expect("write");
let matches = crate::core::scan_for_leaks(
&vault,
&TEST_MASTER_KEY,
&pid,
None,
scan_root.path(),
false,
)
.expect("scan_for_leaks");
let mut buf: Vec<u8> = Vec::new();
super::write_scan_json(&matches, &mut buf).expect("write_scan_json");
let json_str = String::from_utf8(buf).expect("valid UTF-8");
let parsed: serde_json::Value =
serde_json::from_str(&json_str).expect("must be valid JSON");
assert_eq!(parsed.as_array().unwrap().len(), 1);
assert_eq!(parsed[0]["key"], "DB_URL");
assert_eq!(parsed[0]["environment"], "development");
assert!(
parsed[0]["value"].is_null(),
"value must be null when reveal is false"
);
}
#[test]
fn cmd_scan_json_with_reveal_includes_value() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
super::cmd_set(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"KEY",
"secret-value-here",
)
.expect("cmd_set");
let scan_root = tempfile::tempdir().expect("scan root");
std::fs::write(scan_root.path().join("f.txt"), "secret-value-here\n").expect("write");
let matches = crate::core::scan_for_leaks(
&vault,
&TEST_MASTER_KEY,
&pid,
None,
scan_root.path(),
true,
)
.expect("scan_for_leaks");
let mut buf: Vec<u8> = Vec::new();
super::write_scan_json(&matches, &mut buf).expect("write_scan_json");
let json_str = String::from_utf8(buf).expect("valid UTF-8");
let parsed: serde_json::Value =
serde_json::from_str(&json_str).expect("must be valid JSON");
assert_eq!(
parsed[0]["value"], "secret-value-here",
"value must be present when reveal is true"
);
}
#[test]
fn status_report_rotation_reminder_zero_disables() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"OLD_KEY",
"v1",
)
.expect("set_secret");
let report = crate::core::get_status_report(&vault, &pid, 0).expect("get_status_report");
assert_eq!(report.len(), 1);
assert!(
report[0].stale_secrets.is_empty(),
"rotation_reminder_days = 0 must disable the reminder entirely"
);
}
#[test]
fn status_report_rotation_reminder_finds_stale_keys() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(
&vault,
&TEST_MASTER_KEY,
&pid,
"development",
"STALE_KEY",
"v1",
)
.expect("set_secret");
let report = crate::core::get_status_report(&vault, &pid, 1).expect("get_status_report");
assert_eq!(report.len(), 1);
assert!(
report[0].stale_secrets.is_empty(),
"a just-created secret must NOT be flagged as stale"
);
}
#[test]
fn passphrase_strength_hint_weak_contains_hint() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("NO_COLOR", "1") };
let bits = crate::crypto::strength::estimate_entropy_bits("hunter2");
let level = crate::crypto::strength::classify(bits);
assert!(
level.is_weak(),
"hunter2 must classify as weak, got {:?} (bits={bits})",
level
);
unsafe { std::env::remove_var("NO_COLOR") };
}
#[test]
fn passphrase_strength_hint_strong_has_no_hint() {
let bits = crate::crypto::strength::estimate_entropy_bits(
"correct-horse-battery-staple-tiger-lion-eagle",
);
let level = crate::crypto::strength::classify(bits);
assert!(
!level.is_weak(),
"a 7-word Diceware passphrase must NOT be weak, got {:?} (bits={bits})",
level
);
}
}