use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::ops::Deref as _;
use std::path::Path;
use std::path::PathBuf;
use std::rc::Rc;
use anyhow::bail;
use anyhow::Context as _;
use anyhow::Result;
use time::Duration;
use crate::btrfs::Btrfs;
use crate::ops::FileOps;
use crate::ops::LocalOps;
use crate::snapshot::current_time;
use crate::snapshot::Snapshot;
use crate::snapshot::SnapshotBase;
use crate::snapshot::Subvol;
fn is_root(btrfs: &Btrfs, directory: &Path) -> Result<bool> {
btrfs.is_btrfs(directory)
}
fn find_root(btrfs: &Btrfs, directory: &Path) -> Result<PathBuf> {
let mut to_check = directory;
loop {
if is_root(btrfs, to_check)? {
return Ok(to_check.to_path_buf())
}
if let Some(parent) = to_check.parent() {
to_check = parent
} else {
break
}
}
bail!(
"failed to find btrfs file system containing {}",
directory.display()
)
}
fn find_most_recent_snapshot<'snaps>(
snapshots: &'snaps [(Snapshot, usize)],
subvol: &Path,
tag: Option<&str>,
) -> Result<Option<&'snaps (Snapshot, usize)>> {
let base_name = SnapshotBase::from_subvol_path(subvol)?;
let snapshots = snapshots
.iter()
.filter(|(snapshot, _generation)| {
snapshot.as_base_name() == base_name && (tag.is_none() || Some(snapshot.tag.as_ref()) == tag)
})
.collect::<Vec<_>>();
Ok(snapshots.into_iter().last())
}
fn deploy(src: &Repo, dst: &Repo, src_snap: &Snapshot) -> Result<()> {
let base_name = src_snap.as_base_name();
let dst_snaps = dst
.snapshots()?
.into_iter()
.map(|(snapshot, _generation)| snapshot)
.filter(|snapshot| snapshot.as_base_name() == base_name)
.collect::<BTreeSet<_>>();
if dst_snaps.contains(src_snap) {
return Ok(())
}
let src_snaps = src
.snapshots()?
.into_iter()
.map(|(snapshot, _generation)| snapshot)
.filter(|snapshot| snapshot.as_base_name() == base_name)
.collect::<BTreeSet<_>>();
let parents = src_snaps
.intersection(&dst_snaps)
.map(|snapshot| src.path().join(snapshot.to_string()))
.collect::<Vec<_>>();
let parents = parents.iter().map(OsStr::new);
let () = src.btrfs.sync(&src.btrfs_root)?;
let () = src.btrfs.send_recv(
&src.path().join(src_snap.to_string()),
parents,
&dst.btrfs,
&dst.path(),
)?;
Ok(())
}
pub fn backup(src: &Repo, dst: &Repo, subvol: &Path, tag: &str) -> Result<Snapshot> {
let src_snap = src.snapshot(subvol, tag)?;
let () = deploy(src, dst, &src_snap)?;
Ok(src_snap)
}
pub fn restore(src: &Repo, dst: &Repo, subvol: &Path, snapshot_only: bool) -> Result<()> {
if let Some(parent) = subvol.parent() {
let () = dst.file_ops.create_dir_all(parent)?;
}
let snapshots = src.snapshots()?;
let (snapshot, _generation) =
find_most_recent_snapshot(&snapshots, subvol, None)?.with_context(|| {
format!(
"No snapshot to restore found for subvolume {} in {}",
subvol.display(),
src.path().display()
)
})?;
if !snapshot_only && dst.file_ops.is_dir(subvol)? {
bail!(
"Cannot restore subvolume {}: a directory with this name exists",
subvol.display()
)
}
let () = deploy(src, dst, snapshot)?;
if !snapshot_only {
let readonly = true;
let () = dst
.btrfs
.snapshot(&dst.path().join(snapshot.to_string()), subvol, !readonly)?;
}
Ok(())
}
pub fn purge(repo: &Repo, subvol: &Path, tag: &str, keep_for: Duration) -> Result<()> {
let snapshots = repo
.snapshots()
.context("failed to list snapshots")?
.into_iter()
.map(|(snapshot, _generation)| snapshot)
.filter(|snapshot| snapshot.subvol == Subvol::new(subvol) && snapshot.tag == tag);
let current_time = current_time();
let mut to_purge = snapshots
.clone()
.filter(|snapshot| current_time > snapshot.timestamp + keep_for);
if to_purge.clone().count() == snapshots.count() {
let _skipped = to_purge.next_back();
}
let () = to_purge.try_for_each(|snapshot| {
repo.delete(&snapshot).with_context(|| {
format!(
"failed to delete snapshot {} in {}",
snapshot,
repo.path().display()
)
})
})?;
Ok(())
}
#[derive(Clone, Debug, Default)]
pub struct RepoBuilder {
file_ops: Option<Rc<dyn FileOps>>,
btrfs: Option<Btrfs>,
}
impl RepoBuilder {
pub fn set_file_ops<O>(&mut self, file_ops: O)
where
O: FileOps + 'static,
{
self.file_ops = Some(Rc::new(file_ops))
}
pub fn set_btrfs_ops(&mut self, btrfs: Btrfs) {
self.btrfs = Some(btrfs)
}
pub fn build<P>(self, directory: P) -> Result<Repo>
where
P: AsRef<Path>,
{
let file_ops = self.file_ops.unwrap_or_else(|| Rc::new(LocalOps));
let directory = directory.as_ref();
let () = file_ops
.create_dir_all(directory)
.with_context(|| format!("could not ensure directory {} exists", directory.display()))?;
let directory = file_ops
.canonicalize(directory)
.with_context(|| format!("failed to canonicalize path {}", directory.display()))?;
let btrfs = self.btrfs.unwrap_or_default();
let root = find_root(&btrfs, &directory)?;
let repo = Repo {
file_ops,
btrfs,
repo_root: directory
.strip_prefix(&root)
.expect("btrfs root directory is not a prefix of the provided directory")
.to_path_buf(),
btrfs_root: root,
};
Ok(repo)
}
}
#[derive(Clone, Debug)]
pub struct Repo {
file_ops: Rc<dyn FileOps>,
btrfs: Btrfs,
btrfs_root: PathBuf,
repo_root: PathBuf,
}
impl Repo {
pub fn builder() -> RepoBuilder {
RepoBuilder::default()
}
#[cfg(test)]
pub fn new<P>(directory: P) -> Result<Self>
where
P: AsRef<Path>,
{
Self::builder().build(directory)
}
pub fn snapshot(&self, subvol: &Path, tag: &str) -> Result<Snapshot> {
let snapshots = self.snapshots()?;
let most_recent = find_most_recent_snapshot(&snapshots, subvol, Some(tag))?;
let parent = if let Some((snapshot, generation)) = most_recent {
let has_changes = self.btrfs.has_changes(subvol, *generation)?;
if !has_changes {
return Ok(snapshot.clone())
}
Some(snapshot)
} else {
None
};
let mut snapshot = Snapshot::from_subvol_path(subvol, tag)?;
debug_assert_eq!(snapshot.number, None);
if let Some(most_recent) = parent {
if snapshot == most_recent.strip_number() {
snapshot = most_recent.bump_number();
}
}
let readonly = true;
let snapshot_path = self.path().join(snapshot.to_string());
let () = self.btrfs.snapshot(subvol, &snapshot_path, readonly)?;
Ok(snapshot)
}
pub fn delete(&self, snapshot: &Snapshot) -> Result<()> {
let snapshot_path = self.path().join(snapshot.to_string());
let () = self.btrfs.delete_subvol(&snapshot_path)?;
Ok(())
}
pub fn snapshots(&self) -> Result<Vec<(Snapshot, usize)>> {
let readonly = true;
let mut snapshots = self
.btrfs
.subvolumes(
self.file_ops.deref(),
&self.btrfs_root,
Some(&self.repo_root),
readonly,
)?
.into_iter()
.filter_map(|(path, gen)| {
if path.parent() == Some(Path::new("")) {
path
.file_name()
.and_then(|snapshot| Snapshot::from_snapshot_name(snapshot).ok())
.map(|snapshot| (snapshot, gen))
} else {
None
}
})
.collect::<Vec<_>>();
let () = snapshots.sort();
Ok(snapshots)
}
#[inline]
pub fn path(&self) -> PathBuf {
self.btrfs_root.join(&self.repo_root)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::read_to_string;
use std::fs::write;
use serial_test::serial;
use crate::snapshot::Subvol;
use crate::test::with_btrfs;
use crate::test::with_two_btrfs;
use crate::test::BtrfsDev;
use crate::test::Mount;
#[test]
#[serial]
fn snapshot_creation() {
let tag = "";
with_btrfs(|root| {
let btrfs = Btrfs::new();
let subvol = root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let () = write(subvol.join("file"), "test42").unwrap();
let repo = Repo::new(root.join("repo")).unwrap();
let snapshot = repo.snapshot(&subvol, tag).unwrap();
let content = read_to_string(repo.path().join(snapshot.to_string()).join("file")).unwrap();
assert_eq!(content, "test42");
})
}
#[test]
#[serial]
fn tagged_snapshot_creation() {
let tag = "tagged";
with_btrfs(|root| {
let btrfs = Btrfs::new();
let subvol = root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let () = write(subvol.join("file"), "test42").unwrap();
let repo = Repo::new(root.join("repo")).unwrap();
let snapshot = repo.snapshot(&subvol, tag).unwrap();
assert_eq!(snapshot.tag, tag);
let content = read_to_string(repo.path().join(snapshot.to_string()).join("file")).unwrap();
assert_eq!(content, "test42");
})
}
#[test]
#[serial]
fn snapshot_creation_up_to_date() {
let tag = "";
with_btrfs(|root| {
let btrfs = Btrfs::new();
let subvol = root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let () = write(subvol.join("file"), "test42").unwrap();
let repo = Repo::new(root).unwrap();
let snapshot1 = repo.snapshot(&subvol, tag).unwrap();
let snapshot2 = repo.snapshot(&subvol, tag).unwrap();
assert_eq!(snapshot1, snapshot2);
let snapshots = repo.snapshots().unwrap();
assert_eq!(snapshots.len(), 1);
})
}
#[test]
#[serial]
fn snapshot_creation_up_to_date_tagged() {
with_btrfs(|root| {
let btrfs = Btrfs::new();
let subvol = root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let () = write(subvol.join("file"), "test42").unwrap();
let repo = Repo::new(root).unwrap();
let tag = "";
let snapshot1 = repo.snapshot(&subvol, tag).unwrap();
let tag = "different";
let snapshot2 = repo.snapshot(&subvol, tag).unwrap();
assert_ne!(snapshot1, snapshot2);
let snapshots = repo.snapshots().unwrap();
assert_eq!(snapshots.len(), 2);
})
}
#[test]
#[serial]
fn snapshot_creation_on_change() {
let tag = "";
with_btrfs(|root| {
let btrfs = Btrfs::new();
let subvol = root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let () = write(subvol.join("file"), "test42").unwrap();
let repo = Repo::new(root).unwrap();
let snapshot1 = repo.snapshot(&subvol, tag).unwrap();
let () = write(subvol.join("file"), "test43").unwrap();
let snapshot2 = repo.snapshot(&subvol, tag).unwrap();
assert_ne!(snapshot1, snapshot2);
let snapshots = repo.snapshots().unwrap();
assert_eq!(snapshots.len(), 2);
let content = read_to_string(repo.path().join(snapshot1.to_string()).join("file")).unwrap();
assert_eq!(content, "test42");
let content = read_to_string(repo.path().join(snapshot2.to_string()).join("file")).unwrap();
assert_eq!(content, "test43");
})
}
#[test]
#[serial]
fn no_snapshots_in_empty_repo() {
with_btrfs(|root| {
let repo = Repo::new(root).unwrap();
let snapshots = repo.snapshots().unwrap();
assert!(snapshots.is_empty());
})
}
#[test]
#[serial]
fn snapshot_listing() {
let tag = "";
with_btrfs(|root| {
let btrfs = Btrfs::new();
let subvol = root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let repo = Repo::new(root.join("repo")).unwrap();
let snapshot = repo.snapshot(&subvol, tag).unwrap();
let mut snapshots = repo.snapshots().unwrap().into_iter();
assert_eq!(snapshots.len(), 1);
let next = snapshots.next().unwrap();
assert_eq!(next.0.subvol, Subvol::new(&subvol));
assert_eq!(next.0.subvol, snapshot.subvol);
assert_ne!(next.1, 0);
})
}
#[test]
#[serial]
fn snapshot_outside_repo() {
let tag = "";
with_btrfs(|root| {
let btrfs = Btrfs::new();
let subvol = root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let repo = Repo::new(root.join("repo")).unwrap();
let root_repo = Repo::new(root).unwrap();
let _snapshot = root_repo.snapshot(&subvol, tag).unwrap();
let sub_repo = Repo::new(repo.path().join("foobar")).unwrap();
let _snapshot = sub_repo.snapshot(&subvol, tag).unwrap();
let snapshots = repo.snapshots().unwrap();
assert!(snapshots.is_empty());
})
}
#[test]
#[serial]
fn writable_snapshot_listing() {
let tag = "";
with_btrfs(|root| {
let btrfs = Btrfs::new();
let snapshot = Snapshot::from_subvol_path(Path::new("/foobar"), tag).unwrap();
let subvol = root.join(snapshot.to_string());
let () = btrfs.create_subvol(&subvol).unwrap();
let repo = Repo::new(root).unwrap();
let snapshots = repo.snapshots().unwrap();
assert!(snapshots.is_empty());
})
}
#[test]
#[serial]
fn backup_subvolume() {
let tag = "";
with_two_btrfs(|src_root, dst_root| {
let src = Repo::new(src_root).unwrap();
let dst = Repo::new(dst_root).unwrap();
let btrfs = Btrfs::new();
let subvol = src_root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let () = write(subvol.join("file"), "test42").unwrap();
let snapshot = backup(&src, &dst, &subvol, tag).unwrap();
let content = read_to_string(dst.path().join(snapshot.to_string()).join("file")).unwrap();
assert_eq!(content, "test42");
})
}
#[test]
#[serial]
fn backup_subvolume_with_subvol_option() {
let src_loopdev = BtrfsDev::with_default().unwrap();
{
let src_mount = Mount::new(src_loopdev.path()).unwrap();
let btrfs = Btrfs::new();
let subvol = src_mount.path().join("some-subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let () = write(subvol.join("file"), "test42").unwrap();
}
let src_mount = Mount::builder()
.options(["subvol=some-subvol"])
.mount(src_loopdev.path())
.unwrap();
let src_root = src_mount.path();
assert!(src_root.join("file").exists());
let dst_loopdev = BtrfsDev::with_default().unwrap();
let dst_mount = Mount::new(dst_loopdev.path()).unwrap();
let dst_root = dst_mount.path();
let src = Repo::new(src_root).unwrap();
let dst = Repo::new(dst_root).unwrap();
let subvol = src_mount.path();
let () = write(subvol.join("file"), "test42").unwrap();
let tag = "";
let snapshot = backup(&src, &dst, subvol, tag).unwrap();
let content = read_to_string(dst.path().join(snapshot.to_string()).join("file")).unwrap();
assert_eq!(content, "test42");
}
#[test]
#[serial]
fn backup_non_existent_subvolume() {
let tag = "";
with_two_btrfs(|src_root, dst_root| {
let src = Repo::new(src_root).unwrap();
let dst = Repo::new(dst_root).unwrap();
let subvol = src_root.join("subvol");
let error = backup(&src, &dst, &subvol, tag).unwrap_err();
assert!(error.to_string().contains("No such file or directory"));
})
}
#[test]
#[serial]
fn backup_subvolume_up_to_date() {
let tag = "";
with_two_btrfs(|src_root, dst_root| {
let src = Repo::new(src_root).unwrap();
let dst = Repo::new(dst_root).unwrap();
let btrfs = Btrfs::new();
let subvol = src_root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let snapshot1 = backup(&src, &dst, &subvol, tag).unwrap();
let snapshot2 = backup(&src, &dst, &subvol, tag).unwrap();
assert_eq!(snapshot1, snapshot2);
let snapshots = dst.snapshots().unwrap();
assert_eq!(snapshots.len(), 1);
})
}
#[test]
#[serial]
fn backup_subvolume_incremental() {
let tag = "";
with_two_btrfs(|src_root, dst_root| {
let src = Repo::new(src_root).unwrap();
let dst = Repo::new(dst_root).unwrap();
let btrfs = Btrfs::new();
let subvol = src_root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let _snapshot = backup(&src, &dst, &subvol, tag).unwrap();
for i in 0..20 {
let string = "test".to_string() + &i.to_string();
let () = write(subvol.join("file"), &string).unwrap();
let snapshot = backup(&src, &dst, &subvol, tag).unwrap();
let content = read_to_string(dst.path().join(snapshot.to_string()).join("file")).unwrap();
assert_eq!(content, string);
}
})
}
#[test]
#[serial]
fn restore_subvolume() {
let tag = "";
with_two_btrfs(|src_root, dst_root| {
let src = Repo::new(src_root).unwrap();
let dst = Repo::new(dst_root).unwrap();
let btrfs = Btrfs::new();
let subvol = src_root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let () = write(subvol.join("file"), "test42").unwrap();
let snapshot = backup(&src, &dst, &subvol, tag).unwrap();
let () = src.delete(&snapshot).unwrap();
assert!(!src.path().join(snapshot.to_string()).join("file").exists());
let () = btrfs.delete_subvol(&subvol).unwrap();
assert!(!subvol.join("file").exists());
let snapshot_only = false;
let () = restore(&dst, &src, &subvol, snapshot_only).unwrap();
let content = read_to_string(src.path().join(snapshot.to_string()).join("file")).unwrap();
assert_eq!(content, "test42");
let content = read_to_string(subvol.join("file")).unwrap();
assert_eq!(content, "test42");
})
}
#[test]
#[serial]
fn restore_snapshot_only() {
let tag = "";
with_two_btrfs(|src_root, dst_root| {
let src = Repo::new(src_root).unwrap();
let dst = Repo::new(dst_root).unwrap();
let btrfs = Btrfs::new();
let subvol = src_root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let () = write(subvol.join("file"), "test42").unwrap();
let snapshot = backup(&src, &dst, &subvol, tag).unwrap();
let () = src.delete(&snapshot).unwrap();
let snapshot_only = true;
let () = restore(&dst, &src, &subvol, snapshot_only).unwrap();
let content = read_to_string(src.path().join(snapshot.to_string()).join("file")).unwrap();
assert_eq!(content, "test42");
})
}
#[test]
#[serial]
fn restore_subvolume_exists() {
let tag = "";
with_two_btrfs(|src_root, dst_root| {
let src = Repo::new(src_root).unwrap();
let dst = Repo::new(dst_root).unwrap();
let btrfs = Btrfs::new();
let subvol = src_root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let snapshot = backup(&src, &dst, &subvol, tag).unwrap();
let () = src.delete(&snapshot).unwrap();
let snapshot_only = false;
let error = restore(&dst, &src, &subvol, snapshot_only).unwrap_err();
assert!(error
.to_string()
.contains("a directory with this name exists"));
})
}
#[test]
#[serial]
fn restore_subvolume_missing_snapshot() {
let tag = "";
with_two_btrfs(|src_root, dst_root| {
let src = Repo::new(src_root).unwrap();
let dst = Repo::new(dst_root).unwrap();
let btrfs = Btrfs::new();
let subvol = src_root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let snapshot = backup(&src, &dst, &subvol, tag).unwrap();
let () = dst.delete(&snapshot).unwrap();
let snapshot_only = false;
let error = restore(&dst, &src, &subvol, snapshot_only).unwrap_err();
assert!(error.to_string().contains("No snapshot to restore found"));
})
}
}