revault_cli 0.0.11

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

use common::TestTempDir;
use std::fs;
use std::path::Path;
use std::process::{Command, Output};

fn run(bin: &str, cwd: &Path, args: &[&str]) -> Output {
    Command::new(bin)
        .current_dir(cwd)
        .args(args)
        .env("LOCKBOX_KEY", "mirror-test-content-key")
        .env("LOCKBOX_VAULT_DIR", cwd.join("vault"))
        .env("LOCKBOX_VAULT_PASSWORD", "mirror-test-vault-password")
        .env("LOCKBOX_SESSION_AGENT_DIR", cwd.join("agent"))
        .env("LOCKBOX_SESSION_AGENT_LOG", cwd.join("agent.log"))
        .env("LOCKBOX_ADD_PROGRESS", "off")
        .output()
        .unwrap()
}

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

#[test]
fn mirror_create_rules_status_update_and_ownership() {
    let bin = env!("CARGO_BIN_EXE_lockbox");
    let temp = TestTempDir::new("mirror-flow");
    let source = temp.path().join("project");
    let lockbox = temp.path().join("backup.lbox");
    fs::create_dir_all(source.join("src")).unwrap();
    fs::create_dir(source.join("empty")).unwrap();
    fs::write(source.join("README.md"), "one\n").unwrap();
    fs::write(source.join("src/main.rs"), "fn main() {}\n").unwrap();
    fs::write(source.join("ignored.tmp"), "ignored\n").unwrap();

    success(&run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "create"],
    ));
    let created = run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "mirror",
            "project",
            "create",
            "--from",
            "project",
            "--to",
            "/projects/project",
        ],
    );
    success(&created);
    assert!(String::from_utf8_lossy(&created.stdout).contains("No files were copied"));

    success(&run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "mirror",
            "project",
            "rule",
            "add",
            "exclude",
            "*.tmp",
        ],
    ));
    let rules = run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "mirror", "rule", "list"],
    );
    success(&rules);
    assert!(String::from_utf8_lossy(&rules.stdout).contains("*.tmp"));

    let status = run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "mirror", "status"],
    );
    success(&status);
    assert!(String::from_utf8_lossy(&status.stdout).contains("add:       2 files"));

    let before = run(bin, temp.path(), &[lockbox.to_str().unwrap(), "list", "-R"]);
    success(&before);
    assert!(!String::from_utf8_lossy(&before.stdout).contains("README.md"));

    success(&run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "mirror", "update", "--force"],
    ));
    let listing = run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "mirror", "list", "-R"],
    );
    success(&listing);
    let listing = String::from_utf8_lossy(&listing.stdout);
    assert!(listing.contains("README.md"));
    assert!(listing.contains("src/main.rs"));
    assert!(listing.contains("empty"));
    assert!(!listing.contains("ignored.tmp"));

    fs::write(temp.path().join("manual.txt"), "manual\n").unwrap();
    let blocked = run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "add",
            "manual.txt",
            "--to",
            "/projects/project/manual.txt",
        ],
    );
    assert!(!blocked.status.success());
    assert!(String::from_utf8_lossy(&blocked.stderr).contains("managed by mirror"));

    let scoped = run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "mirror",
            "add",
            "manual.txt",
            "--to",
            "manual.txt",
        ],
    );
    success(&scoped);
    let diverged = run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "mirror", "status"],
    );
    success(&diverged);
    assert!(String::from_utf8_lossy(&diverged.stdout).contains("remove:    1 files"));

    let rejected_dry_run = run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "mirror", "update", "--dry-run"],
    );
    assert!(!rejected_dry_run.status.success());
}

#[test]
fn multiple_mirrors_require_name_and_destinations_cannot_overlap() {
    let bin = env!("CARGO_BIN_EXE_lockbox");
    let temp = TestTempDir::new("mirror-multiple");
    let lockbox = temp.path().join("backup.lbox");
    fs::create_dir_all(temp.path().join("one")).unwrap();
    fs::create_dir_all(temp.path().join("two")).unwrap();
    success(&run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "create"],
    ));
    for (name, source, destination) in [("one", "one", "/one"), ("two", "two", "/two")] {
        success(&run(
            bin,
            temp.path(),
            &[
                lockbox.to_str().unwrap(),
                "mirror",
                name,
                "create",
                "--from",
                source,
                "--to",
                destination,
            ],
        ));
    }
    let ambiguous = run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "mirror", "status"],
    );
    assert!(!ambiguous.status.success());
    assert!(String::from_utf8_lossy(&ambiguous.stderr).contains("more than one"));

    let overlap = run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "mirror",
            "nested",
            "create",
            "--from",
            "one",
            "--to",
            "/one/nested",
        ],
    );
    assert!(!overlap.status.success());
    assert!(String::from_utf8_lossy(&overlap.stderr).contains("overlaps"));
}

#[test]
fn mirror_retain_forget_and_delete_have_distinct_results() {
    let bin = env!("CARGO_BIN_EXE_lockbox");
    let temp = TestTempDir::new("mirror-lifecycle");
    let lockbox = temp.path().join("backup.lbox");
    fs::create_dir_all(temp.path().join("source")).unwrap();
    fs::write(temp.path().join("source/a.txt"), "a").unwrap();
    success(&run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "create"],
    ));
    success(&run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "mirror",
            "docs",
            "create",
            "--from",
            "source",
            "--to",
            "/docs",
        ],
    ));
    success(&run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "mirror", "update", "--force"],
    ));
    success(&run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "mirror",
            "configure",
            "--missing-files",
            "retain",
        ],
    ));
    fs::remove_file(temp.path().join("source/a.txt")).unwrap();
    success(&run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "mirror", "update", "--force"],
    ));
    let retained = run(bin, temp.path(), &[lockbox.to_str().unwrap(), "list", "-R"]);
    success(&retained);
    assert!(String::from_utf8_lossy(&retained.stdout).contains("/docs/a.txt"));

    success(&run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "mirror", "forget", "--force"],
    ));
    let unmanaged = run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "remove",
            "--force",
            "/docs/a.txt",
        ],
    );
    success(&unmanaged);
}

#[test]
fn mirror_file_commands_match_archive_file_command_shapes_and_destroy_removes_root() {
    let bin = env!("CARGO_BIN_EXE_lockbox");
    let temp = TestTempDir::new("mirror-file-commands");
    let lockbox = temp.path().join("backup.lbox");
    fs::create_dir_all(temp.path().join("source")).unwrap();
    fs::write(temp.path().join("manual.txt"), "manual\n").unwrap();

    success(&run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "create"],
    ));
    success(&run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "mirror",
            "docs",
            "create",
            "--from",
            "source",
            "--to",
            "/docs",
        ],
    ));
    success(&run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "mirror",
            "add",
            "manual.txt",
            "--to",
            "notes/manual.txt",
        ],
    ));
    let cat = run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "mirror",
            "cat",
            "notes/manual.txt",
        ],
    );
    success(&cat);
    assert_eq!(String::from_utf8_lossy(&cat.stdout), "manual\n");

    success(&run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "mirror",
            "mv",
            "notes/manual.txt",
            "notes/moved.txt",
        ],
    ));
    let extracted = temp.path().join("extracted.txt");
    success(&run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "mirror",
            "extract",
            "notes/moved.txt",
            extracted.to_str().unwrap(),
        ],
    ));
    assert_eq!(fs::read_to_string(extracted).unwrap(), "manual\n");

    success(&run(
        bin,
        temp.path(),
        &[
            lockbox.to_str().unwrap(),
            "mirror",
            "rm",
            "--force",
            "notes/moved.txt",
        ],
    ));
    success(&run(
        bin,
        temp.path(),
        &[lockbox.to_str().unwrap(), "mirror", "destroy", "--force"],
    ));
    let listing = run(bin, temp.path(), &[lockbox.to_str().unwrap(), "list", "-R"]);
    success(&listing);
    assert!(!String::from_utf8_lossy(&listing.stdout).contains("/docs"));
}