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}");
}