tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! Secret generation and shell-completion command handlers.
//!
//! Implements `tsafe gen` and `tsafe completions` — CSPRNG secret generation
//! (random chars or EFF-style passphrase) and shell tab-completion scripts.

use anyhow::{Context, Result};
use clap_complete::generate;
use colored::Colorize;
use tsafe_cli::cli::{Cli, Shell};
use tsafe_core::audit::AuditEntry;

use crate::helpers::*;

/// Estimate entropy in bits for a secret of `length` chars drawn from a pool of `pool_size`.
/// Formula: length * log2(pool_size).
fn entropy_bits(length: usize, pool_size: usize) -> f64 {
    if length == 0 || pool_size == 0 {
        return 0.0;
    }
    (length as f64) * (pool_size as f64).log2()
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_gen(
    profile: &str,
    key: &str,
    length: usize,
    charset: &str,
    words: Option<usize>,
    tags: Vec<String>,
    print: bool,
    exclude_ambiguous: bool,
) -> Result<()> {
    let (value, desc, entropy_note) = if let Some(n) = words {
        if n == 0 {
            anyhow::bail!("--words must be at least 1");
        }
        let passphrase = generate_passphrase(n);
        let note = format!(
            "~{:.0} bits entropy (EFF word list, {n} words)",
            entropy_bits(n, 200)
        );
        (passphrase, format!("{n} words"), note)
    } else {
        if length == 0 {
            anyhow::bail!("--length must be at least 1");
        }
        let value = if exclude_ambiguous {
            tsafe_core::gen::generate_unambiguous(length, charset)
        } else {
            tsafe_core::gen::generate(length, charset)
        };
        let pool_size = tsafe_core::gen::effective_pool_size(charset, exclude_ambiguous);
        let note = format!(
            "~{:.0} bits entropy ({length} chars, pool {pool_size})",
            entropy_bits(length, pool_size)
        );
        let charset_label = if exclude_ambiguous {
            format!("{charset} (no ambiguous)")
        } else {
            charset.to_string()
        };
        (
            value,
            format!("{length} chars, charset={charset_label}"),
            note,
        )
    };
    let tag_map = parse_tags_map(&tags);
    let mut vault = open_vault(profile)?;
    vault
        .set(key, &value, tag_map)
        .context("failed to store generated secret")?;
    audit(profile)
        .append(&AuditEntry::success(profile, "gen", Some(key)))
        .ok();
    if print {
        println!("{value}");
    } else {
        println!(
            "{} Generated '{}' ({}) — {}",
            "".green(),
            key,
            desc,
            entropy_note
        );
    }
    Ok(())
}

/// Generate a passphrase of N random words from a built-in EFF-style word list.
fn generate_passphrase(count: usize) -> String {
    use rand::seq::SliceRandom;
    // EFF short word list (subset — 200 common, unambiguous words).
    const WORDS: &[&str] = &[
        "acid", "acorn", "acre", "acts", "afar", "afoot", "aged", "agile", "aging", "agony",
        "ahead", "aide", "aisle", "ajar", "alarm", "alert", "alias", "alibi", "alien", "align",
        "amber", "amend", "ample", "anchor", "angel", "anger", "angle", "ankle", "annex", "anvil",
        "apart", "apple", "april", "apron", "arena", "argue", "arise", "armor", "arrow", "asset",
        "atlas", "atom", "attic", "audio", "audit", "avoid", "awake", "award", "bacon", "badge",
        "bagel", "baker", "balmy", "banjo", "barge", "baron", "basin", "batch", "beach", "beard",
        "bench", "berry", "birch", "blade", "blame", "blank", "blast", "blaze", "bleed", "blend",
        "blimp", "block", "bloom", "blown", "blurt", "board", "boat", "bonus", "booth", "bound",
        "brace", "brain", "brave", "bread", "break", "brick", "bride", "brief", "bring", "brisk",
        "broad", "broil", "brook", "brunt", "brush", "budge", "buggy", "build", "bulge", "bunch",
        "cabin", "cable", "camel", "candy", "cargo", "carry", "catch", "cause", "cedar", "chain",
        "chair", "chalk", "champ", "charm", "cheap", "check", "chess", "chief", "child", "chill",
        "chips", "chose", "chunk", "civic", "claim", "clamp", "clash", "clasp", "class", "clean",
        "clerk", "click", "cliff", "climb", "cling", "clock", "clone", "cloth", "cloud", "coach",
        "coast", "coral", "couch", "could", "cover", "crack", "craft", "crane", "crash", "crate",
        "crawl", "crazy", "cream", "creek", "crisp", "cross", "crowd", "crush", "cubic", "curve",
        "cycle", "daily", "dance", "darts", "debug", "decay", "decoy", "delta", "dense", "depot",
        "derby", "desk", "digit", "ditch", "dodge", "doing", "donor", "donut", "draft", "drain",
        "drama", "drawn", "dried", "drift", "drill", "drive", "drone", "drove", "dwarf", "dwell",
        "eagle", "earth", "easel", "eight", "elder", "elite", "ember", "empty", "envoy", "equal",
    ];
    let mut rng = rand::thread_rng();
    (0..count)
        .map(|_| *WORDS.choose(&mut rng).unwrap())
        .collect::<Vec<&str>>()
        .join("-")
}

pub(crate) fn cmd_completions(shell: Shell) -> Result<()> {
    use clap::CommandFactory;
    let mut buf: Vec<u8> = Vec::new();
    generate(shell, &mut Cli::command(), "tsafe", &mut buf);
    let script = String::from_utf8_lossy(&buf);
    let patched = patch_completions(shell, &script);
    print!("{patched}");
    Ok(())
}

/// Patch clap-generated completion scripts to add dynamic value lookups for
/// `--profile` (vault names from the data dir) and `--contract` (contract names
/// from the nearest `.tsafe.yml` / `.tsafe.json` manifest).
///
/// The generated scripts use `compgen -f` (bash) or `_files` (zsh) as a
/// fallback for free-form string arguments. We replace those fallbacks with
/// calls to `tsafe _completions-data` so users get real tab-completed values.
fn patch_completions(shell: Shell, script: &str) -> String {
    match shell {
        Shell::Bash => patch_bash(script),
        Shell::Zsh => patch_zsh(script),
        Shell::Fish => patch_fish(script),
        Shell::PowerShell => patch_powershell(script),
        _ => script.to_string(),
    }
}

fn patch_bash(script: &str) -> String {
    // clap_complete emits a block like:
    //   --profile)
    //       COMPREPLY=($(compgen -f "${cur}"))
    //       return 0
    //       ;;
    // We replace the compgen -f call with a call to _completions-data.
    let profile_static = "--profile)\n                    COMPREPLY=($(compgen -f \"${cur}\"))\n                    return 0\n                    ;;";
    let profile_dynamic = "--profile)\n                    COMPREPLY=($(compgen -W \"$(tsafe _completions-data profiles 2>/dev/null)\" -- \"${cur}\"))\n                    return 0\n                    ;;";
    let contract_static = "--contract)\n                    COMPREPLY=($(compgen -f \"${cur}\"))\n                    return 0\n                    ;;";
    let contract_dynamic = "--contract)\n                    COMPREPLY=($(compgen -W \"$(tsafe _completions-data contracts 2>/dev/null)\" -- \"${cur}\"))\n                    return 0\n                    ;;";
    script
        .replace(profile_static, profile_dynamic)
        .replace(contract_static, contract_dynamic)
}

fn patch_zsh(script: &str) -> String {
    // clap_complete zsh emits argument specs like:
    //   '--profile=[...]:profile:_files'
    // We replace _files with a dynamic function.
    // Also append helper functions at the end.
    let patched = script
        .replace(
            "'--profile=[Named vault / profile. Defaults to the persisted default (or '\\''default'\\''). Override with TSAFE_PROFILE env var]:PROFILE:_files'",
            "'--profile=[Named vault / profile]:PROFILE:->tsafe_profiles'",
        )
        .replace(
            "'(-p --profile)'{-p,--profile}'=[Named vault / profile. Defaults to the persisted default (or '\\''default'\\''). Override with TSAFE_PROFILE env var]:PROFILE:_files'",
            "'(-p --profile)'{-p,--profile}'=[Named vault / profile]:PROFILE:->tsafe_profiles'",
        )
        .replace(
            "'--contract=[Load a named authority contract from the nearest .tsafe.yml (or .tsafe.json) manifest. The contract sets the profile, namespace, allowed/required secrets, allowed targets, and trust posture. Explicit flags (--ns, --keys, --mode, etc.) still override contract values.]:NAME:_files'",
            "'--contract=[Named authority contract from nearest .tsafe.yml]:NAME:->tsafe_contracts'",
        );

    // Append state handler for dynamic values
    format!(
        "{patched}

# tsafe dynamic completion handlers
_tsafe_dynamic() {{
  case $state in
    tsafe_profiles)
      local profiles
      profiles=($(tsafe _completions-data profiles 2>/dev/null))
      _describe 'profile' profiles
      ;;
    tsafe_contracts)
      local contracts
      contracts=($(tsafe _completions-data contracts 2>/dev/null))
      _describe 'contract' contracts
      ;;
  esac
}}
"
    )
}

fn patch_powershell(script: &str) -> String {
    // Inject a dynamic value pre-check into the clap-generated Register-ArgumentCompleter block.
    // The injection fires before the static switch, so --profile and --contract return live values
    // and the static completions (subcommand/flag names) are returned for everything else.
    //
    // Windows compat notes:
    //   - Normalise \r\n → \n before searching so the needle matches on both platforms.
    //   - Use .Trim() on each split item to strip stray \r from tsafe output on Windows.
    //   - Split on "`n" (PowerShell LF) — works for both \n and \r\n output after Trim().
    let injection = r#"
    # Dynamic value completion for --profile and --contract
    $prevToken = ''
    for ($i = 1; $i -lt $commandElements.Count; $i++) {
        if ($commandElements[$i].Value -eq $wordToComplete -and $i -gt 0) {
            $prevToken = $commandElements[$i - 1].Value
            break
        }
    }
    if ($prevToken -in @('--profile', '-p')) {
        $profiles = (tsafe _completions-data profiles 2>$null) -split "`n" |
            ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
        foreach ($p in $profiles) {
            if ($p -like "$wordToComplete*") {
                [CompletionResult]::new($p, $p, [CompletionResultType]::ParameterValue, "Profile: $p")
            }
        }
        return
    }
    if ($prevToken -eq '--contract') {
        $contracts = (tsafe _completions-data contracts 2>$null) -split "`n" |
            ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
        foreach ($c in $contracts) {
            if ($c -like "$wordToComplete*") {
                [CompletionResult]::new($c, $c, [CompletionResultType]::ParameterValue, "Contract: $c")
            }
        }
        return
    }
"#;
    // Normalise CRLF so the needle matches regardless of platform line endings.
    let normalised = script.replace("\r\n", "\n");
    normalised.replacen(
        "\n    $completions = @(switch ($command) {",
        &format!("\n{injection}\n    $completions = @(switch ($command) {{"),
        1,
    )
}

fn patch_fish(script: &str) -> String {
    // Append dynamic completions for --profile and --contract.
    // Fish uses `complete -c tsafe -l profile -a (...)` syntax.
    // We append override completions that suppress the default and provide dynamic values.
    format!(
        "{script}
# Dynamic completions for --profile and --contract
complete -c tsafe -l profile -f -a '(tsafe _completions-data profiles 2>/dev/null)' -d 'Vault profile name'
complete -c tsafe -n '__fish_seen_subcommand_from exec' -l contract -f -a '(tsafe _completions-data contracts 2>/dev/null)' -d 'Authority contract name'
"
    )
}

/// Output completion candidates for use by shell completion scripts.
/// This is a hidden internal command called by the patched completion scripts.
pub(crate) fn cmd_completions_data(data_type: &str) -> Result<()> {
    match data_type {
        "profiles" => {
            let profiles = tsafe_core::profile::list_profiles().unwrap_or_default();
            for p in profiles {
                println!("{p}");
            }
        }
        "contracts" => {
            let cwd = std::env::current_dir().unwrap_or_default();
            if let Some(path) = tsafe_core::contracts::find_contracts_manifest(&cwd) {
                if let Ok(contracts) = tsafe_core::contracts::load_contracts(&path) {
                    for name in contracts.keys() {
                        println!("{name}");
                    }
                }
            }
            // No manifest → no output (not an error; completions just produce nothing)
        }
        _ => {} // Unknown type → no output
    }
    Ok(())
}