mod.rs
raw
//! 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<Repository> {
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<PathBuf>, capacity: u64) -> SmallStore {
SmallStore::answering(root, capacity, StoreAnswers::Everything)
}
pub fn answering(root: impl Into<PathBuf>, 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<Option<Vec<u8>>, repository::Error> {
self.inner.get(key)
}
fn contains(&self, key: &ObjectKey) -> Result<bool, repository::Error> {
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<Option<u64>, 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<Option<u64>, 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<Repository> {
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<Repository> {
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<u8> {
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<Repository>,
root: &Path,
state_dir: &Path,
exclude: &[&str],
suspend: &Arc<AtomicBool>,
) -> 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<Repository>,
snapshot: SnapshotId,
target: &Path,
state_dir: &Path,
suspend: &Arc<AtomicBool>,
) -> 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<PathBuf, Entry> {
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<u8> {
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"
}
}