use anyhow::{Context, Result};
use colored::Colorize;
use crate::cli::AuditExportFormat;
use tsafe_core::{profile, snapshot, vault::Vault};
use crate::helpers::*;
pub(crate) fn cmd_diff(profile: &str) -> Result<()> {
profile::validate_profile_name(profile)?;
let vault_path_current = profile::vault_path(profile);
anyhow::ensure!(
vault_path_current.exists(),
"no vault for profile '{profile}'"
);
let snaps = snapshot::list(profile)?;
let latest = snaps.last().ok_or_else(|| {
anyhow::anyhow!(
"no snapshots found for profile '{profile}' — make at least one change first"
)
})?;
let password = prompt_password("Vault password: ")?;
let current_vault = Vault::open(&vault_path_current, password.as_bytes())
.context("failed to open current vault")?;
let snap_tmp = vault_path_current.with_extension("vault.diff.tmp");
std::fs::copy(latest, &snap_tmp)?;
let snap_vault = match Vault::open(&snap_tmp, password.as_bytes()) {
Ok(v) => v,
Err(e) => {
let _ = std::fs::remove_file(&snap_tmp);
return Err(e).context("failed to open snapshot — was password different?");
}
};
let current_keys: std::collections::BTreeSet<String> =
current_vault.list().iter().map(|s| s.to_string()).collect();
let snap_keys: std::collections::BTreeSet<String> =
snap_vault.list().iter().map(|s| s.to_string()).collect();
let added: Vec<&String> = current_keys.difference(&snap_keys).collect();
let removed: Vec<&String> = snap_keys.difference(¤t_keys).collect();
let common: Vec<&String> = current_keys.intersection(&snap_keys).collect();
let mut changed = Vec::new();
for k in &common {
let cur_val = current_vault.get(k).ok();
let snap_val = snap_vault.get(k).ok();
if cur_val.as_deref().map(|z| &**z) != snap_val.as_deref().map(|z| &**z) {
changed.push(k.as_str());
}
}
drop(snap_vault);
drop(current_vault);
let _ = std::fs::remove_file(&snap_tmp);
if added.is_empty() && removed.is_empty() && changed.is_empty() {
println!(
"{} no changes since last snapshot ({})",
"i".blue(),
latest.file_name().unwrap_or_default().to_string_lossy()
);
return Ok(());
}
println!(
"diff: current ↔ {}",
latest.file_name().unwrap_or_default().to_string_lossy()
);
for k in &added {
println!(" {} {k}", "+".green());
}
for k in &removed {
println!(" {} {k}", "-".red());
}
for k in &changed {
println!(" {} {k}", "~".yellow());
}
println!(
"{} +{} -{} ~{}",
"i".blue(),
added.len(),
removed.len(),
changed.len()
);
Ok(())
}
pub(crate) fn cmd_compare(profile_a: &str, profile_b: &str) -> Result<()> {
profile::validate_profile_name(profile_a)?;
profile::validate_profile_name(profile_b)?;
let path_a = profile::vault_path(profile_a);
let path_b = profile::vault_path(profile_b);
anyhow::ensure!(path_a.exists(), "no vault for profile '{profile_a}'");
anyhow::ensure!(path_b.exists(), "no vault for profile '{profile_b}'");
let vf_a: tsafe_core::vault::VaultFile =
serde_json::from_str(&std::fs::read_to_string(&path_a)?)?;
let vf_b: tsafe_core::vault::VaultFile =
serde_json::from_str(&std::fs::read_to_string(&path_b)?)?;
let keys_a: std::collections::BTreeSet<&str> =
vf_a.secrets.keys().map(|s| s.as_str()).collect();
let keys_b: std::collections::BTreeSet<&str> =
vf_b.secrets.keys().map(|s| s.as_str()).collect();
let only_a: Vec<&&str> = keys_a.difference(&keys_b).collect();
let only_b: Vec<&&str> = keys_b.difference(&keys_a).collect();
let both: Vec<&&str> = keys_a.intersection(&keys_b).collect();
let mut mismatch = Vec::new();
for k in &both {
if vf_a.secrets[**k].ciphertext != vf_b.secrets[**k].ciphertext {
mismatch.push(**k);
}
}
if only_a.is_empty() && only_b.is_empty() && mismatch.is_empty() {
println!(
"{} profiles '{profile_a}' and '{profile_b}' have identical key sets",
"✓".green()
);
return Ok(());
}
println!("compare: {profile_a} ↔ {profile_b}");
for k in &only_a {
println!(" {} {k} (only in {profile_a})", "<".cyan());
}
for k in &only_b {
println!(" {} {k} (only in {profile_b})", ">".cyan());
}
for k in &mismatch {
println!(" {} {k} (value differs)", "~".yellow());
}
println!(
"{} <{} >{} ~{}",
"i".blue(),
only_a.len(),
only_b.len(),
mismatch.len()
);
Ok(())
}
#[cfg(feature = "git-helpers")]
pub(crate) fn cmd_hook_install(dir: Option<&str>) -> Result<()> {
let start = match dir {
Some(d) => std::path::PathBuf::from(d),
None => std::env::current_dir().context("cannot determine current directory")?,
};
let mut current = start.as_path();
let repo_root = loop {
if current.join(".git").is_dir() {
break current.to_path_buf();
}
current = current
.parent()
.ok_or_else(|| anyhow::anyhow!("not inside a git repository (no .git found)"))?;
};
let hooks_dir = repo_root.join(".githooks");
std::fs::create_dir_all(&hooks_dir)?;
let hook_path = hooks_dir.join("pre-commit");
let existing = if hook_path.exists() {
std::fs::read_to_string(&hook_path)?
} else {
String::new()
};
let scanner_line = "# tsafe secret scanner\n./scripts/quality/secret-scan.ps1\n";
if existing.contains("secret-scan.ps1") {
println!(
"{} secret-scanner hook already installed in {}",
"i".blue(),
hook_path.display()
);
return Ok(());
}
let new_content = if existing.trim().is_empty() {
format!("#!/usr/bin/env pwsh\n{scanner_line}")
} else {
format!("{existing}\n{scanner_line}")
};
let tmp = hook_path.with_extension("tmp");
std::fs::write(&tmp, &new_content)?;
std::fs::rename(&tmp, &hook_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path)?.permissions();
perms.set_mode(perms.mode() | 0o111);
std::fs::set_permissions(&hook_path, perms)?;
}
println!(
"{} Secret-scanner hook written to {}",
"✓".green(),
hook_path.display()
);
println!(
"{} Run once to activate: git config core.hooksPath .githooks",
"i".blue()
);
Ok(())
}
pub(crate) fn cmd_audit_export(
profile: &str,
format: AuditExportFormat,
output: Option<&str>,
) -> Result<()> {
let entries = audit(profile).read(None)?;
if entries.is_empty() {
eprintln!("{} no audit entries for profile '{profile}'", "i".blue());
return Ok(());
}
let content = match format {
AuditExportFormat::Json => entries
.iter()
.map(|e| serde_json::to_string(e).unwrap())
.collect::<Vec<_>>()
.join("\n"),
AuditExportFormat::Splunk => entries
.iter()
.map(|e| {
serde_json::json!({
"time": e.timestamp.timestamp(),
"event": {
"id": &e.id,
"profile": &e.profile,
"operation": &e.operation,
"key": &e.key,
"status": format!("{:?}", e.status).to_lowercase(),
"message": &e.message,
}
})
.to_string()
})
.collect::<Vec<_>>()
.join("\n"),
AuditExportFormat::CloudEvents => entries
.iter()
.map(|e| serde_json::to_string(&tsafe_core::events::CloudEvent::from_audit(e)).unwrap())
.collect::<Vec<_>>()
.join("\n"),
};
match output {
Some(path) => {
let tmp = format!("{path}.tmp");
std::fs::write(&tmp, &content)?;
std::fs::rename(&tmp, path)?;
println!(
"{} Exported {} entries to '{path}'",
"✓".green(),
entries.len()
);
}
None => println!("{content}"),
}
Ok(())
}