tsafe-cli 1.0.26

Secrets runtime for developers — inject credentials into processes via exec, never into shell history or .env files
//! Diff, compare, hook-install, and audit-export command handlers.
//!
//! Implements `tsafe diff`, `tsafe compare`, `tsafe hook-install`, and
//! `tsafe audit-export` — introspection and tooling commands that help operators
//! understand vault state changes and integrate tsafe into git workflows.

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"
        )
    })?;

    // Open both vaults so we can decrypt and compare actual values (not ciphertext).
    let password = prompt_password("Vault password: ")?;
    let current_vault = Vault::open(&vault_path_current, password.as_bytes())
        .context("failed to open current vault")?;

    // Open snapshot as a vault by copying to a temp file (avoids locking the snapshot).
    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(&current_keys).collect();
    let common: Vec<&String> = current_keys.intersection(&snap_keys).collect();

    // Decrypt both sides and compare actual plaintext values.
    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 vaults (releases locks) before cleanup.
    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();

    // "mismatch" = same key, different ciphertext (possible value diff — we can't decrypt without pw).
    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<()> {
    // Walk from `dir` (or cwd) up until we find a `.git` directory.
    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");

    // Append or create.
    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}")
    };

    // Atomic write.
    let tmp = hook_path.with_extension("tmp");
    std::fs::write(&tmp, &new_content)?;
    std::fs::rename(&tmp, &hook_path)?;

    // Make executable on Unix.
    #[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(())
}