revault_cli 0.0.6

CLI for reVault encrypted lockboxes, store files, variables and forms with integration to your platform key storage
mod common;

use common::TestTempDir;
use revault_lockbox_api::{Lockbox, LOCKBOX_FORMAT_VERSION};
use revault_lockbox_api_v1::{ContactKeyPair as V1ContactKeyPair, SecretString as V1SecretString};
use revault_vault_api::VaultDirectory;
use revault_vault_api_v1::VaultDirectory as V1VaultDirectory;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};

const VAULT_PASSWORD: &str = "migration vault password";
const LOCKBOX_PASSWORD: &str = "migration lockbox password";
const ARTIFACT_PASSWORD: &str = "migration artifact password";

#[test]
fn vault_migration_commands_and_options_execute_end_to_end() {
    let fixture = Fixture::new("migration-vault-e2e");
    fixture.init_current_vault();

    let direct_output = fixture.root.join("direct-vault");
    fixture.success(&[
        "migrate",
        "vault",
        "--output",
        path(&direct_output),
        "--exporter",
        "unused-for-current-format",
    ]);
    assert_current_vault(&direct_output);

    let conflict = fixture.run(&[
        "migrate",
        "vault",
        "--replace",
        "--output",
        path(&fixture.root.join("conflict")),
    ]);
    assert_failure_contains(&conflict, "cannot be used with");

    let export = fixture.root.join("vault-export.migration");
    let exported = fixture.run_with_stdin(
        &[
            "migrate",
            "vault",
            "export",
            "-o",
            path(&export),
            "--vault-password-stdin",
            "--migration-password-stdin",
        ],
        &format!("{VAULT_PASSWORD}\n{ARTIFACT_PASSWORD}\n"),
        true,
    );
    assert_success(&exported);
    assert!(export.is_file());

    fixture.success(&["migrate", "vault", "verify", path(&export)]);
    let upgraded = fixture.root.join("vault-upgraded.migration");
    fixture.success(&[
        "migrate",
        "vault",
        "upgrade",
        path(&export),
        "--output",
        path(&upgraded),
    ]);
    fixture.success(&["migrate", "vault", "verify", path(&upgraded)]);

    let imported = fixture.root.join("vault-imported");
    fixture.success(&[
        "migrate",
        "vault",
        "import",
        path(&upgraded),
        "--output",
        path(&imported),
    ]);
    assert_current_vault(&imported);

    fixture.success(&["migrate", "vault", "--replace"]);
    assert_current_vault(&fixture.vault);
    assert_current_vault(&fixture.root.join("vault.v2.pre-migration"));
}

#[test]
fn archive_migration_commands_and_options_execute_end_to_end() {
    let fixture = Fixture::new("migration-archive-e2e");
    fixture.init_current_vault();
    let advanced_source = fixture.create_archive("advanced.lbox");

    let artifact = fixture.root.join("archive-export.migration");
    let exported = fixture.run_with_stdin(
        &[
            "migrate",
            "archive",
            "export",
            path(&advanced_source),
            "-o",
            path(&artifact),
            "--migration-password-stdin",
        ],
        &format!("{ARTIFACT_PASSWORD}\n"),
        false,
    );
    assert_success(&exported);
    fixture.success(&["migrate", "archive", "verify", path(&artifact)]);

    let upgraded = fixture.root.join("archive-upgraded.migration");
    fixture.success(&[
        "migrate",
        "archive",
        "upgrade",
        path(&artifact),
        "--output",
        path(&upgraded),
    ]);
    fixture.success(&["migrate", "archive", "verify", path(&upgraded)]);

    let imported = fixture.root.join("archive-imported.lbox");
    fixture.success(&[
        "migrate",
        "archive",
        "import",
        path(&upgraded),
        "--output",
        path(&imported),
    ]);
    Lockbox::inspect_file(&imported).unwrap();

    let direct_source = fixture.create_archive("direct.lbox");
    let direct_output = fixture.root.join("direct-migrated.lbox");
    fixture.success(&[
        "migrate",
        "archive",
        path(&direct_source),
        "--output",
        path(&direct_output),
        "--exporter",
        "unused-for-current-format",
    ]);
    Lockbox::inspect_file(&direct_output).unwrap();

    let replace_source = fixture.create_archive("replace.lbox");
    fixture.success(&["migrate", "archive", path(&replace_source), "--replace"]);
    Lockbox::inspect_file(&replace_source).unwrap();
    Lockbox::inspect_file(fixture.root.join(format!(
        "replace.lbox.v{LOCKBOX_FORMAT_VERSION}.pre-migration"
    )))
    .unwrap();

    let conflict = fixture.run(&[
        "migrate",
        "archive",
        path(&replace_source),
        "--replace",
        "--output",
        path(&fixture.root.join("archive-conflict.lbox")),
    ]);
    assert_failure_contains(&conflict, "cannot be used with");
}

#[test]
fn vault_v1_replace_uses_the_explicit_historical_exporter() {
    let fixture = Fixture::new("migration-v1-e2e");
    fixture.init_v1_vault();
    let exporter = build_historical_vault_exporter();

    let output = fixture.run(&[
        "migrate",
        "vault",
        "--replace",
        "--exporter",
        path(&exporter),
    ]);
    assert_success(&output);
    assert!(String::from_utf8_lossy(&output.stdout).contains("format version 2"));
    assert_current_vault(&fixture.vault);
    assert!(fixture.root.join("vault.v1.pre-migration").is_dir());
}

#[test]
fn automatic_historical_exporter_install_hides_cargo_output() {
    let fixture = Fixture::new("migration-v1-install-e2e");
    fixture.init_v1_vault();
    let exporter = build_historical_vault_exporter();
    let fake_cargo = build_fake_cargo(&fixture.root);
    let home = fixture.root.join("home");

    let output = fixture
        .command(&["migrate", "vault", "--replace"])
        .env("CARGO", fake_cargo)
        .env("HOME", home)
        .env("FAKE_EXPORTER_SOURCE", exporter)
        .output()
        .unwrap();
    assert_success(&output);
    let rendered = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(rendered.contains("Installing historical reVault exporter"));
    assert!(rendered.contains("is ready"));
    assert!(!rendered.contains("Compiling revault"));
    assert!(!rendered.contains("Installed package"));
    assert!(!rendered.contains("add `"));
    assert!(!rendered.contains("to your PATH"));
    assert_current_vault(&fixture.vault);
}

struct Fixture {
    _temp: TestTempDir,
    root: PathBuf,
    vault: PathBuf,
    agent: PathBuf,
}

impl Fixture {
    fn new(prefix: &str) -> Self {
        let temp = TestTempDir::new(prefix);
        let root = temp.path().to_path_buf();
        Self {
            vault: root.join("vault"),
            agent: root.join("agent"),
            _temp: temp,
            root,
        }
    }

    fn init_current_vault(&self) {
        self.success(&["vault", "init"]);
    }

    fn init_v1_vault(&self) {
        let password = V1SecretString::try_from_slice(VAULT_PASSWORD.as_bytes()).unwrap();
        let vault = V1VaultDirectory::replace(&self.vault, &password).unwrap();
        vault
            .store_private_key("default", &V1ContactKeyPair::generate().unwrap())
            .unwrap();
        vault
            .store_identity_email("default", "migration@example.test")
            .unwrap();
    }

    fn create_archive(&self, name: &str) -> PathBuf {
        let archive = self.root.join(name);
        self.success(&[path(&archive), "create"]);
        archive
    }

    fn success(&self, args: &[&str]) -> Output {
        let output = self.run(args);
        assert_success(&output);
        output
    }

    fn run(&self, args: &[&str]) -> Output {
        self.command(args).output().unwrap()
    }

    fn run_with_stdin(
        &self,
        args: &[&str],
        stdin: &str,
        remove_vault_password_env: bool,
    ) -> Output {
        let mut command = self.command(args);
        command.env_remove("LOCKBOX_MIGRATION_PASSWORD");
        if remove_vault_password_env {
            command.env_remove("LOCKBOX_VAULT_PASSWORD");
        }
        let mut child = command
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .unwrap();
        child
            .stdin
            .as_mut()
            .unwrap()
            .write_all(stdin.as_bytes())
            .unwrap();
        child.wait_with_output().unwrap()
    }

    fn command(&self, args: &[&str]) -> Command {
        let mut command = Command::new(env!("CARGO_BIN_EXE_lockbox"));
        command
            .args(args)
            .env("LOCKBOX_VAULT_DIR", &self.vault)
            .env("LOCKBOX_VAULT_PASSWORD", VAULT_PASSWORD)
            .env("LOCKBOX_PASSWORD", LOCKBOX_PASSWORD)
            .env("LOCKBOX_KEY", "migration-test-content-key")
            .env("LOCKBOX_MIGRATION_PASSWORD", ARTIFACT_PASSWORD)
            .env("LOCKBOX_PLATFORM_SECRET_STORE", "disabled")
            .env("LOCKBOX_SESSION_AGENT_DIR", &self.agent)
            .env("LOCKBOX_SESSION_AGENT_LOG", self.agent.join("agent.log"));
        command
    }
}

fn build_historical_vault_exporter() -> PathBuf {
    let status = Command::new(env!("CARGO"))
        .current_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join(".."))
        .args([
            "build",
            "--quiet",
            "-p",
            "revault_migrate_vault_v1",
            "--bin",
            "revault-migrate-vault-v1",
        ])
        .status()
        .unwrap();
    assert!(status.success());
    let extension = if cfg!(windows) { ".exe" } else { "" };
    std::env::var_os("CARGO_TARGET_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../target"))
        .join("debug")
        .join(format!("revault-migrate-vault-v1{extension}"))
}

fn build_fake_cargo(output_dir: &Path) -> PathBuf {
    let extension = if cfg!(windows) { ".exe" } else { "" };
    let output = output_dir.join(format!("fake-cargo{extension}"));
    let status = Command::new("rustc")
        .arg("--edition=2021")
        .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/support/fake_cargo.rs"))
        .arg("-o")
        .arg(&output)
        .status()
        .unwrap();
    assert!(status.success());
    output
}

fn assert_current_vault(root: &Path) {
    assert!(root.join("local-vault.lbox").is_file());
    let password =
        revault_vault_api::SecretString::try_from_slice(VAULT_PASSWORD.as_bytes()).unwrap();
    let vault = VaultDirectory::open_or_create(root, &password).unwrap();
    assert_eq!(
        vault.structure_version().unwrap(),
        revault_vault_api::CURRENT_VAULT_STRUCTURE_VERSION
    );
}

fn assert_success(output: &Output) {
    assert!(
        output.status.success(),
        "command failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

fn assert_failure_contains(output: &Output, expected: &str) {
    assert!(!output.status.success());
    let rendered = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(rendered.contains(expected), "output was:\n{rendered}");
}

fn path(value: &Path) -> &str {
    value.to_str().unwrap()
}