restore.rs raw

//! Integration tests for the restore pipeline: full roundtrips from a
//! real tree through the repository and back, suspend/resume, and
//! defenses against hostile manifests.

mod common;

use std::{
    path::{Path, PathBuf},
    sync::{Arc, atomic::AtomicBool, atomic::Ordering},
};

use beeping::pipeline::{Outcome, restore};
use repository::{Entry, EntryKind, SnapshotId};

use common::{assert_trees_equal, deterministic_bytes, open_repository, run_backup, run_restore};

/// Backs up `root` to completion and returns the snapshot id.
fn backup_completed(
    repository: &Arc<repository::Repository>,
    root: &Path,
    state: &Path,
) -> SnapshotId {
    let suspend = Arc::new(AtomicBool::new(false));

    match run_backup(repository, root, state, &[], &suspend) {
        Outcome::Completed(snapshot) => snapshot,
        other => panic!("backup did not complete: {other:?}"),
    }
}

#[test]
fn full_restore_roundtrip() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("tree");

    std::fs::create_dir_all(root.join("sub/deeper")).unwrap();
    std::fs::create_dir_all(root.join("empty")).unwrap();
    std::fs::write(root.join("small.txt"), b"hello restore").unwrap();
    std::fs::write(root.join("sub/nested.txt"), b"nested content").unwrap();
    std::fs::write(
        root.join("big.bin"),
        deterministic_bytes(6 * 1024 * 1024, 7),
    )
    .unwrap();

    #[cfg(unix)]
    {
        use std::os::unix::ffi::OsStringExt as _;
        use std::os::unix::fs::PermissionsExt as _;

        let weird = std::ffi::OsString::from_vec(vec![b'w', 0xFF, 0xFE, b'x']);
        std::fs::write(root.join(weird), b"non-utf8 name").unwrap();
        std::os::unix::fs::symlink("small.txt", root.join("link")).unwrap();
        std::os::unix::fs::symlink("nowhere/dangling", root.join("dangling")).unwrap();

        std::fs::write(root.join("private.txt"), b"restricted").unwrap();
        std::fs::set_permissions(
            root.join("private.txt"),
            std::fs::Permissions::from_mode(0o600),
        )
        .unwrap();

        // A read-only directory with content: its mode can only be
        // applied after the file inside it is written
        std::fs::create_dir(root.join("readonly")).unwrap();
        std::fs::write(root.join("readonly/inside.txt"), b"locked in").unwrap();
        std::fs::set_permissions(
            root.join("readonly"),
            std::fs::Permissions::from_mode(0o555),
        )
        .unwrap();
    }

    let repository = open_repository(dir.path());
    let snapshot = backup_completed(&repository, &root, &dir.path().join("backup-state"));

    let target = dir.path().join("restored");
    let suspend = Arc::new(AtomicBool::new(false));

    let outcome = run_restore(
        &repository,
        snapshot,
        &target,
        &dir.path().join("restore-state"),
        &suspend,
    );
    assert!(matches!(outcome, Outcome::Completed(id) if id == snapshot));
    assert!(!dir.path().join("restore-state").exists());

    assert_trees_equal(&root, &target);

    // Let the temp dir clean up the read-only directories
    #[cfg(unix)]
    for base in [&root, &target] {
        use std::os::unix::fs::PermissionsExt as _;

        std::fs::set_permissions(
            base.join("readonly"),
            std::fs::Permissions::from_mode(0o755),
        )
        .unwrap();
    }
}

#[test]
fn suspend_and_resume_restore() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("tree");

    for outer in 0..8 {
        let sub = root.join(format!("dir-{outer}"));
        std::fs::create_dir_all(&sub).unwrap();

        for file in 0..20 {
            std::fs::write(
                sub.join(format!("file-{file}.dat")),
                deterministic_bytes(3000, (outer * 100 + file) as u64),
            )
            .unwrap();
        }
    }

    let repository = open_repository(dir.path());
    let snapshot = backup_completed(&repository, &root, &dir.path().join("backup-state"));

    let target = dir.path().join("restored");
    let state = dir.path().join("restore-state");

    // Deterministic suspension after ~one controller tick of restoring
    let suspend = Arc::new(AtomicBool::new(true));
    let outcome = run_restore(&repository, snapshot, &target, &state, &suspend);
    assert!(
        matches!(outcome, Outcome::Suspended { .. }),
        "got {outcome:?}"
    );
    assert!(state.exists());

    suspend.store(false, Ordering::Release);
    let outcome = run_restore(&repository, snapshot, &target, &state, &suspend);
    assert!(matches!(outcome, Outcome::Completed(id) if id == snapshot));
    assert!(!state.exists());

    assert_trees_equal(&root, &target);
}

#[test]
fn restore_state_refuses_a_different_snapshot() {
    let dir = tempfile::tempdir().unwrap();

    let repository = open_repository(dir.path());

    let mut snapshots = Vec::new();
    for which in ["a", "b"] {
        let root = dir.path().join(format!("tree-{which}"));
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("file.txt"), which).unwrap();

        snapshots.push(backup_completed(
            &repository,
            &root,
            &dir.path().join("backup-state"),
        ));
    }

    let target = dir.path().join("restored");
    let state = dir.path().join("restore-state");

    let suspend = Arc::new(AtomicBool::new(true));
    let outcome = run_restore(&repository, snapshots[0], &target, &state, &suspend);
    assert!(matches!(outcome, Outcome::Suspended { .. }));

    // Resuming the same state directory for a different snapshot must
    // fail rather than mix two restores
    let options = restore::RestoreOptions::new(snapshots[1], &target, &state);
    let (events, _receiver) = crossbeam_channel::unbounded();

    match restore::run(
        repository,
        options,
        events,
        Arc::new(AtomicBool::new(false)),
    ) {
        Err(beeping::pipeline::Error::StateMismatch { .. }) => {}
        other => panic!("expected StateMismatch, got {other:?}"),
    }
}

#[test]
fn hostile_manifest_paths_cannot_escape_the_target() {
    let dir = tempfile::tempdir().unwrap();
    let repository = open_repository(dir.path());

    let (chunk, _) = repository.store_chunk(b"evil payload").unwrap();

    let escape_relative = PathBuf::from("../escaped-relative.txt");
    let escape_absolute = dir.path().join("escaped-absolute.txt");

    let entries = vec![
        Entry {
            path: escape_relative.clone(),
            kind: EntryKind::File {
                chunks: vec![chunk],
                len: 12,
            },
            mode: None,
            mtime: None,
        },
        Entry {
            path: escape_absolute.clone(),
            kind: EntryKind::File {
                chunks: vec![chunk],
                len: 12,
            },
            mode: None,
            mtime: None,
        },
        Entry {
            path: PathBuf::from("../escaped-link"),
            kind: EntryKind::Symlink {
                target: PathBuf::from("/etc/passwd"),
            },
            mode: None,
            mtime: None,
        },
        Entry {
            path: PathBuf::from("legitimate.txt"),
            kind: EntryKind::File {
                chunks: vec![chunk],
                len: 12,
            },
            mode: None,
            mtime: None,
        },
    ];

    let manifest = repository.store_manifest(entries).unwrap();
    let id = repository.snapshot_id(
        Path::new("/hostile"),
        &manifest,
        repository::Coverage::Complete,
    );
    let snapshot = repository::Snapshot::new(id, PathBuf::from("/hostile"), manifest, 4, 36);
    repository.store_snapshot(&snapshot).unwrap();

    let target = dir.path().join("restored");
    let suspend = Arc::new(AtomicBool::new(false));

    let outcome = run_restore(
        &repository,
        id,
        &target,
        &dir.path().join("restore-state"),
        &suspend,
    );
    assert!(matches!(outcome, Outcome::Completed(_)));

    // The legitimate entry lands; the escapes do not exist anywhere
    assert_eq!(
        std::fs::read(target.join("legitimate.txt")).unwrap(),
        b"evil payload"
    );
    assert!(!dir.path().join("escaped-relative.txt").exists());
    assert!(!dir.path().join("escaped-link").exists());
    assert!(!escape_absolute.exists());
}

/// Cherry-picking: only the selection (plus its ancestor directories,
/// with their metadata) is restored; a selected directory brings its
/// whole subtree.
#[test]
fn cherry_picked_restore() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("tree");

    std::fs::create_dir_all(root.join("docs/notes")).unwrap();
    std::fs::create_dir_all(root.join("other")).unwrap();
    std::fs::create_dir_all(root.join("deep/x/y")).unwrap();
    std::fs::write(root.join("docs/a.txt"), b"doc a").unwrap();
    std::fs::write(root.join("docs/notes/b.txt"), b"note b").unwrap();
    std::fs::write(root.join("other/c.txt"), b"other c").unwrap();
    std::fs::write(root.join("deep/x/y/z.txt"), b"deep z").unwrap();
    std::fs::write(root.join("deep/x/skip.txt"), b"not selected").unwrap();

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;

        std::fs::set_permissions(root.join("deep/x"), std::fs::Permissions::from_mode(0o750))
            .unwrap();
    }

    let repository = open_repository(dir.path());
    let snapshot = backup_completed(&repository, &root, &dir.path().join("backup-state"));

    let target = dir.path().join("restored");

    let mut options =
        restore::RestoreOptions::new(snapshot, &target, dir.path().join("restore-state"));
    options.select = vec!["/docs".to_string(), "deep/x/y/z.txt".to_string()];

    let (events, receiver) = crossbeam_channel::unbounded();
    drop(receiver);

    let outcome = restore::run(
        repository.clone(),
        options,
        events,
        Arc::new(AtomicBool::new(false)),
    )
    .unwrap();
    assert!(matches!(outcome, Outcome::Completed(_)));

    // The selected directory came with its whole subtree
    assert_eq!(std::fs::read(target.join("docs/a.txt")).unwrap(), b"doc a");
    assert_eq!(
        std::fs::read(target.join("docs/notes/b.txt")).unwrap(),
        b"note b"
    );

    // The selected deep file exists, its unselected sibling does not
    assert_eq!(
        std::fs::read(target.join("deep/x/y/z.txt")).unwrap(),
        b"deep z"
    );
    assert!(!target.join("deep/x/skip.txt").exists());
    assert!(!target.join("other").exists());

    // Ancestor directories carry their recorded metadata, not defaults
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt as _;

        let mode = std::fs::metadata(target.join("deep/x")).unwrap().mode() & 0o7777;
        assert_eq!(mode, 0o750, "ancestor directory metadata restored");
    }

    let original_mtime = std::fs::metadata(root.join("deep/x/y"))
        .unwrap()
        .modified()
        .unwrap();
    let restored_mtime = std::fs::metadata(target.join("deep/x/y"))
        .unwrap()
        .modified()
        .unwrap();
    assert_eq!(original_mtime, restored_mtime);
}

/// Resuming a cherry-picked restore with a different selection must be
/// refused: the durable queues hold the old selection's work.
#[test]
fn resume_refuses_a_different_selection() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("tree");

    std::fs::create_dir_all(&root).unwrap();
    for file in 0..80 {
        std::fs::write(
            root.join(format!("file-{file}.dat")),
            deterministic_bytes(2000, file),
        )
        .unwrap();
    }

    let repository = open_repository(dir.path());
    let snapshot = backup_completed(&repository, &root, &dir.path().join("backup-state"));

    let target = dir.path().join("restored");
    let state = dir.path().join("restore-state");

    let run = |select: &[&str], suspend: bool| {
        let mut options = restore::RestoreOptions::new(snapshot, &target, &state);
        options.select = select.iter().map(|s| s.to_string()).collect();

        let (events, receiver) = crossbeam_channel::unbounded();
        drop(receiver);

        restore::run(
            repository.clone(),
            options,
            events,
            Arc::new(AtomicBool::new(suspend)),
        )
    };

    assert!(matches!(
        run(&["file-1*.dat"], true).unwrap(),
        Outcome::Suspended { .. }
    ));

    // Same selection in a different order is the same operation...
    // (single pattern here, so exercise the mismatch instead)
    match run(&["file-2*.dat"], false) {
        Err(beeping::pipeline::Error::StateMismatch { .. }) => {}
        other => panic!("expected StateMismatch, got {other:?}"),
    }

    // ...and the original selection resumes and completes
    assert!(matches!(
        run(&["file-1*.dat"], false).unwrap(),
        Outcome::Completed(_)
    ));

    // file-1.dat and file-10..19 selected: 11 files
    let restored: Vec<_> = std::fs::read_dir(&target).unwrap().collect();
    assert_eq!(restored.len(), 11);
}

/// `beeping list` prints every entry of a snapshot, through the real
/// binary.
#[test]
fn list_shows_snapshot_contents() {
    use std::process::Command;

    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("tree");

    std::fs::create_dir_all(root.join("sub")).unwrap();
    std::fs::write(root.join("sub/data.bin"), vec![7u8; 1234]).unwrap();
    #[cfg(unix)]
    std::os::unix::fs::symlink("sub/data.bin", root.join("link")).unwrap();

    let repository = open_repository(dir.path());
    let snapshot = backup_completed(&repository, &root, &dir.path().join("backup-state"));
    drop(repository); // release any local locks before the CLI opens it

    let output = Command::new(env!("CARGO_BIN_EXE_beeping"))
        .args([
            "list",
            &snapshot.to_hex(),
            "--repository",
            dir.path().join("repo").to_str().unwrap(),
        ])
        .env("BEEPING_PASSWORD", "test-password")
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "list failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let listing = String::from_utf8(output.stdout).unwrap();
    assert!(listing.contains("1234  sub/data.bin"), "{listing}");
    assert!(listing.contains("dir  sub/"), "{listing}");
    #[cfg(unix)]
    assert!(listing.contains("link  link -> sub/data.bin"), "{listing}");
}