revault_cli 0.0.2

CLI for reVault encrypted lockboxes, store files, variables and forms with integration to your platform key storage
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

use clap::ArgMatches;
use revault_lockbox_api::Error;
use revault_vault_api::{
    get_platform_vault_password, list as list_open_lockboxes, local_vault,
    platform_secret_store_status, put_platform_vault_password, set_auto_open_scope,
    stop as stop_agent, verify_agent_transport_security, AutoOpenScope,
};

use super::context::{
    ensure_lockbox_path_accessible, open_default_vault_with_password, read_vault_password,
    CliResult,
};
use super::output::{output_format_from_matches, print_records};

pub(crate) fn run_matches(matches: &ArgMatches) -> CliResult<()> {
    match matches.subcommand() {
        Some(("default", sub)) => default_lockbox_matches(sub),
        Some(("close-all", _)) => {
            local_vault().close_all()?;
            clear_default_lockbox()?;
            println!("All lockbox sessions closed.");
            Ok(())
        }
        Some(("stop", _)) => {
            stop_agent()?;
            clear_default_lockbox()?;
            println!("Session agent stopped.");
            Ok(())
        }
        Some(("auto-open", sub)) => auto_open_matches(sub),
        Some((command, _)) => {
            Err(Error::InvalidInput(format!("unknown session command: {command}")).into())
        }
        None => list_sessions(output_format_from_matches(matches)?),
    }
}

fn default_lockbox_matches(matches: &ArgMatches) -> CliResult<()> {
    if matches.get_flag("clear") {
        clear_default_lockbox()?;
        println!("Default lockbox cleared.");
        return Ok(());
    }
    let lockbox_path = matches
        .get_one::<String>("lockbox")
        .ok_or_else(|| Error::InvalidInput("missing lockbox".to_string()))?;
    set_default_lockbox(lockbox_path)
}

fn set_default_lockbox(lockbox_path: &str) -> CliResult<()> {
    ensure_lockbox_path_accessible(lockbox_path)?;
    let lockbox_path = fs::canonicalize(lockbox_path)?;
    let lockbox_path = lockbox_path.to_string_lossy().into_owned();
    write_default_lockbox(&lockbox_path)?;
    println!("Default lockbox: {lockbox_path}");
    Ok(())
}

fn list_sessions(format: super::output::OutputFormat) -> CliResult<()> {
    let agent_enabled = agent_enabled();
    let agent_running = revault_vault_api::is_running();
    let auto_open = platform_secret_store_status()?;
    let vault_pass_phrase_stored = platform_vault_pass_phrase_stored();
    if !matches!(format, super::output::OutputFormat::Table) {
        let default = default_lockbox_path_value()?;
        let mut rows = Vec::new();
        rows.push(vec![
            "agent".to_string(),
            "enabled".to_string(),
            yes_no(agent_enabled).to_string(),
            String::new(),
            String::new(),
        ]);
        rows.push(vec![
            "agent".to_string(),
            "running".to_string(),
            yes_no(agent_running).to_string(),
            String::new(),
            String::new(),
        ]);
        rows.push(vec![
            "auto-open".to_string(),
            "scope".to_string(),
            auto_open.scope.as_str().to_string(),
            String::new(),
            String::new(),
        ]);
        rows.push(vec![
            "auto-open".to_string(),
            "vault pass phrase stored".to_string(),
            yes_no(vault_pass_phrase_stored).to_string(),
            String::new(),
            String::new(),
        ]);
        rows.push(vec![
            "lockbox".to_string(),
            "default".to_string(),
            if default.is_some() { "yes" } else { "no" }.to_string(),
            default.clone().unwrap_or_default(),
            String::new(),
        ]);
        for lockbox in list_open_lockboxes()? {
            let path = lockbox.path.unwrap_or_default();
            rows.push(vec![
                "lockbox".to_string(),
                "open".to_string(),
                if default.as_deref() == Some(path.as_str()) {
                    "yes".to_string()
                } else {
                    "no".to_string()
                },
                path,
                lockbox.id,
            ]);
        }
        print_records(&["kind", "state", "value", "path", "uuid"], rows, format)?;
        return Ok(());
    }

    println!("Session agent:");
    println!("  enabled: {}", yes_no(agent_enabled));
    println!("  running: {}", yes_no(agent_running));
    println!();
    println!("Auto-open:");
    println!("  scope: {}", auto_open.scope.as_str());
    println!(
        "  vault pass phrase stored: {}",
        yes_no(vault_pass_phrase_stored)
    );
    println!();
    println!("Default lockbox:");
    match default_lockbox_path_value()? {
        Some(path) => println!("  {path}"),
        None => println!("  none"),
    }
    println!();
    println!("Open lockboxes:");
    let open = list_open_lockboxes()?;
    if open.is_empty() {
        println!("  none");
    } else {
        for lockbox in open {
            println!("  {}", lockbox.path.unwrap_or(lockbox.id));
        }
    }
    Ok(())
}

fn auto_open_matches(matches: &ArgMatches) -> CliResult<()> {
    match matches.subcommand() {
        Some(("status", sub)) => auto_open_status(output_format_from_matches(sub)?),
        Some(("disable", sub)) => {
            if !confirm_auto_open_disable(sub.get_flag("yes"))? {
                println!("Auto-open not disabled.");
                return Ok(());
            }
            set_auto_open_scope(AutoOpenScope::Off)?;
            local_vault().close_all()?;
            clear_default_lockbox()?;
            auto_open_status(super::output::OutputFormat::Table)
        }
        Some(("vault", _)) => {
            let password = read_vault_password("Vault pass phrase: ")?;
            open_default_vault_with_password(&password)?;
            set_auto_open_scope(AutoOpenScope::Vault)?;
            put_platform_vault_password(&password)?;
            local_vault().close_all()?;
            auto_open_status(super::output::OutputFormat::Table)
        }
        Some(("lockboxes", _)) => {
            let password = read_vault_password("Vault pass phrase: ")?;
            open_default_vault_with_password(&password)?;
            set_auto_open_scope(AutoOpenScope::Lockboxes)?;
            put_platform_vault_password(&password)?;
            local_vault().close_all()?;
            auto_open_status(super::output::OutputFormat::Table)
        }
        Some((command, _)) => {
            Err(Error::InvalidInput(format!("unknown session auto-open command: {command}")).into())
        }
        None => auto_open_status(super::output::OutputFormat::Table),
    }
}

fn confirm_auto_open_disable(yes: bool) -> CliResult<bool> {
    if yes {
        return Ok(true);
    }

    eprintln!("Disable auto-open?");
    eprintln!("The stored vault pass phrase will be removed from the OS key store.");
    eprintln!("All open lockbox sessions will be closed.");
    eprint!("Type 'yes' to disable auto-open: ");
    io::stderr().flush()?;
    let mut answer = String::new();
    io::stdin().read_line(&mut answer)?;
    Ok(answer.trim() == "yes")
}

fn auto_open_status(format: super::output::OutputFormat) -> CliResult<()> {
    let status = platform_secret_store_status()?;
    let stored = platform_vault_pass_phrase_stored();
    print_records(
        &["property", "value"],
        vec![
            vec![
                "supported".to_string(),
                yes_no(status.supported).to_string(),
            ],
            vec!["scope".to_string(), status.scope.as_str().to_string()],
            vec![
                "vault pass phrase stored".to_string(),
                yes_no(stored).to_string(),
            ],
            vec!["backend".to_string(), status.backend.to_string()],
            vec!["vault".to_string(), status.item],
        ],
        format,
    )?;
    Ok(())
}

fn agent_enabled() -> bool {
    verify_agent_transport_security().is_ok()
}

fn platform_vault_pass_phrase_stored() -> bool {
    get_platform_vault_password()
        .map(|password| password.is_some())
        .unwrap_or(false)
}

fn default_lockbox_path_value() -> CliResult<Option<String>> {
    let path = default_lockbox_path()?;
    match fs::read_to_string(path) {
        Ok(value) => Ok(Some(value.trim_end_matches('\n').to_string())),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(err) => Err(err.into()),
    }
}

pub(crate) fn clear_default_lockbox() -> CliResult<()> {
    match fs::remove_file(default_lockbox_path()?) {
        Ok(()) => {}
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
        Err(err) => return Err(err.into()),
    }
    Ok(())
}

fn write_default_lockbox(lockbox_path: &str) -> CliResult<()> {
    let path = default_lockbox_path()?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, format!("{lockbox_path}\n"))?;
    Ok(())
}

fn default_lockbox_path() -> CliResult<PathBuf> {
    Ok(revault_vault_api::default_vault_dir()?.join(".default-lockbox"))
}

pub(crate) fn default_lockbox_or_none() -> CliResult<Option<String>> {
    default_lockbox_path_value()
}

pub(crate) fn clear_default_if_matches(path: &str) -> CliResult<()> {
    let Some(default) = default_lockbox_path_value()? else {
        return Ok(());
    };
    if default == path || canonical_path_matches(&default, path) {
        clear_default_lockbox()?;
    }
    Ok(())
}

pub(crate) fn default_matches(path: &str) -> CliResult<bool> {
    Ok(default_lockbox_path_value()?
        .is_some_and(|default| default == path || canonical_path_matches(&default, path)))
}

pub(crate) fn replace_default_after_move(path: &Path) -> CliResult<()> {
    let canonical = fs::canonicalize(path)?;
    write_default_lockbox(&canonical.to_string_lossy())
}

fn canonical_path_matches(active: &str, path: &str) -> bool {
    fs::canonicalize(path)
        .map(|path| path == Path::new(active))
        .unwrap_or(false)
}

fn yes_no(value: bool) -> &'static str {
    if value {
        "yes"
    } else {
        "no"
    }
}