//! Helpers shared by the pipeline integration tests. Everything here //! operates strictly on synthetic trees under `tempfile` temp //! directories — never on real user data. // Each integration-test binary compiles this module separately and uses // a different subset of it. #![allow(dead_code)] use std::{ collections::HashMap, path::{Path, PathBuf}, sync::{Arc, atomic::AtomicBool}, }; use beeping::pipeline::{Outcome, backup, restore}; use repository::{ Backend, Entry, EntryKind, LocalBackend, ObjectKey, ObjectKind, Repository, SnapshotId, }; pub fn open_repository(dir: &Path) -> Arc { let backend = LocalBackend::new(dir.join("repo")).unwrap(); let repository = match Repository::open( Box::new(LocalBackend::new(dir.join("repo")).unwrap()), "test-password", ) { Ok(repository) => repository, Err(repository::Error::NotInitialized) => { Repository::create(Box::new(backend), "test-password").unwrap() } Err(err) => panic!("opening repository: {err}"), }; Arc::new(repository) } /// What a store will say about itself, which is what decides which /// limits a backup can be held to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StoreAnswers { /// Free space and size both, as a local disk does. Everything, /// Only what it is holding, as an FTP server with MLSD does. OnlyItsSize, /// Neither, as a plain FTP server does. Nothing, } /// A store with a bottom to it: a [`LocalBackend`] that reports the room /// it was given, less everything it is holding — and that can be made /// as taciturn as the protocol under test. /// /// Real stores fill up as a backup pours into them, which is the whole /// of what these limits react to; this reproduces that on a synthetic /// tree without needing a small filesystem to run on. What it holds is /// measured rather than counted, so reopening the store — as a second /// run does — sees the room the first one used up. pub struct SmallStore { inner: LocalBackend, root: PathBuf, capacity: u64, answers: StoreAnswers, } impl SmallStore { pub fn new(root: impl Into, capacity: u64) -> SmallStore { SmallStore::answering(root, capacity, StoreAnswers::Everything) } pub fn answering(root: impl Into, capacity: u64, answers: StoreAnswers) -> SmallStore { let root = root.into(); SmallStore { inner: LocalBackend::new(&root).unwrap(), root, capacity, answers, } } } impl Backend for SmallStore { fn put(&self, key: &ObjectKey, data: &[u8]) -> Result<(), repository::Error> { self.inner.put(key, data) } fn get(&self, key: &ObjectKey) -> Result>, repository::Error> { self.inner.get(key) } fn contains(&self, key: &ObjectKey) -> Result { self.inner.contains(key) } fn list( &self, kind: ObjectKind, visit: &mut dyn FnMut(ObjectKey) -> Result<(), repository::Error>, ) -> Result<(), repository::Error> { self.inner.list(kind, visit) } fn delete(&self, key: &ObjectKey) -> Result<(), repository::Error> { self.inner.delete(key) } fn free_space(&self) -> Result, repository::Error> { if self.answers != StoreAnswers::Everything { return Ok(None); } Ok(Some(self.capacity.saturating_sub(tree_bytes(&self.root)))) } fn used_space(&self) -> Result, repository::Error> { if self.answers == StoreAnswers::Nothing { return Ok(None); } Ok(Some(tree_bytes(&self.root))) } fn local_root(&self) -> Option<&Path> { self.inner.local_root() } } /// How many bytes of file content a directory tree holds. fn tree_bytes(path: &Path) -> u64 { let Ok(entries) = std::fs::read_dir(path) else { return 0; }; let mut total = 0; for entry in entries.flatten() { let Ok(metadata) = entry.metadata() else { continue; }; total += if metadata.is_dir() { tree_bytes(&entry.path()) } else { metadata.len() }; } total } /// Opens (or creates) a repository on a store that only has `capacity` /// bytes of room in it. pub fn open_small_repository(dir: &Path, capacity: u64) -> Arc { open_store(dir, capacity, StoreAnswers::Everything) } /// Opens (or creates) a repository on a store of that size which /// answers only some questions about itself. pub fn open_store(dir: &Path, capacity: u64, answers: StoreAnswers) -> Arc { let store = || Box::new(SmallStore::answering(dir.join("repo"), capacity, answers)); let repository = match Repository::open(store(), "test-password") { Ok(repository) => repository, Err(repository::Error::NotInitialized) => { Repository::create(store(), "test-password").unwrap() } Err(err) => panic!("opening repository: {err}"), }; Arc::new(repository) } pub fn deterministic_bytes(len: usize, mut state: u64) -> Vec { let mut bytes = Vec::with_capacity(len); while bytes.len() < len { state = state .wrapping_mul(6364136223846793005) .wrapping_add(1442695040888963407); bytes.extend_from_slice(&state.to_le_bytes()); } bytes.truncate(len); bytes } pub fn run_backup( repository: &Arc, root: &Path, state_dir: &Path, exclude: &[&str], suspend: &Arc, ) -> Outcome { let mut options = backup::BackupOptions::new(root, state_dir); let mut globs = globset::GlobSetBuilder::new(); for pattern in exclude { globs.add(globset::Glob::new(pattern).unwrap()); } options.exclude = globs.build().unwrap(); let (events, receiver) = crossbeam_channel::unbounded(); drop(receiver); // event delivery must be optional backup::run(repository.clone(), options, events, suspend.clone()).unwrap() } pub fn run_restore( repository: &Arc, snapshot: SnapshotId, target: &Path, state_dir: &Path, suspend: &Arc, ) -> Outcome { let options = restore::RestoreOptions::new(snapshot, target, state_dir); let (events, receiver) = crossbeam_channel::unbounded(); drop(receiver); restore::run(repository.clone(), options, events, suspend.clone()).unwrap() } /// Loads a snapshot's manifest into a path-keyed map, asserting that no /// path appears twice. pub fn manifest_map(repository: &Repository, snapshot: SnapshotId) -> HashMap { let snapshot = repository.load_snapshot(snapshot).unwrap(); let mut map = HashMap::new(); for entry in repository.manifest_entries(snapshot.manifest) { let entry = entry.unwrap(); let previous = map.insert(entry.path.clone(), entry); assert!( previous.is_none(), "manifest lists {:?} more than once", previous.unwrap().path ); } // Recorded entries are an upper bound: a run suspended mid-directory // records that directory's children again when it resumes, and // reading the manifest yields each path once assert!( snapshot.entries as usize >= map.len(), "recorded {} entries but read back {}", snapshot.entries, map.len() ); map } /// Reassembles a file entry's content from the chunk store. pub fn read_back(repository: &Repository, entry: &Entry) -> Vec { let EntryKind::File { chunks, len } = &entry.kind else { panic!("{:?} is not a file entry", entry.path); }; let mut content = Vec::new(); for id in chunks { content.extend_from_slice(&repository.load_chunk(*id).unwrap()); } assert_eq!(*len, content.len() as u64); content } /// Asserts that `restored` faithfully reproduces `original`: same tree /// shape, file contents, symlink targets, permission bits, and /// modification times. pub fn assert_trees_equal(original: &Path, restored: &Path) { assert_tree_covered(original, restored, true); // ... and nothing extra was invented assert_tree_covered(restored, original, false); } /// Walks `from`, asserting each object exists in `to`; checks content /// and metadata only when `check_content` (one direction suffices). fn assert_tree_covered(from: &Path, to: &Path, check_content: bool) { for child in std::fs::read_dir(from).unwrap() { let child = child.unwrap(); let from_path = child.path(); let to_path = to.join(child.file_name()); let from_meta = child.metadata().unwrap(); let to_meta = std::fs::symlink_metadata(&to_path) .unwrap_or_else(|_| panic!("{to_path:?} missing (counterpart of {from_path:?})")); assert_eq!( file_kind(&from_meta), file_kind(&to_meta), "kind mismatch at {to_path:?}" ); if from_meta.file_type().is_symlink() { if check_content { assert_eq!( std::fs::read_link(&from_path).unwrap(), std::fs::read_link(&to_path).unwrap(), "symlink target mismatch at {to_path:?}" ); } continue; } if check_content { #[cfg(unix)] { use std::os::unix::fs::MetadataExt as _; assert_eq!( from_meta.mode() & 0o7777, to_meta.mode() & 0o7777, "mode mismatch at {to_path:?}" ); } assert_eq!( from_meta.modified().unwrap(), to_meta.modified().unwrap(), "mtime mismatch at {to_path:?}" ); } if from_meta.is_file() { if check_content { assert_eq!( std::fs::read(&from_path).unwrap(), std::fs::read(&to_path).unwrap(), "content mismatch at {to_path:?}" ); } } else if from_meta.is_dir() { assert_tree_covered(&from_path, &to_path, check_content); } } } fn file_kind(metadata: &std::fs::Metadata) -> &'static str { let file_type = metadata.file_type(); if file_type.is_symlink() { "symlink" } else if file_type.is_dir() { "directory" } else if file_type.is_file() { "file" } else { "special" } }