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