revault_cli 0.0.3

CLI for reVault encrypted lockboxes, store files, variables and forms with integration to your platform key storage
use super::context::{cli_error, default_vault, Access, CliResult};
use super::optional_lockbox_value;
use super::output::{output_format_from_matches, print_records, OutputFormat};
use clap::ArgMatches;
use revault_lockbox_api::vault_integration::VaultOpen;
use revault_lockbox_api::{Error, RecoveryReport, RecoveryScanner, SecretVec};
use revault_vault_api::{get as get_cached_content_key, VaultDirectory};
use std::fs;
use std::path::{Path, PathBuf};

pub(crate) fn run_matches(matches: &ArgMatches, access: &Access) -> CliResult<()> {
    let options = RecoverOptions::from_matches(matches)?;
    run_options(options, access)
}

fn run_options(options: RecoverOptions, access: &Access) -> CliResult<()> {
    if options.dry_run {
        let report = scan_report(&options.lockbox_path, access)?;
        print_report(&report, options.format)?;
        return Ok(());
    }

    let output = options
        .output
        .clone()
        .unwrap_or_else(|| default_recovered_path(&options.lockbox_path));
    let output_path = Path::new(&output);
    let input_path = Path::new(&options.lockbox_path);
    let in_place = same_existing_path(input_path, output_path);
    if output_path.exists() && !options.overwrite {
        return Err(Error::AlreadyExists(output).into());
    }
    let bytes = fs::read(&options.lockbox_path)
        .map_err(|err| Error::Io(format!("read lockbox {}: {err}", options.lockbox_path)))?;
    let recovered = salvage_bytes(&options.lockbox_path, bytes, access)?;
    let damaged_original = if in_place {
        let backup = next_damaged_backup_path(input_path);
        fs::rename(input_path, &backup).map_err(|err| {
            Error::Io(format!(
                "move damaged lockbox {} to {}: {err}",
                options.lockbox_path,
                backup.display()
            ))
        })?;
        Some(backup)
    } else {
        None
    };
    fs::write(&output, recovered.try_to_bytes()?)
        .map_err(|err| Error::Io(format!("write recovered lockbox {output}: {err}")))?;
    let report = scan_report(&output, access)?;
    let rows = report_rows(&report, Some(&output), damaged_original.as_deref());
    print_records(&["field", "value"], rows, options.format)?;
    Ok(())
}

struct RecoverOptions {
    lockbox_path: String,
    output: Option<String>,
    overwrite: bool,
    dry_run: bool,
    format: OutputFormat,
}

impl RecoverOptions {
    fn from_matches(matches: &ArgMatches) -> CliResult<Self> {
        let output = matches.get_one::<String>("output").cloned();
        let overwrite = matches.get_flag("overwrite");
        let dry_run = matches.get_flag("dry-run");
        if dry_run && output.is_some() {
            return Err(
                Error::InvalidInput("--dry-run cannot be used with --output".to_string()).into(),
            );
        }
        if dry_run && overwrite {
            return Err(Error::InvalidInput(
                "--dry-run cannot be used with --overwrite".to_string(),
            )
            .into());
        }
        Ok(Self {
            lockbox_path: optional_lockbox_value(matches, "lockbox")?,
            output,
            overwrite,
            dry_run,
            format: output_format_from_matches(matches)?,
        })
    }
}

fn scan_report(lockbox_path: &str, access: &Access) -> CliResult<RecoveryReport> {
    match access {
        Access::ContentKey(key) => {
            let bytes = fs::read(lockbox_path)
                .map_err(|err| Error::Io(format!("read lockbox {lockbox_path}: {err}")))?;
            scan_bytes_with_secret_key(bytes, key)
        }
        Access::CacheOnly => {
            let key = cached_key(lockbox_path)?;
            let bytes = fs::read(lockbox_path)
                .map_err(|err| Error::Io(format!("read lockbox {lockbox_path}: {err}")))?;
            scan_bytes_with_secret_key(bytes, &key)
        }
        Access::PromptPassword => {
            Err(Error::InvalidInput("recover requires --key or an open lockbox".to_string()).into())
        }
    }
}

fn scan_bytes_with_secret_key(bytes: Vec<u8>, key: &SecretVec) -> CliResult<RecoveryReport> {
    let mut key_bytes = key.with_bytes(|key| key.to_vec())?;
    let report = RecoveryScanner::scan_bytes(bytes, &key_bytes);
    key_bytes.fill(0);
    Ok(report)
}

fn salvage_bytes(
    lockbox_path: &str,
    bytes: Vec<u8>,
    access: &Access,
) -> CliResult<revault_lockbox_api::Lockbox> {
    let signing_key = default_vault()?.load_owner_signing_key(VaultDirectory::DEFAULT_KEY_NAME)?;
    match access {
        Access::ContentKey(key) => Ok(RecoveryScanner::salvage_bytes_with_secret_key(
            bytes,
            key,
            &signing_key,
        )?),
        Access::CacheOnly => {
            let key = cached_key(lockbox_path)?;
            Ok(RecoveryScanner::salvage_bytes_with_secret_key(
                bytes,
                &key,
                &signing_key,
            )?)
        }
        Access::PromptPassword => {
            Err(Error::InvalidInput("recover requires --key or an open lockbox".to_string()).into())
        }
    }
}

fn cached_key(lockbox_path: &str) -> CliResult<SecretVec> {
    let lockbox_id = VaultOpen::read_lockbox_id(Path::new(lockbox_path)).map_err(|_| {
        cli_error(format!(
            "cannot read lockbox id from {lockbox_path}; run recover with --key for badly damaged headers"
        ))
    })?;
    get_cached_content_key(lockbox_id)?.ok_or_else(|| {
        cli_error(format!(
            "lockbox is closed: {lockbox_path}. Run `lockbox open {lockbox_path}` first or pass --key."
        ))
    })
}

fn print_report(report: &RecoveryReport, format: OutputFormat) -> CliResult<()> {
    print_records(&["field", "value"], report_rows(report, None, None), format)
}

fn report_rows(
    report: &RecoveryReport,
    output: Option<&str>,
    damaged_original: Option<&Path>,
) -> Vec<Vec<String>> {
    let mut rows = vec![
        vec![
            "intact_file_count".to_string(),
            report.intact_file_count.to_string(),
        ],
        vec![
            "partial_files".to_string(),
            report.partial_files.to_string(),
        ],
        vec![
            "corrupt_records".to_string(),
            report.corrupt_records.to_string(),
        ],
        vec![
            "toc_recovered".to_string(),
            report.toc_recovered.to_string(),
        ],
        vec![
            "variables_recovered".to_string(),
            report.variables_recovered.to_string(),
        ],
        vec![
            "variable_count".to_string(),
            report.variable_count.to_string(),
        ],
        vec![
            "forms_recovered".to_string(),
            report.forms_recovered.to_string(),
        ],
        vec![
            "form_definition_count".to_string(),
            report.form_definition_count.to_string(),
        ],
        vec![
            "form_record_count".to_string(),
            report.form_record_count.to_string(),
        ],
    ];
    if let Some(output) = output {
        rows.push(vec!["output".to_string(), output.to_string()]);
    }
    if let Some(damaged_original) = damaged_original {
        rows.push(vec![
            "damaged_original".to_string(),
            damaged_original.display().to_string(),
        ]);
    }
    rows
}

fn default_recovered_path(lockbox_path: &str) -> String {
    let path = Path::new(lockbox_path);
    let parent = path.parent().unwrap_or_else(|| Path::new(""));
    let stem = path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .filter(|stem| !stem.is_empty())
        .unwrap_or("lockbox");
    parent
        .join(format!("{stem}.recovered.lbox"))
        .display()
        .to_string()
}

fn same_existing_path(left: &Path, right: &Path) -> bool {
    match (fs::canonicalize(left), fs::canonicalize(right)) {
        (Ok(left), Ok(right)) => left == right,
        _ => left == right,
    }
}

fn next_damaged_backup_path(input_path: &Path) -> PathBuf {
    let parent = input_path.parent().unwrap_or_else(|| Path::new(""));
    let file_name = input_path
        .file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .unwrap_or("lockbox.lbox");
    let mut candidate = parent.join(format!("{file_name}.damaged"));
    let mut index = 1usize;
    while candidate.exists() {
        candidate = parent.join(format!("{file_name}.damaged.{index}"));
        index += 1;
    }
    candidate
}