tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! Profile and vault rotation command handlers.
//!
//! Implements `tsafe rotate`, `tsafe profile`, and `tsafe unlock` — vault
//! master-password rotation, profile lifecycle management (list/delete/set-default/rename),
//! and stale lock-file removal.

use std::io::{self, BufRead, Write};

use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_cli::cli::ProfileAction;
use tsafe_core::{audit::AuditEntry, events::emit_event, profile};

use crate::helpers::*;

pub(crate) fn cmd_rotate(profile: &str) -> Result<()> {
    let mut vault = open_vault(profile)?;
    let new_pw = if let Ok(pw) = std::env::var("TSAFE_NEW_MASTER_PASSWORD") {
        if pw.is_empty() {
            anyhow::bail!("TSAFE_NEW_MASTER_PASSWORD is set but empty — unset it or set a non-empty new password");
        }
        eprintln!(
            "{} Using new master password from TSAFE_NEW_MASTER_PASSWORD (non-interactive).",
            "i".blue()
        );
        pw
    } else {
        prompt_password_confirmed()?
    };
    vault.rotate(new_pw.as_bytes()).context("rotation failed")?;
    audit(profile)
        .append(&AuditEntry::success(profile, "rotate", None))
        .ok();
    emit_event(profile, "rotate", None);
    println!("{} Vault re-encrypted with new password", "".green());
    offer_os_keychain_for_master_password(
        profile,
        &new_pw,
        MasterPasswordKeychainPrompt::AfterRotate,
    )?;
    Ok(())
}

pub(crate) fn cmd_profile(active_profile: &str, action: ProfileAction) -> Result<()> {
    match action {
        ProfileAction::List => {
            let profiles = profile::list_profiles()?;
            let default = profile::get_default_profile();
            if profiles.is_empty() {
                println!("{} No profiles found", "i".blue());
            } else {
                for p in &profiles {
                    if p == &default {
                        println!("{} {} {}", "".cyan(), p, "(default)".dimmed());
                    } else {
                        println!("  {p}");
                    }
                }
            }
        }
        ProfileAction::Delete { name, force } => {
            if !force {
                eprint!("Delete vault for profile '{name}'? [y/N]: ");
                io::stderr().flush().ok();
                let mut input = String::new();
                io::stdin().lock().read_line(&mut input)?;
                if !input.trim().eq_ignore_ascii_case("y") {
                    println!("Aborted.");
                    return Ok(());
                }
            }
            let path = profile::vault_path(&name);
            if !path.exists() {
                anyhow::bail!("no vault found for profile '{name}'");
            }
            std::fs::remove_file(&path)?;
            // If the deleted profile was the stored default, reset to "default".
            if profile::get_default_profile() == name {
                profile::set_default_profile("default").map_err(|e| anyhow::anyhow!("{e}"))?;
            }
            println!("{} Deleted vault for profile '{name}'", "".green());
        }
        ProfileAction::SetDefault { name } => {
            profile::validate_profile_name(&name).map_err(|e| anyhow::anyhow!("{e}"))?;
            if !profile::profile_exists(&name) {
                anyhow::bail!(
                    "no vault found for profile '{name}' — create it with: tsafe --profile {name} init"
                );
            }
            profile::set_default_profile(&name).map_err(|e| anyhow::anyhow!("{e}"))?;
            println!("{} Default profile set to '{name}'.", "".green());
            println!("  Running `tsafe` without -p will now use '{name}'.");
        }
        ProfileAction::Rename { from, to } => {
            profile::validate_profile_name(&from).map_err(|e| anyhow::anyhow!("{e}"))?;
            profile::validate_profile_name(&to).map_err(|e| anyhow::anyhow!("{e}"))?;
            let src = profile::vault_path(&from);
            let dst = profile::vault_path(&to);
            anyhow::ensure!(src.exists(), "no vault found for profile '{from}'");
            anyhow::ensure!(!dst.exists(), "a vault already exists for profile '{to}'");
            let snapshots_migrated = profile::rename_profile_snapshot_history(&from, &to)
                .with_context(|| format!("failed to migrate snapshots from '{from}' to '{to}'"))?;
            if let Err(err) = std::fs::rename(&src, &dst) {
                if snapshots_migrated {
                    // Best-effort rollback so a failed vault rename does not leave snapshot
                    // history moved to the new profile while the vault stays behind.
                    match profile::rename_profile_snapshot_history(&to, &from) {
                        Ok(_) => {
                            return Err(anyhow::Error::new(err).context(format!(
                                "failed to rename vault from '{from}' to '{to}' after moving snapshots; snapshot migration was rolled back"
                            )));
                        }
                        Err(rollback_err) => {
                            return Err(anyhow::anyhow!(
                                "failed to rename vault from '{from}' to '{to}' after moving snapshots: {err}; snapshot rollback also failed: {rollback_err}"
                            ));
                        }
                    }
                }
                return Err(anyhow::Error::new(err)
                    .context(format!("failed to rename vault from '{from}' to '{to}'")));
            }
            // Also rename audit log if present.
            let audit_src = profile::audit_log_path(&from);
            let audit_dst = profile::audit_log_path(&to);
            if audit_src.exists() {
                let _ = std::fs::rename(&audit_src, &audit_dst);
            }
            // Update stored default if needed.
            if profile::get_default_profile() == from {
                profile::set_default_profile(&to).map_err(|e| anyhow::anyhow!("{e}"))?;
            }
            println!("{} Profile '{from}' renamed to '{to}'.", "".green());
            if snapshots_migrated {
                println!("  {} Snapshot history moved to profile '{to}'.", "i".blue());
            }
            if active_profile == from {
                println!(
                    "  {}: update your shell to use -p {to} or set TSAFE_PROFILE={to}",
                    "note".yellow()
                );
            }
        }
    }
    Ok(())
}

pub(crate) fn cmd_unlock(profile: &str) -> Result<()> {
    profile::validate_profile_name(profile)?;
    let vault_path = profile::vault_path(profile);
    let lock_path = vault_path.with_extension("vault.lock");
    if lock_path.exists() {
        std::fs::remove_file(&lock_path)
            .with_context(|| format!("Failed to remove lock file: {}", lock_path.display()))?;
        println!(
            "{} lock removed: {}",
            "".green().bold(),
            lock_path.display()
        );
    } else {
        println!(
            "{} no lock file found for profile '{profile}' — vault is not locked",
            "i".blue()
        );
    }
    Ok(())
}