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