tsafe-cli 1.0.26

Secrets runtime for developers — inject credentials into processes via exec, never into shell history or .env files
//! Core vault CRUD command handlers.
//!
//! Implements `tsafe init`, `set`, `get`, `delete`, `list`, and `export` —
//! the fundamental secret lifecycle operations every vault user needs daily.

use std::collections::HashMap;
use std::io::{self, BufRead, IsTerminal, Write};

use anyhow::{Context, Result};
use colored::Colorize;
use crate::cli::ExportFormat;
use tsafe_core::{
    audit::AuditEntry, env as tsenv, errors::SafeError, events::emit_event, profile, vault::Vault,
};

use crate::helpers::*;

pub(crate) fn cmd_init(profile: &str, profile_explicit: bool) -> Result<()> {
    // If -p was not given explicitly and we are on a TTY, ask what to name the vault.
    let resolved: String = if !profile_explicit {
        if io::stdin().is_terminal() {
            eprint!("Vault name [{profile}]: ");
            io::stderr().flush().ok();
            let mut input = String::new();
            io::stdin().lock().read_line(&mut input)?;
            let trimmed = input.trim().to_owned();
            if trimmed.is_empty() {
                profile.to_owned()
            } else {
                trimmed
            }
        } else {
            profile.to_owned()
        }
    } else {
        profile.to_owned()
    };
    let profile = resolved.as_str();
    profile::validate_profile_name(profile)?;
    if profile::profile_exists(profile) {
        anyhow::bail!(
            "vault already exists for profile '{profile}'\n\
             \n  To use a different profile name: tsafe --profile <name> init\
             \n  To see existing profiles:        tsafe profile list"
        );
    }
    let password = prompt_password_confirmed()?;
    let v = Vault::create(&profile::vault_path(profile), password.as_bytes())
        .context("failed to create vault")?;
    audit(profile)
        .append(&AuditEntry::success(profile, "init", None))
        .ok();
    emit_event(profile, "init", None);
    println!("{} Vault ready: {}", "".green(), v.path().display());
    backup_new_profile_password_if_configured(profile, &password)?;
    // Onboarding: OS quick unlock, then optional .env import.
    onboarding_biometric_unlock(profile, &password)?;
    onboarding_import_dotenv(profile, &password)?;
    Ok(())
}

pub(crate) fn cmd_set(
    profile: &str,
    key: &str,
    value: Option<String>,
    tags: Vec<String>,
    overwrite: bool,
) -> Result<()> {
    // Validate key at the CLI layer for a clean error before opening the vault.
    if key.is_empty() || key.trim().is_empty() {
        anyhow::bail!("key name must not be empty\n\n  Usage: tsafe set KEY value\n  Keys must start with a letter or underscore.");
    }
    tsafe_core::vault::validate_secret_key(key)
        .map_err(|e| anyhow::anyhow!("{e}"))?;

    let val = match value {
        Some(v) => {
            eprintln!(
                "{} Secret value passed as a command-line argument — \
                 it may appear in shell history and process listings. \
                 Omit the value to read it securely from stdin.",
                "warn:".yellow().bold()
            );
            v
        }
        None => {
            if io::stdin().is_terminal() {
                use inquire::{Password, PasswordDisplayMode};
                Password::new(&format!("Value for '{key}'"))
                    .with_display_mode(PasswordDisplayMode::Masked)
                    .without_confirmation()
                    .prompt()
                    .with_context(|| format!("failed to read secret value for '{key}'"))?
            } else {
                eprint!("Value for '{key}' (piped / non-interactive): ");
                io::stderr().flush().ok();
                let mut line = String::new();
                io::stdin().lock().read_line(&mut line)?;
                line.trim_end_matches(['\n', '\r']).to_string()
            }
        }
    };
    let tag_map: HashMap<String, String> = tags
        .iter()
        .filter_map(|t| {
            let mut p = t.splitn(2, '=');
            Some((p.next()?.to_string(), p.next()?.to_string()))
        })
        .collect();
    let mut vault = open_vault(profile)?;
    if vault.list().contains(&key) && !overwrite {
        if io::stdin().is_terminal() {
            eprint!(
                "{} '{}' already exists. Overwrite? [y/N]: ",
                "warn:".yellow().bold(),
                key
            );
            io::stderr().flush().ok();
            let mut resp = String::new();
            io::stdin().lock().read_line(&mut resp)?;
            if !resp.trim().eq_ignore_ascii_case("y") {
                println!("Aborted.");
                return Ok(());
            }
        } else {
            anyhow::bail!("key '{key}' already exists — use --overwrite to replace");
        }
    }
    vault
        .set(key, &val, tag_map)
        .context("failed to set secret")?;
    audit(profile)
        .append(&AuditEntry::success(profile, "set", Some(key)))
        .ok();
    emit_event(profile, "set", Some(key));
    println!("{} Set '{key}'", "".green());
    Ok(())
}

pub(crate) fn cmd_get(profile: &str, key: &str, copy: bool, version: Option<usize>) -> Result<()> {
    let vault = open_vault(profile)?;
    let value = if let Some(v) = version {
        vault.get_version(key, v).map_err(|e| match &e {
            SafeError::SecretNotFound { .. } => anyhow::anyhow!(
                "secret '{key}' not found\n\
                 \n  See all keys:        tsafe list\
                 \n  Add a new secret:    tsafe set {key} <value>"
            ),
            other => anyhow::anyhow!("{other}"),
        })?
    } else {
        let raw = vault.get(key).map_err(|e| match &e {
            SafeError::SecretNotFound { .. } => anyhow::anyhow!(
                "secret '{key}' not found\n\
                 \n  See all keys:        tsafe list\
                 \n  Add a new secret:    tsafe set {key} <value>"
            ),
            other => anyhow::anyhow!("{other}"),
        })?;
        // Resolve aliases (1 level deep).
        if let Some(real_key) = raw.strip_prefix("@alias:") {
            vault
                .get(real_key)
                .with_context(|| format!("alias target '{real_key}' not found"))?
        } else {
            raw
        }
    };
    if let Some(entry) = vault.file().secrets.get(key) {
        warn_if_expired(key, &entry.tags);
    }
    audit(profile)
        .append(&AuditEntry::success(profile, "get", Some(key)))
        .ok();
    if copy {
        set_clipboard(&value)?;
        println!(
            "{} '{}' copied to clipboard — clears in 30 s",
            "".green(),
            key
        );
    } else {
        print!("{}", &*value);
    }
    Ok(())
}

pub(crate) fn cmd_delete(profile: &str, key: &str) -> Result<()> {
    let mut vault = open_vault(profile)?;
    vault.delete(key).map_err(|e| match &e {
        SafeError::SecretNotFound { .. } => anyhow::anyhow!(
            "secret '{key}' not found\n\
             \n  See all keys: tsafe list"
        ),
        other => anyhow::anyhow!("{other}"),
    })?;
    audit(profile)
        .append(&AuditEntry::success(profile, "delete", Some(key)))
        .ok();
    emit_event(profile, "delete", Some(key));
    println!("{} Deleted '{key}'", "".green());
    Ok(())
}

pub(crate) fn cmd_list(profile: &str, tag_filters: &[String], ns: Option<&str>) -> Result<()> {
    let vault = open_vault(profile)?;
    let parsed = parse_tag_filters(tag_filters);
    let ns_prefix = ns.map(|n| format!("{n}/"));
    let keys: Vec<String> = vault
        .list()
        .into_iter()
        .filter(|k| {
            // Namespace filter: only keys with the right prefix.
            if let Some(ref pfx) = ns_prefix {
                if !k.starts_with(pfx.as_str()) {
                    return false;
                }
            }
            // Tag filter.
            parsed.iter().all(|(fk, fv)| {
                vault
                    .file()
                    .secrets
                    .get(*k)
                    .map(|e| e.tags.get(*fk).map(|v| v == fv).unwrap_or(false))
                    .unwrap_or(false)
            })
        })
        // Strip namespace prefix from display.
        .map(|k| match &ns_prefix {
            Some(pfx) => k.strip_prefix(pfx.as_str()).unwrap_or(k).to_string(),
            None => k.to_string(),
        })
        .collect();
    if keys.is_empty() {
        let ctx = match (ns, parsed.is_empty()) {
            (Some(n), true) => format!(" in namespace '{n}' (profile '{profile}')"),
            (Some(n), false) => {
                format!(" matching filter in namespace '{n}' (profile '{profile}')")
            }
            (None, true) => format!(" in profile '{profile}'"),
            (None, false) => format!(" matching tag filter in profile '{profile}'"),
        };
        println!("{} No secrets{ctx}", "i".blue());
    } else {
        keys.iter().for_each(|k| println!("{k}"));
    }
    Ok(())
}

pub(crate) fn cmd_export(
    profile: &str,
    format: ExportFormat,
    filter: Vec<String>,
    tag_filters: Vec<String>,
    ns: Option<&str>,
) -> Result<()> {
    let vault = open_vault(profile)?;
    let ns_prefix = ns.map(|n| format!("{n}/"));
    let mut all = vault.export_all().context("failed to decrypt secrets")?;
    // Namespace filter — keep only keys with the prefix (full stored key lookup).
    if let Some(ref pfx) = ns_prefix {
        all.retain(|k, _| k.starts_with(pfx.as_str()));
    }
    // Key filter (matches against bare name OR full ns/name).
    if !filter.is_empty() {
        all.retain(|k, _| {
            filter
                .iter()
                .any(|f| k == f || k.ends_with(&format!("/{f}")))
        });
    }
    // Tag filter — vault lookup uses the full stored key.
    let parsed = parse_tag_filters(&tag_filters);
    if !parsed.is_empty() {
        all.retain(|k, _| {
            parsed.iter().all(|(fk, fv)| {
                vault
                    .file()
                    .secrets
                    .get(k)
                    .map(|e| e.tags.get(*fk).map(|v| v == fv).unwrap_or(false))
                    .unwrap_or(false)
            })
        });
    }
    // Strip namespace prefix after all filtering so output has bare key names.
    if let Some(ref pfx) = ns_prefix {
        all = all
            .into_iter()
            .map(|(k, v)| (k.strip_prefix(pfx.as_str()).unwrap_or(&k).to_string(), v))
            .collect();
    }
    audit(profile)
        .append(&AuditEntry::success(profile, "export", None))
        .ok();
    let out = match format {
        ExportFormat::Env => tsenv::format_env(&all),
        ExportFormat::Dotenv => tsenv::format_dotenv(&all),
        ExportFormat::Powershell => tsenv::format_powershell(&all),
        ExportFormat::Json => tsenv::format_json(&all)?,
        ExportFormat::GithubActions => tsenv::format_github_actions(&all),
        ExportFormat::Yaml => tsenv::format_yaml(&all)?,
        ExportFormat::DockerEnv => tsenv::format_docker_env(&all),
        ExportFormat::Toml => {
            let pairs: Vec<(String, String)> = {
                let mut v: Vec<(String, String)> = all.into_iter().collect();
                v.sort_by(|(a, _), (b, _)| a.cmp(b));
                v
            };
            tsenv::format_toml(&pairs)
        }
    };
    println!("{out}");
    Ok(())
}