tsafe-cli 1.0.23

Local-first developer secret vault CLI — encrypted storage, process injection via exec, cloud sync, audit trail
Documentation
//! Alias, history, and move command handlers.
//!
//! Implements `tsafe alias`, `tsafe history`, and `tsafe mv` — creating key
//! aliases within a vault, viewing secret version history, and renaming or
//! moving secrets within or across profiles.

use std::collections::HashMap;

use anyhow::Result;
use colored::Colorize;
use tsafe_core::{audit::AuditEntry, errors::SafeError};

use crate::helpers::*;

pub(crate) fn cmd_alias(
    profile: &str,
    target_key: Option<&str>,
    alias_name: Option<&str>,
    list: bool,
) -> Result<()> {
    if list {
        let vault = open_vault(profile)?;
        let aliases: Vec<(&String, &tsafe_core::vault::SecretEntry)> = vault
            .file()
            .secrets
            .iter()
            .filter(|(_, e)| e.tags.get("type").map(|t| t == "alias").unwrap_or(false))
            .collect();
        if aliases.is_empty() {
            println!("{} No aliases in profile '{profile}'", "i".blue());
        } else {
            for (name, entry) in &aliases {
                let target = entry.tags.get("target").map(|s| s.as_str()).unwrap_or("?");
                println!("{name}{target}");
            }
        }
        return Ok(());
    }
    let target = target_key
        .ok_or_else(|| anyhow::anyhow!("provide TARGET_KEY and ALIAS_NAME, or use --list"))?;
    let alias = alias_name.ok_or_else(|| anyhow::anyhow!("provide TARGET_KEY and ALIAS_NAME"))?;
    let mut vault = open_vault(profile)?;
    // Validate the target key exists.
    if !vault.list().contains(&target) {
        anyhow::bail!("target key '{target}' does not exist in profile '{profile}'");
    }
    let mut tags = HashMap::new();
    tags.insert("type".into(), "alias".into());
    tags.insert("target".into(), target.to_string());
    vault.set(alias, &format!("@alias:{target}"), tags)?;
    audit(profile)
        .append(&AuditEntry::success(profile, "alias", Some(alias)))
        .ok();
    println!("{} Alias '{alias}' → '{target}' created", "".green());
    Ok(())
}

pub(crate) fn cmd_history(profile: &str, key: &str) -> Result<()> {
    let vault = open_vault(profile)?;
    let versions = vault.history(key).map_err(|e| match &e {
        SafeError::SecretNotFound { .. } => anyhow::anyhow!("secret '{key}' not found"),
        other => anyhow::anyhow!("{other}"),
    })?;
    if versions.len() <= 1 {
        println!("No previous versions for '{key}'");
    } else {
        println!("Versions for '{key}':");
        for (ver, ts) in &versions {
            let label = if *ver == 0 { " (current)" } else { "" };
            println!("  v{ver}  {}{label}", ts.format("%Y-%m-%d %H:%M:%S UTC"));
        }
        println!(
            "\nUse {} to retrieve a specific version.",
            format!("tsafe get {key} --version N").cyan()
        );
    }
    Ok(())
}

pub(crate) fn cmd_mv(
    profile: &str,
    source: &str,
    dest: Option<&str>,
    to_profile: Option<&str>,
    force: bool,
) -> Result<()> {
    match to_profile {
        None => {
            // Within same profile: rename/move key (namespace change).
            let dest_key = dest.ok_or_else(|| {
                anyhow::anyhow!("provide a destination key name, e.g. tsafe mv OLD NEW")
            })?;
            if source == dest_key {
                anyhow::bail!("source and destination are the same key");
            }
            let mut vault = open_vault(profile)?;
            vault
                .rename_key(source, dest_key, force)
                .map_err(|e| match &e {
                    SafeError::SecretNotFound { .. } => {
                        anyhow::anyhow!("secret '{source}' not found")
                    }
                    SafeError::SecretAlreadyExists { .. } => anyhow::anyhow!(
                        "destination '{dest_key}' already exists — use --force to overwrite"
                    ),
                    other => anyhow::anyhow!("{other}"),
                })?;
            println!("{} Moved '{}' → '{}'", "".green(), source, dest_key);
        }
        Some(target_profile) => {
            // Cross-profile move: read from source profile, write to target, delete from source.
            let dest_key = dest.unwrap_or(source);
            if profile == target_profile && source == dest_key {
                anyhow::bail!("source and destination are identical");
            }
            // Read value + tags from source vault.
            let value = {
                let src_vault = open_vault(profile)?;
                let val = src_vault.get(source).map_err(|e| match &e {
                    SafeError::SecretNotFound { .. } => {
                        anyhow::anyhow!("secret '{source}' not found in profile '{profile}'")
                    }
                    other => anyhow::anyhow!("{other}"),
                })?;
                (*val).clone()
            };
            let tags = {
                let src_vault = open_vault(profile)?;
                src_vault
                    .file()
                    .secrets
                    .get(source)
                    .map(|e| e.tags.clone())
                    .unwrap_or_default()
            };
            // Write to destination vault.
            {
                let mut dst_vault = open_vault(target_profile)?;
                if !force && dst_vault.file().secrets.contains_key(dest_key) {
                    anyhow::bail!(
                        "'{dest_key}' already exists in profile '{target_profile}' — use --force to overwrite"
                    );
                }
                dst_vault.set(dest_key, &value, tags)?;
            }
            // Delete from source vault.
            {
                let mut src_vault = open_vault(profile)?;
                src_vault.delete(source)?;
            }
            println!(
                "{} Moved '{}/{}' → '{}/{}'",
                "".green(),
                profile,
                source,
                target_profile,
                dest_key
            );
        }
    }
    Ok(())
}