oth_rvault 0.4.0

Partial Ansible Vault encoder and decoder
Documentation
use clap::{Args, Parser, Subcommand};
use rvaultlib::{
    clip::clip,
    decrypt::{decrypt, decrypt_fuzzy, decrypt_value_only},
    edit::{edit, edit_fuzzy},
    encrypt::encrypt,
};

/// Partial vault encryption/decryption for JSON and YAML files (Ansible Vault compatible)
#[derive(Parser)]
#[command(name = "rvault", version)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

/// Flags shared between encrypt and decrypt commands
#[derive(Args)]
struct CommonArgs {
    /// Path to the input file
    #[arg(short, long, required = true)]
    input: String,

    /// Output file to create (required unless --overwrite or --stdout is given)
    #[arg(short, long)]
    output: Option<String>,

    /// Vault password
    #[arg(short, long)]
    password: Option<String>,

    /// Keys to process using dot notation for nested keys (e.g. second.a.v).
    /// Can be given multiple times or as comma-separated values.
    /// If omitted, all encryptable values are processed.
    #[arg(short = 'k', long, value_delimiter = ',')]
    key: Vec<String>,

    /// Overwrite the input file with the result instead of writing to a separate output file
    #[arg(long)]
    overwrite: bool,

    /// Print the result to stdout instead of writing to a file
    #[arg(long)]
    stdout: bool,

    /// Path to a file that contains the password
    #[arg(long)]
    pfile: Option<String>,
}

#[derive(Subcommand)]
enum Commands {
    /// Partially encrypt a JSON or YAML file using Ansible Vault AES256
    Encrypt {
        #[command(flatten)]
        common: CommonArgs,

        /// Encrypt values in the style used by Ansible (intermediate file for YAML)
        #[arg(long)]
        ansible: bool,

        /// Encrypt every node at this nesting depth (1 = top-level keys).
        /// Use 0 to encrypt the entire file as a single ansible-vault blob (YAML only),
        /// producing output compatible with `ansible-vault encrypt`.
        /// Nodes in branches shallower than the given level are left untouched.
        /// Cannot be combined with --key.
        #[arg(long)]
        level: Option<u32>,
    },

    /// Decrypt a partially Ansible Vault encoded JSON or YAML file
    Decrypt {
        #[command(flatten)]
        common: CommonArgs,

        /// Output only the specified key(s) as a minimal nested JSON/YAML document.
        /// Requires at least one --key (or --interactive). The output mirrors the input
        /// file format and reconstructs the full nesting from each dot-notation key path.
        #[arg(short = 'v', long)]
        value_only: bool,

        /// Fuzzy-select which encrypted keys to decrypt via an interactive prompt.
        /// Cannot be combined with --key.
        #[arg(long)]
        interactive: bool,

        /// Disable ANSI color output (also honoured via the NO_COLOR env var)
        #[arg(long)]
        no_color: bool,
    },

    /// Interactively edit vault-encrypted values in a JSON or YAML file.
    /// With --key: edit only the specified keys.
    /// Without --key or --interactive: fuzzy-select which keys to edit.
    /// With --interactive: edit all encrypted values without selection.
    Edit {
        #[command(flatten)]
        common: CommonArgs,

        /// Edit all encrypted values without fuzzy selection
        #[arg(short = 'a', long)]
        interactive: bool,
    },

    /// Fuzzy-search encrypted values and copy them to the clipboard.
    /// Selecting a sub-node opens a field picker; Esc goes back or exits.
    Clip {
        /// Path to the input file
        #[arg(short, long, required = true)]
        input: String,

        /// Vault password
        #[arg(short, long)]
        password: Option<String>,

        /// Path to a file that contains the password
        #[arg(long)]
        pfile: Option<String>,

        /// Seconds before the clipboard is automatically cleared (0 = never)
        #[arg(long, default_value = "30")]
        timeout: u64,

        /// Disable ANSI color output (also honoured via the NO_COLOR env var)
        #[arg(long)]
        no_color: bool,
    },
}

/// Resolve the effective output target from the three mutually-exclusive output options.
/// Returns `None` and prints an error if none of the options are provided.
fn resolve_output(
    input: &str,
    output: Option<&str>,
    overwrite: bool,
    stdout: bool,
) -> Option<String> {
    match output {
        Some(o) => Some(o.to_string()),
        None if overwrite => Some(input.to_string()),
        None if stdout => Some("stdout".to_string()),
        None => {
            eprintln!("No output file, --overwrite flag or --stdout given, cancel.");
            None
        }
    }
}

/// Resolves the password to use. Priority order:
/// 1. direct `password` string
/// 2. `pfile` path (file whose contents are the password)
/// 3. `MY_RVAULT_PWD` env var (path to a password file)
fn resolve_vault_password(password: Option<&str>, pfile: Option<&str>) -> Result<String, String> {
    if let Some(pw) = password {
        if !pw.is_empty() {
            return Ok(pw.to_string());
        }
    }

    if let Some(pf) = pfile {
        return std::fs::read_to_string(pf)
            .map(|s| s.trim().to_string())
            .map_err(|e| format!("failed to read password file '{}': {}", pf, e));
    }

    if let Ok(env_path) = std::env::var("MY_RVAULT_PWD") {
        if std::path::Path::new(&env_path).is_file() {
            return std::fs::read_to_string(&env_path)
                .map(|s| s.trim().to_string())
                .map_err(|e| {
                    format!(
                        "failed to read password file from MY_RVAULT_PWD '{}': {}",
                        env_path, e
                    )
                });
        }
        return Err(format!(
            "MY_RVAULT_PWD is set to '{}' but it is not a file",
            env_path
        ));
    }

    Err("no password provided: use --password, --pfile, or set MY_RVAULT_PWD to a password file path".to_string())
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Encrypt {
            common,
            ansible: _,
            level,
        } => {
            if let Some(_lvl) = level {
                if !common.key.is_empty() {
                    eprintln!("error: --level and --key cannot be combined");
                    std::process::exit(1);
                }
            }
            let Some(output) = resolve_output(
                &common.input,
                common.output.as_deref(),
                common.overwrite,
                common.stdout,
            ) else {
                std::process::exit(1);
            };
            let password =
                match resolve_vault_password(common.password.as_deref(), common.pfile.as_deref()) {
                    Ok(pw) => pw,
                    Err(e) => {
                        eprintln!("error: {e}");
                        std::process::exit(1);
                    }
                };
            if let Err(e) = encrypt(&common.input, &output, &password, &common.key, level) {
                eprintln!("error: {e}");
                std::process::exit(1);
            }
        }

        Commands::Decrypt {
            common,
            value_only,
            interactive,
            no_color,
        } => {
            let color = !no_color && std::env::var_os("NO_COLOR").is_none();
            if interactive && !common.key.is_empty() {
                eprintln!("error: --interactive and --key cannot be combined");
                std::process::exit(1);
            }
            if value_only && !interactive && common.key.is_empty() {
                eprintln!("error: --value-only requires at least one --key or --interactive");
                std::process::exit(1);
            }
            let Some(output) = resolve_output(
                &common.input,
                common.output.as_deref(),
                common.overwrite,
                common.stdout,
            ) else {
                std::process::exit(1);
            };
            let password =
                match resolve_vault_password(common.password.as_deref(), common.pfile.as_deref()) {
                    Ok(pw) => pw,
                    Err(e) => {
                        eprintln!("error: {e}");
                        std::process::exit(1);
                    }
                };
            let result = if interactive {
                decrypt_fuzzy(&common.input, &output, &password, value_only, color)
            } else if value_only {
                decrypt_value_only(&common.input, &output, &password, &common.key)
            } else {
                decrypt(&common.input, &output, &password, &common.key)
            };
            if let Err(e) = result {
                eprintln!("error: {e}");
                std::process::exit(1);
            }
        }

        Commands::Edit {
            common,
            interactive,
        } => {
            let Some(output) = resolve_output(
                &common.input,
                common.output.as_deref(),
                common.overwrite,
                common.stdout,
            ) else {
                std::process::exit(1);
            };
            let password =
                match resolve_vault_password(common.password.as_deref(), common.pfile.as_deref()) {
                    Ok(pw) => pw,
                    Err(e) => {
                        eprintln!("error: {e}");
                        std::process::exit(1);
                    }
                };
            let result = if !common.key.is_empty() || interactive {
                edit(&common.input, &output, &password, &common.key)
            } else {
                edit_fuzzy(&common.input, &output, &password)
            };
            if let Err(e) = result {
                eprintln!("error: {e}");
                std::process::exit(1);
            }
        }

        Commands::Clip {
            input,
            password,
            pfile,
            timeout,
            no_color,
        } => {
            let color = !no_color && std::env::var_os("NO_COLOR").is_none();
            let password = match resolve_vault_password(password.as_deref(), pfile.as_deref()) {
                Ok(pw) => pw,
                Err(e) => {
                    eprintln!("error: {e}");
                    std::process::exit(1);
                }
            };
            if let Err(e) = clip(&input, &password, timeout, color) {
                eprintln!("error: {e}");
                std::process::exit(1);
            }
        }
    }
}