//! Integration tests for removing paths from the snapshots of one tree: //! that what is taken out goes, that what is not stays restorable, and //! that an excision refuses every state in which sweeping would break a //! backup that is not finished with its work. mod common; use std::{ path::{Path, PathBuf}, sync::{Arc, atomic::AtomicBool}, }; use beeping::{ excise::{self, Refusal}, pipeline::{Outcome, backup}, }; use repository::{Coverage, EntryKind, Repository, SnapshotId}; use common::{deterministic_bytes, manifest_map, open_repository, read_back}; /// A tree with something in it that should not have been backed up. fn sample_tree(root: &Path) -> Vec { let mut written = Vec::new(); for (directory, files) in [("keep", 4), ("junk", 3), ("keep/nested", 2)] { std::fs::create_dir_all(root.join(directory)).unwrap(); for file in 0..files { let relative = PathBuf::from(directory).join(format!("file-{file}.dat")); let bytes = deterministic_bytes(2000 + file * 97, (files * 31 + file) as u64); std::fs::write(root.join(&relative), &bytes).unwrap(); written.push(relative); } } written } fn run_to_completion(repository: &Arc, root: &Path, state: &Path) -> SnapshotId { let outcome = backup::run( repository.clone(), backup::BackupOptions::new(root, state), crossbeam_channel::unbounded().0, Arc::new(AtomicBool::new(false)), ) .unwrap(); match outcome { Outcome::Completed(snapshot) => snapshot, other => panic!("expected completion: {other:?}"), } } fn selecting(patterns: &[&str]) -> globset::GlobSet { beeping::patterns::compile_set(patterns).unwrap() } /// The paths go from every snapshot of the tree, their content is swept, /// and everything else stays exactly as restorable as it was. #[test] fn excising_takes_a_directory_out_of_every_snapshot_of_a_tree() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("tree"); let state = dir.path().join("state"); let written = sample_tree(&root); let repository = open_repository(dir.path()); let first = run_to_completion(&repository, &root, &state); // A second backup of the same tree, so the excision has to reach // more than one snapshot std::fs::write(root.join("keep/later.dat"), b"added between backups").unwrap(); let second = run_to_completion(&repository, &root, &state); // ...and a backup of a different tree, which must come through // untouched however alike its paths look let other_root = dir.path().join("other"); std::fs::create_dir_all(other_root.join("junk")).unwrap(); std::fs::write(other_root.join("junk/file-0.dat"), b"not mine to remove").unwrap(); let other = run_to_completion(&repository, &other_root, &dir.path().join("other-state")); let before = manifest_map(&repository, other); let outcome = excise::excise( &repository, &root, &selecting(&["/junk"]), &state, false, false, |_| {}, ) .unwrap(); assert_eq!(outcome.rewritten.len(), 2, "both snapshots held junk"); assert_eq!(outcome.untouched, 0); assert_eq!(outcome.unqueued, 0, "no backup was suspended"); assert!(outcome.swept > 0, "the junk's content should have gone"); for rewritten in &outcome.rewritten { // Three files, and the directory that held them assert_eq!(rewritten.entries, 4); assert!(rewritten.content_bytes > 0); } // The old snapshots are gone and their replacements stand in the // same order, still saying when they were made let now: Vec = repository.snapshots().unwrap(); assert_eq!(now.len(), 3); assert!(!now.contains(&first) && !now.contains(&second)); let replacements: Vec = outcome .rewritten .iter() .map(|rewritten| rewritten.to.unwrap()) .collect(); for (original, id) in [(first, replacements[0]), (second, replacements[1])] { let rewritten = repository.load_snapshot(id).unwrap(); assert_eq!(rewritten.root, root); assert_eq!(rewritten.coverage, Coverage::Complete); // Everything under junk has gone, and the directory with it let entries = manifest_map(&repository, id); assert!(!entries.contains_key(Path::new("junk"))); assert!(entries.contains_key(Path::new("keep"))); assert!(entries.contains_key(Path::new("keep/nested"))); for path in &written { let expected = !path.starts_with("junk"); assert_eq!( entries.contains_key(path), expected, "{path:?} in {original} -> {id}" ); } // And what is left is not merely named but there for entry in entries.values() { if matches!(entry.kind, EntryKind::File { .. }) { assert_eq!( read_back(&repository, entry), std::fs::read(root.join(&entry.path)).unwrap(), "{:?}", entry.path ); } } } // The other tree's snapshot is untouched, chunks and all assert!(now.contains(&other)); assert_eq!(manifest_map(&repository, other), before); for entry in before.values() { if matches!(entry.kind, EntryKind::File { .. }) { read_back(&repository, entry); } } } /// Two snapshots that differ only in what was excised still describe two /// backups, and must not collapse into one record. #[test] fn snapshots_left_identical_by_an_excision_stay_separate() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("tree"); let state = dir.path().join("state"); std::fs::create_dir_all(root.join("junk")).unwrap(); std::fs::write(root.join("keep.dat"), b"unchanging").unwrap(); std::fs::write(root.join("junk/churn.dat"), b"before").unwrap(); let repository = open_repository(dir.path()); let first = run_to_completion(&repository, &root, &state); // Only the junk changes between the two std::fs::write(root.join("junk/churn.dat"), b"after!").unwrap(); let second = run_to_completion(&repository, &root, &state); assert_ne!(first, second); let made: Vec = [first, second] .iter() .map(|id| repository.load_snapshot(*id).unwrap().created) .collect(); let outcome = excise::excise( &repository, &root, &selecting(&["/junk"]), &state, false, false, |_| {}, ) .unwrap(); let ids: Vec = outcome .rewritten .iter() .map(|rewritten| rewritten.to.unwrap()) .collect(); assert_eq!(ids.len(), 2); assert_ne!(ids[0], ids[1], "two backups became one record"); assert_eq!(repository.snapshots().unwrap().len(), 2); // Each still says when its own backup ran, which is the whole of // what tells them apart now let created: Vec = ids .iter() .map(|id| repository.load_snapshot(*id).unwrap().created) .collect(); assert_eq!(created, made); } /// A dry run answers the only question worth asking first — did the /// patterns select what I meant? — and changes nothing at all. #[test] fn a_dry_run_writes_nothing() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("tree"); let state = dir.path().join("state"); sample_tree(&root); let repository = open_repository(dir.path()); let snapshot = run_to_completion(&repository, &root, &state); let before = manifest_map(&repository, snapshot); let outcome = excise::excise( &repository, &root, &selecting(&["/junk"]), &state, true, false, |_| {}, ) .unwrap(); assert_eq!(outcome.rewritten.len(), 1); assert_eq!(outcome.rewritten[0].from, snapshot); assert_eq!(outcome.rewritten[0].to, None); assert_eq!(outcome.rewritten[0].entries, 4); assert_eq!(outcome.swept, 0); assert_eq!(repository.snapshots().unwrap(), vec![snapshot]); assert_eq!(manifest_map(&repository, snapshot), before); } /// Nothing selected is not an error, and not a reason to rewrite /// anything either: a snapshot that held none of it keeps its identity. #[test] fn a_pattern_that_selects_nothing_leaves_every_snapshot_alone() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("tree"); let state = dir.path().join("state"); sample_tree(&root); let repository = open_repository(dir.path()); let snapshot = run_to_completion(&repository, &root, &state); let outcome = excise::excise( &repository, &root, &selecting(&["/nothing-like-this"]), &state, false, false, |_| {}, ) .unwrap(); assert!(outcome.rewritten.is_empty()); assert_eq!(outcome.untouched, 1); assert_eq!( outcome.swept, 0, "an excision that removed nothing sweeps nothing" ); assert_eq!(repository.snapshots().unwrap(), vec![snapshot]); } /// A root with no snapshots is a typo far more often than a surprise. #[test] fn a_root_with_no_snapshots_is_refused() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("tree"); let state = dir.path().join("state"); sample_tree(&root); let repository = open_repository(dir.path()); run_to_completion(&repository, &root, &state); let err = excise::excise( &repository, &dir.path().join("somewhere-else"), &selecting(&["/junk"]), &state, false, false, |_| {}, ) .unwrap_err(); let message = err.to_string(); assert!(message.contains("no snapshots of"), "{message}"); assert!( message.contains(&root.display().to_string()), "it should say what the repository does hold: {message}" ); } /// A suspended backup is corrected along with the snapshots: what it has /// published is rewritten, what it still has queued loses the selected /// paths, and it resumes to a finished snapshot without them — with no /// need to re-store what it had already done. #[test] fn a_suspended_backup_is_corrected_along_with_its_snapshots() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("tree"); let state = dir.path().join("state"); // Enough of it that a suspension lands partway through for outer in 0..10 { let sub = root.join(format!("dir-{outer}")); std::fs::create_dir_all(&sub).unwrap(); for file in 0..20 { let bytes = deterministic_bytes(3000, (outer * 40 + file) as u64); std::fs::write(sub.join(format!("file-{file}.dat")), &bytes).unwrap(); } } std::fs::create_dir_all(root.join("junk")).unwrap(); for file in 0..6 { let bytes = deterministic_bytes(5000, 900 + file); std::fs::write(root.join(format!("junk/file-{file}.dat")), &bytes).unwrap(); } let repository = open_repository(dir.path()); let run = |suspend: bool| { backup::run( repository.clone(), backup::BackupOptions::new(&root, &state), crossbeam_channel::unbounded().0, Arc::new(AtomicBool::new(suspend)), ) .unwrap() }; let Outcome::Suspended { restorable } = run(true) else { panic!("expected a suspension"); }; let partial = restorable.expect("a suspension publishes what it has"); assert!(state.exists()); let outcome = excise::excise( &repository, &root, &selecting(&["/junk"]), &state, false, false, |_| {}, ) .unwrap(); // Whatever the suspension had reached of the junk is gone from its // partial snapshot, and whatever it had not is gone from its queues assert!( outcome.unqueued > 0 || outcome.rewritten.len() == 1, "the junk was neither queued nor stored: {outcome:?}" ); let published = repository.snapshots().unwrap(); assert_eq!(published.len(), 1, "one snapshot, rewritten in place"); assert_eq!( repository.load_snapshot(published[0]).unwrap().coverage, Coverage::Partial, "an unfinished backup's snapshot is still unfinished" ); if !outcome.rewritten.is_empty() { assert_ne!(published[0], partial); } // The run resumes — it is not told to start again — and finishes // without any of the junk let Outcome::Completed(complete) = run(false) else { panic!("expected the resumed backup to finish"); }; let entries = manifest_map(&repository, complete); for (path, entry) in &entries { assert!( !path.starts_with("junk"), "the resumed run put {path:?} back" ); if matches!(entry.kind, EntryKind::File { .. }) { assert_eq!( read_back(&repository, entry), std::fs::read(root.join(path)).unwrap(), "{path:?}" ); } } // ...and it did finish: everything outside the junk is there assert_eq!(entries.len(), 10 + 200, "10 directories and their files"); assert_eq!(repository.snapshots().unwrap(), vec![complete]); } /// The queue filtering itself: what a suspended run still has to do /// loses exactly the selected paths, and nothing else. #[test] fn a_suspended_runs_queues_lose_the_selected_paths() { use beeping::pipeline::backup::{SuspendedRun, WorkingState}; let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("tree"); let state = dir.path().join("state"); for outer in 0..10 { let sub = root.join(format!("dir-{outer}")); std::fs::create_dir_all(&sub).unwrap(); for file in 0..20 { let bytes = deterministic_bytes(3000, (outer * 40 + file) as u64); std::fs::write(sub.join(format!("file-{file}.dat")), &bytes).unwrap(); } } let repository = open_repository(dir.path()); let outcome = backup::run( repository.clone(), backup::BackupOptions::new(&root, &state), crossbeam_channel::unbounded().0, Arc::new(AtomicBool::new(true)), ) .unwrap(); assert!(matches!(outcome, Outcome::Suspended { .. })); let opened = |state: &Path| match SuspendedRun::open(state, &root).unwrap() { WorkingState::Suspended(run) => run, other => panic!("expected a suspended run, not {}", suspended_kind(&other)), }; let everything = selecting(&["**"]); let queued = { let run = opened(&state); let queued = run.count_paths(&everything).unwrap(); assert!(queued > 0, "the suspension left nothing to do"); // Selecting one directory takes only that directory's share let some = run.count_paths(&selecting(&["/dir-3"])).unwrap(); assert!(some < queued, "selecting one directory selected all of it"); // Counting is not doing assert_eq!(run.count_paths(&everything).unwrap(), queued); assert_eq!(run.drop_paths(&selecting(&["/dir-3"])).unwrap(), some); assert_eq!(run.count_paths(&everything).unwrap(), queued - some); queued - some }; // ...and what it left behind is what the next session finds let run = opened(&state); assert_eq!(run.count_paths(&everything).unwrap(), queued); assert_eq!(run.count_paths(&selecting(&["/dir-3"])).unwrap(), 0); } fn suspended_kind(state: &beeping::pipeline::backup::WorkingState) -> &'static str { use beeping::pipeline::backup::WorkingState; match state { WorkingState::Missing => "no state at all", WorkingState::Suspended(_) => "a suspended run", WorkingState::Unsettled(_) => "an unsettled run", } } /// A backup that was killed still holds content that no snapshot names. /// Sweeping around it would leave its eventual snapshot naming chunks /// that are gone, so an excision refuses until the run is settled — and /// this drives the real binary, because a killed run is the one state /// that cannot be arranged politely. #[test] fn a_killed_backup_refuses_the_excision_until_it_is_settled() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("tree"); // Big enough that the run is still going when it is killed for outer in 0..12 { let sub = root.join(format!("dir-{outer:02}")); std::fs::create_dir_all(&sub).unwrap(); for file in 0..25 { std::fs::write( sub.join(format!("file-{file:02}.dat")), deterministic_bytes(20_000, (outer * 100 + file) as u64), ) .unwrap(); } } std::fs::create_dir_all(root.join("junk")).unwrap(); std::fs::write(root.join("junk/waste.dat"), b"never wanted").unwrap(); beeping_command(dir.path()) .args(["init", "--repository", "repo"]) .status() .unwrap(); let backup = [ "backup", "tree", "--repository", "repo", "--state-dir", "state", "--plain", ]; let mut child = beeping_command(dir.path()).args(backup).spawn().unwrap(); // Kill it once it has claimed the repository, which is the state // that matters here: a lock object written and never given back let locks = dir.path().join("repo/locks"); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::fs::read_dir(&locks).map(|d| d.count()).unwrap_or(0) == 0 { assert!( std::time::Instant::now() < deadline, "the backup never claimed the repository" ); std::thread::sleep(std::time::Duration::from_millis(50)); } child.kill().unwrap(); child.wait().unwrap(); let excising = |extra: &[&str]| { let mut args = vec![ "excise", "tree", "/junk", "--repository", "repo", "--state-dir", "state", ]; args.extend_from_slice(extra); let output = beeping_command(dir.path()).args(args).output().unwrap(); ( output.status.success(), String::from_utf8_lossy(&output.stderr).into_owned(), ) }; // The lock stops it first... let (ok, message) = excising(&[]); assert!(!ok, "the excision should have been refused"); assert!(message.contains("in use by a backup"), "{message}"); // ...and breaking the lock does not get past the working state, // which is the thing that would actually be broken let (ok, message) = excising(&["--break-lock"]); assert!(!ok, "breaking the lock should not be enough"); assert!(message.contains("without standing down"), "{message}"); // Settling the run is what makes it safe, and then it goes through let finished = beeping_command(dir.path()).args(backup).status().unwrap(); assert!(finished.success()); let (ok, message) = excising(&[]); assert!(ok, "{message}"); } fn beeping_command(dir: &Path) -> std::process::Command { let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_beeping")); command .current_dir(dir) .env("BEEPING_PASSWORD", "test-password") .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); command } /// A partial snapshot whose backup's working state is not here belongs /// to a run that cannot be told what happened. Rewriting it would leave /// that run unable to resume, so the excision says so instead. #[test] fn a_partial_snapshot_without_its_working_state_refuses_the_excision() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("tree"); let state = dir.path().join("state"); sample_tree(&root); let repository = open_repository(dir.path()); let Outcome::Suspended { restorable } = backup::run( repository.clone(), backup::BackupOptions::new(&root, &state), crossbeam_channel::unbounded().0, Arc::new(AtomicBool::new(true)), ) .unwrap() else { panic!("expected a suspension"); }; let partial = restorable.expect("a suspension publishes what it has"); // The state is there, but this excision is not looking at it let err = excise::excise( &repository, &root, &selecting(&["/junk"]), &dir.path().join("not-the-state-dir"), false, false, |_| {}, ) .unwrap_err(); assert!( matches!( err, excise::Error::Refused(Refusal::StraySnapshot { snapshot }) if snapshot == partial ), "{err:?}" ); assert_eq!(repository.snapshots().unwrap(), vec![partial]); }