use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
};
use miette::{Context as _, IntoDiagnostic as _, Result, bail};
use tracing::{debug, info};
const COMPARE_CHUNK: usize = 1024 * 1024;
const COPY_BATCH: usize = 256;
pub enum Decide {
Named(BTreeSet<PathBuf>),
Compare,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Removal {
pub rel: PathBuf,
pub is_dir: bool,
}
#[derive(Debug, Default)]
pub struct Delta {
pub mkdir: Vec<PathBuf>,
pub retouch: Vec<PathBuf>,
pub copy: Vec<PathBuf>,
pub remove: Vec<Removal>,
pub copy_bytes: u64,
pub remove_bytes: u64,
pub displaced_bytes: u64,
}
impl Delta {
pub fn has_work(&self) -> bool {
!self.mkdir.is_empty() || !self.copy.is_empty() || !self.remove.is_empty()
}
pub fn net_growth(&self) -> u64 {
self.copy_bytes
.saturating_sub(self.displaced_bytes)
.saturating_sub(self.remove_bytes)
}
pub fn written_bytes(&self) -> u64 {
self.copy_bytes
}
}
pub async fn compare(capture: &Path, dest: &Path, skip: &[PathBuf], decide: Decide) -> Result<Delta> {
let capture = capture.to_path_buf();
let dest = dest.to_path_buf();
let skip = skip.to_vec();
tokio::task::spawn_blocking(move || {
let mut walk = Walk {
capture: &capture,
dest: &dest,
skip: &skip,
ours: compare_buffer(&decide),
theirs: compare_buffer(&decide),
decide,
read: 0,
delta: Delta::default(),
};
walk.descend(Path::new(""))?;
if walk.read > 0 {
debug!(bytes = walk.read, "compared the same-size entries by content");
}
Ok(walk.delta)
})
.await
.into_diagnostic()
.wrap_err("joining the capture comparison")?
}
fn compare_buffer(decide: &Decide) -> Vec<u8> {
match decide {
Decide::Compare => vec![0u8; COMPARE_CHUNK],
Decide::Named(_) => Vec::new(),
}
}
struct Walk<'a> {
capture: &'a Path,
dest: &'a Path,
skip: &'a [PathBuf],
decide: Decide,
ours: Vec<u8>,
theirs: Vec<u8>,
read: u64,
delta: Delta,
}
fn read_side(dir: &Path) -> Result<Option<BTreeMap<std::ffi::OsString, std::fs::Metadata>>> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(err)
.into_diagnostic()
.wrap_err_with(|| format!("reading {}", dir.display()));
}
};
let mut out = BTreeMap::new();
for entry in entries {
let entry = entry
.into_diagnostic()
.wrap_err_with(|| format!("reading an entry of {}", dir.display()))?;
let meta = entry
.metadata()
.into_diagnostic()
.wrap_err_with(|| format!("stating {}", entry.path().display()))?;
out.insert(entry.file_name(), meta);
}
Ok(Some(out))
}
impl Walk<'_> {
fn skipped(&self, rel: &Path) -> bool {
self.skip.iter().any(|s| s == rel)
}
fn descend(&mut self, rel: &Path) -> Result<()> {
let Some(from) = read_side(&self.capture.join(rel))? else {
return Ok(());
};
let into = read_side(&self.dest.join(rel))?.unwrap_or_default();
for (name, capture_meta) in &from {
let child = rel.join(name);
if self.skipped(&child) {
continue;
}
let live_meta = into.get(name);
match classify(capture_meta, live_meta) {
Some(Change::Copy) if capture_meta.is_dir() => {
self.delta.mkdir.push(child.clone());
if let Some(live) = live_meta {
self.delta.remove_bytes = self.delta.remove_bytes.saturating_add(live.len());
self.delta.remove.push(Removal {
rel: child.clone(),
is_dir: false,
});
}
self.copy_subtree(&child)?;
}
Some(Change::Copy) => self.schedule_copy(child, capture_meta, live_meta),
Some(Change::SameSize { mtime_same, len }) => {
if self.diverged(&child, mtime_same) {
self.delta.copy_bytes = self.delta.copy_bytes.saturating_add(len);
self.delta.displaced_bytes = self.delta.displaced_bytes.saturating_add(len);
self.delta.copy.push(child);
}
}
None => {
self.delta.retouch.push(child.clone());
self.descend(&child)?;
}
}
}
for (name, live_meta) in &into {
if from.contains_key(name) {
continue;
}
let child = rel.join(name);
if self.skipped(&child) {
continue;
}
if live_meta.is_dir() {
if self.remove_subtree(&child)? {
continue;
}
}
self.delta.remove_bytes = self.delta.remove_bytes.saturating_add(live_meta.len());
self.delta.remove.push(Removal {
rel: child,
is_dir: live_meta.is_dir(),
});
}
Ok(())
}
fn schedule_copy(
&mut self,
child: PathBuf,
capture_meta: &std::fs::Metadata,
live_meta: Option<&std::fs::Metadata>,
) {
self.delta.copy_bytes = self.delta.copy_bytes.saturating_add(capture_meta.len());
if let Some(live) = live_meta {
self.delta.displaced_bytes = self.delta.displaced_bytes.saturating_add(live.len());
}
self.delta.copy.push(child);
}
fn copy_subtree(&mut self, rel: &Path) -> Result<()> {
let Some(entries) = read_side(&self.capture.join(rel))? else {
return Ok(());
};
for (name, meta) in &entries {
let child = rel.join(name);
if self.skipped(&child) {
continue;
}
if meta.is_dir() {
self.delta.mkdir.push(child.clone());
self.copy_subtree(&child)?;
} else {
self.schedule_copy(child, meta, None);
}
}
Ok(())
}
fn remove_subtree(&mut self, rel: &Path) -> Result<bool> {
let Some(entries) = read_side(&self.dest.join(rel))? else {
return Ok(false);
};
let mut kept = false;
for (name, meta) in &entries {
let child = rel.join(name);
if self.skipped(&child) {
kept = true;
continue;
}
if meta.is_dir() && self.remove_subtree(&child)? {
kept = true;
continue;
}
self.delta.remove_bytes = self.delta.remove_bytes.saturating_add(meta.len());
self.delta.remove.push(Removal {
rel: child,
is_dir: meta.is_dir(),
});
}
Ok(kept)
}
fn diverged(&mut self, rel: &Path, mtime_same: bool) -> bool {
match &self.decide {
Decide::Named(paths) => paths.contains(rel),
Decide::Compare if mtime_same => false,
Decide::Compare => {
let read = same_contents(
&self.capture.join(rel),
&self.dest.join(rel),
&mut self.ours,
&mut self.theirs,
);
self.read = self.read.saturating_add(read.bytes);
!read.same
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Change {
Copy,
SameSize { mtime_same: bool, len: u64 },
}
fn classify(capture: &std::fs::Metadata, live: Option<&std::fs::Metadata>) -> Option<Change> {
let Some(live) = live else {
return Some(Change::Copy);
};
if capture.file_type() != live.file_type() {
return Some(Change::Copy);
}
if capture.is_dir() {
return None;
}
if capture.is_symlink() || capture.len() != live.len() {
return Some(Change::Copy);
}
Some(Change::SameSize {
mtime_same: same_mtime(capture, live),
len: capture.len(),
})
}
fn same_mtime(a: &std::fs::Metadata, b: &std::fs::Metadata) -> bool {
match (a.modified(), b.modified()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
struct Comparison {
same: bool,
bytes: u64,
}
fn same_contents(a: &Path, b: &Path, ours: &mut [u8], theirs: &mut [u8]) -> Comparison {
use std::io::Read as _;
fn fill(file: &mut std::fs::File, buf: &mut [u8]) -> std::io::Result<usize> {
let mut filled = 0;
while filled < buf.len() {
match file.read(&mut buf[filled..]) {
Ok(0) => break,
Ok(n) => filled += n,
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
Err(err) => return Err(err),
}
}
Ok(filled)
}
let (Ok(mut a), Ok(mut b)) = (std::fs::File::open(a), std::fs::File::open(b)) else {
return Comparison { same: false, bytes: 0 };
};
let mut bytes = 0u64;
loop {
let (Ok(mine), Ok(yours)) = (fill(&mut a, ours), fill(&mut b, theirs)) else {
return Comparison { same: false, bytes };
};
bytes = bytes.saturating_add((mine + yours) as u64);
if mine != yours || ours[..mine] != theirs[..yours] {
return Comparison { same: false, bytes };
}
if mine == 0 {
return Comparison { same: true, bytes };
}
}
}
pub async fn apply(capture: &Path, dest: &Path, delta: &Delta) -> Result<()> {
let removals: Vec<(PathBuf, bool)> = delta
.remove
.iter()
.map(|entry| (dest.join(&entry.rel), entry.is_dir))
.collect();
tokio::task::spawn_blocking(move || {
for (path, is_dir) in removals {
remove_entry(&path, is_dir)
.into_diagnostic()
.wrap_err_with(|| format!("removing {}", path.display()))?;
}
Ok::<_, miette::Report>(())
})
.await
.into_diagnostic()
.wrap_err("joining the removal pass")??;
let directories: Vec<PathBuf> = delta.mkdir.iter().map(|rel| dest.join(rel)).collect();
tokio::task::spawn_blocking(move || {
for path in directories {
match std::fs::create_dir(&path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir_all(&path)
.into_diagnostic()
.wrap_err_with(|| format!("creating {}", path.display()))?;
}
Err(err) => {
return Err(err)
.into_diagnostic()
.wrap_err_with(|| format!("creating {}", path.display()));
}
}
}
Ok::<_, miette::Report>(())
})
.await
.into_diagnostic()
.wrap_err("joining the directory pass")??;
let total = delta.copy.len();
let mut done = 0usize;
let mut copied = 0u64;
let mut last_report = std::time::Instant::now();
for batch in delta.copy.chunks(COPY_BATCH) {
let capture = capture.to_path_buf();
let dest = dest.to_path_buf();
let size = batch.len();
let batch: Vec<PathBuf> = batch.to_vec();
copied = copied.saturating_add(
tokio::task::spawn_blocking(move || {
let mut bytes = 0u64;
let mut last_parent: Option<&Path> = None;
for rel in &batch {
let parent = rel.parent();
let fresh_parent = parent.is_some() && parent != last_parent;
bytes = bytes.saturating_add(copy_entry_blocking(
&capture.join(rel),
&dest.join(rel),
fresh_parent,
)?);
if fresh_parent {
last_parent = parent;
}
}
Ok::<_, miette::Report>(bytes)
})
.await
.into_diagnostic()
.wrap_err("joining the copy pass")??,
);
done += size;
if last_report.elapsed() >= std::time::Duration::from_secs(30) {
info!(done, total, bytes = copied, "copying the divergence into place");
last_report = std::time::Instant::now();
}
}
let directories: Vec<(PathBuf, PathBuf)> = delta
.mkdir
.iter()
.chain(&delta.retouch)
.rev()
.map(|rel| (capture.join(rel), dest.join(rel)))
.collect();
tokio::task::spawn_blocking(move || {
for (from, to) in directories {
if let Ok(meta) = from.symlink_metadata() {
carry_metadata(&meta, &to);
}
}
})
.await
.into_diagnostic()
.wrap_err("joining the directory metadata pass")?;
Ok(())
}
pub(super) fn remove_entry(path: &Path, is_dir: bool) -> std::io::Result<()> {
if is_dir {
return std::fs::remove_dir(path);
}
#[cfg(windows)]
{
if path.symlink_metadata().is_ok_and(|meta| meta.is_symlink()) {
return std::fs::remove_dir(path).or_else(|_| std::fs::remove_file(path));
}
}
match std::fs::remove_file(path) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
}
}
pub async fn copy_entry(from: &Path, to: &Path, make_parent: bool) -> Result<u64> {
let from = from.to_path_buf();
let to = to.to_path_buf();
tokio::task::spawn_blocking(move || copy_entry_blocking(&from, &to, make_parent))
.await
.into_diagnostic()
.wrap_err("joining the entry copy")?
}
fn copy_entry_blocking(from: &Path, to: &Path, make_parent: bool) -> Result<u64> {
let meta = from
.symlink_metadata()
.into_diagnostic()
.wrap_err_with(|| format!("stating {}", from.display()))?;
if make_parent
&& let Some(parent) = to.parent()
{
std::fs::create_dir_all(parent)
.into_diagnostic()
.wrap_err_with(|| format!("creating {}", parent.display()))?;
}
match to.symlink_metadata() {
Ok(existing) if existing.is_dir() => {
std::fs::remove_dir_all(to)
.into_diagnostic()
.wrap_err_with(|| format!("removing {} to replace it", to.display()))?;
}
Ok(existing) if existing.is_symlink() || meta.is_symlink() => {
remove_entry(to, false)
.into_diagnostic()
.wrap_err_with(|| format!("removing {} to replace it", to.display()))?;
}
_ => {}
}
if meta.is_symlink() {
let target = std::fs::read_link(from)
.into_diagnostic()
.wrap_err_with(|| format!("reading the link {}", from.display()))?;
symlink(&target, to)?;
return Ok(0);
}
if meta.is_dir() {
std::fs::create_dir_all(to)
.into_diagnostic()
.wrap_err_with(|| format!("creating {}", to.display()))?;
carry_metadata(&meta, to);
return Ok(0);
}
if !meta.is_file() {
debug!("skipping {} ({:?})", from.display(), meta.file_type());
return Ok(0);
}
std::fs::copy(from, to)
.into_diagnostic()
.wrap_err_with(|| format!("copying {} to {}", from.display(), to.display()))?;
carry_metadata(&meta, to);
Ok(meta.len())
}
#[cfg(unix)]
fn symlink(target: &Path, at: &Path) -> Result<()> {
std::os::unix::fs::symlink(target, at)
.into_diagnostic()
.wrap_err_with(|| format!("linking {} -> {}", at.display(), target.display()))
}
#[cfg(windows)]
fn symlink(target: &Path, at: &Path) -> Result<()> {
let result = if target.is_dir() {
std::os::windows::fs::symlink_dir(target, at)
} else {
std::os::windows::fs::symlink_file(target, at)
};
result
.into_diagnostic()
.wrap_err_with(|| format!("linking {} -> {}", at.display(), target.display()))
}
fn carry_metadata(meta: &std::fs::Metadata, to: &Path) {
if meta.is_symlink() {
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
if let Err(err) = std::os::unix::fs::chown(to, Some(meta.uid()), Some(meta.gid())) {
debug!("could not set the owner of {}: {err}", to.display());
}
if let Err(err) =
std::fs::set_permissions(to, std::fs::Permissions::from_mode(meta.permissions().mode()))
{
debug!("could not set the mode of {}: {err}", to.display());
}
}
#[cfg(not(unix))]
{
if let Err(err) = std::fs::set_permissions(to, meta.permissions()) {
debug!("could not set the permissions of {}: {err}", to.display());
}
}
if let Ok(mtime) = meta.modified()
&& let Err(err) = filetime::set_file_mtime(to, filetime::FileTime::from(mtime))
{
debug!("could not set the mtime of {}: {err}", to.display());
}
}
pub async fn ensure_readable(capture: &Path) -> Result<()> {
match read_side(capture) {
Ok(Some(entries)) if !entries.is_empty() => Ok(()),
Ok(Some(_)) => bail!(
"the capture at {} is an empty directory, so restoring from it would \
erase the cluster rather than roll it back",
capture.display()
),
Ok(None) => bail!("the capture at {} is not there", capture.display()),
Err(err) => Err(err),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, contents: &str) {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
async fn delta_of(build: impl FnOnce(&Path, &Path)) -> (tempfile::TempDir, PathBuf, PathBuf, Delta) {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
std::fs::create_dir_all(&capture).unwrap();
std::fs::create_dir_all(&dest).unwrap();
build(&capture, &dest);
let delta = compare(&capture, &dest, &[], Decide::Compare).await.unwrap();
(tmp, capture, dest, delta)
}
fn removed(delta: &Delta) -> Vec<&Path> {
delta.remove.iter().map(|entry| entry.rel.as_path()).collect()
}
async fn roll_back(capture: &Path, dest: &Path, skip: &[PathBuf]) {
let delta = compare(capture, dest, skip, Decide::Compare).await.unwrap();
apply(capture, dest, &delta).await.unwrap();
}
#[tokio::test]
async fn identical_trees_have_nothing_to_do() {
let (_tmp, _c, _d, delta) = delta_of(|capture, dest| {
write(capture, "PG_VERSION", "16");
write(capture, "base/1/2345", "page");
write(dest, "PG_VERSION", "16");
write(dest, "base/1/2345", "page");
})
.await;
assert!(!delta.has_work());
}
#[tokio::test]
async fn a_file_written_since_the_freeze_is_removed() {
let (_tmp, _c, _d, delta) = delta_of(|capture, dest| {
write(capture, "PG_VERSION", "16");
write(dest, "PG_VERSION", "16");
write(dest, "base/1/9999", "written after the capture");
})
.await;
let removes = removed(&delta);
assert!(removes.contains(&Path::new("base/1/9999")));
assert!(removes.contains(&Path::new("base")));
let file = removes.iter().position(|p| p.ends_with("9999")).unwrap();
let dir = removes.iter().position(|p| *p == Path::new("base")).unwrap();
assert!(file < dir, "children must be removed before their parents");
}
#[tokio::test]
async fn a_file_deleted_since_the_freeze_comes_back() {
let (_tmp, _c, _d, delta) = delta_of(|capture, dest| {
write(capture, "base/1/2345", "page");
write(dest, "PG_VERSION", "16");
})
.await;
assert!(delta.copy.contains(&PathBuf::from("base/1/2345")));
assert!(delta.mkdir.contains(&PathBuf::from("base")));
assert!(removed(&delta).contains(&Path::new("PG_VERSION")));
}
#[tokio::test]
async fn a_size_change_is_decided_by_the_walk_alone() {
let (_tmp, _c, _d, delta) = delta_of(|capture, dest| {
write(capture, "base/1/2345", "grown since the freeze");
write(dest, "base/1/2345", "short");
})
.await;
assert_eq!(delta.copy, vec![PathBuf::from("base/1/2345")]);
assert_eq!(delta.displaced_bytes, "short".len() as u64);
assert_eq!(delta.copy_bytes, "grown since the freeze".len() as u64);
}
#[tokio::test]
async fn skipped_paths_are_left_out_of_both_sides() {
let (_tmp, _c, _d, delta) = delta_of(|capture, dest| {
write(capture, "PG_VERSION", "16");
write(dest, "PG_VERSION", "9.6");
})
.await;
assert_eq!(delta.copy, vec![PathBuf::from("PG_VERSION")]);
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "PG_VERSION", "16");
write(&dest, "PG_VERSION", "9.6");
let delta = compare(&capture, &dest, &[PathBuf::from("PG_VERSION")], Decide::Compare)
.await
.unwrap();
assert!(!delta.has_work(), "the interlock's file must not be synced");
}
#[tokio::test]
async fn a_skipped_path_under_a_replaced_directory_is_still_skipped() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "data/PG_VERSION", "16");
write(&capture, "data/base/1", "page");
std::fs::create_dir_all(&dest).unwrap();
let delta = compare(&capture, &dest, &[PathBuf::from("data/PG_VERSION")], Decide::Compare)
.await
.unwrap();
assert!(delta.copy.contains(&PathBuf::from("data/base/1")));
assert!(!delta.copy.contains(&PathBuf::from("data/PG_VERSION")));
}
#[tokio::test]
async fn a_skipped_file_keeps_its_directory_from_being_removed() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "PG_VERSION", "16");
write(&dest, "PG_VERSION", "16");
write(&dest, "gone/keep-me", "protected");
write(&dest, "gone/take-me", "post-freeze");
let skip = vec![PathBuf::from("gone/keep-me")];
let delta = compare(&capture, &dest, &skip, Decide::Compare).await.unwrap();
let removes = removed(&delta);
assert!(removes.contains(&Path::new("gone/take-me")));
assert!(!removes.contains(&Path::new("gone/keep-me")), "the skip must hold");
assert!(
!removes.contains(&Path::new("gone")),
"a directory with something kept in it cannot be removed"
);
apply(&capture, &dest, &delta).await.unwrap();
assert!(dest.join("gone/keep-me").exists());
assert!(!dest.join("gone/take-me").exists());
}
#[tokio::test]
async fn a_directory_with_nothing_kept_in_it_still_goes() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "PG_VERSION", "16");
write(&dest, "PG_VERSION", "16");
write(&dest, "gone/take-me", "post-freeze");
roll_back(&capture, &dest, &[PathBuf::from("elsewhere")]).await;
assert!(!dest.join("gone").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn a_directory_whose_mode_drifted_is_put_back() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "data/PG_VERSION", "16");
write(&dest, "data/PG_VERSION", "16");
std::fs::set_permissions(capture.join("data"), std::fs::Permissions::from_mode(0o700))
.unwrap();
std::fs::set_permissions(dest.join("data"), std::fs::Permissions::from_mode(0o777)).unwrap();
roll_back(&capture, &dest, &[]).await;
let mode = std::fs::metadata(dest.join("data")).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700, "got {mode:o}");
}
#[cfg(unix)]
#[tokio::test]
async fn ownership_is_set_before_the_mode_so_setgid_survives() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "setgid-dir/f", "x");
std::fs::set_permissions(capture.join("setgid-dir"), std::fs::Permissions::from_mode(0o2755))
.unwrap();
std::fs::create_dir_all(&dest).unwrap();
roll_back(&capture, &dest, &[]).await;
let mode = std::fs::metadata(dest.join("setgid-dir")).unwrap().permissions().mode();
assert_eq!(mode & 0o7777, 0o2755, "the setgid bit must come back, got {mode:o}");
}
#[tokio::test]
async fn same_size_different_mtime_but_same_content_is_not_copied() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "f", "sixteen bytes!!!");
write(&dest, "f", "sixteen bytes!!!");
filetime::set_file_mtime(dest.join("f"), filetime::FileTime::from_unix_time(1, 0)).unwrap();
let delta = compare(&capture, &dest, &[], Decide::Compare).await.unwrap();
assert!(!delta.has_work(), "identical contents must not be copied");
}
#[tokio::test]
async fn same_size_different_mtime_and_different_content_is_copied() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "f", "sixteen bytes!!!");
write(&dest, "f", "SIXTEEN BYTES!!!");
filetime::set_file_mtime(dest.join("f"), filetime::FileTime::from_unix_time(1, 0)).unwrap();
let delta = compare(&capture, &dest, &[], Decide::Compare).await.unwrap();
assert_eq!(delta.copy, vec![PathBuf::from("f")]);
}
#[tokio::test]
async fn identical_mtime_and_size_is_skipped_without_reading() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "f", "sixteen bytes!!!");
write(&dest, "f", "SIXTEEN BYTES!!!");
let when = filetime::FileTime::from_unix_time(1, 0);
filetime::set_file_mtime(capture.join("f"), when).unwrap();
filetime::set_file_mtime(dest.join("f"), when).unwrap();
let delta = compare(&capture, &dest, &[], Decide::Compare).await.unwrap();
assert!(!delta.has_work());
}
#[tokio::test]
async fn a_named_basis_overrides_a_matching_modification_time() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "f", "sixteen bytes!!!");
write(&dest, "f", "SIXTEEN BYTES!!!");
let when = filetime::FileTime::from_unix_time(1, 0);
filetime::set_file_mtime(capture.join("f"), when).unwrap();
filetime::set_file_mtime(dest.join("f"), when).unwrap();
let named = Decide::Named(BTreeSet::from([PathBuf::from("f")]));
let delta = compare(&capture, &dest, &[], named).await.unwrap();
assert_eq!(delta.copy, vec![PathBuf::from("f")]);
}
#[tokio::test]
async fn a_named_basis_leaves_alone_what_it_does_not_name() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "f", "sixteen bytes!!!");
write(&dest, "f", "SIXTEEN BYTES!!!");
filetime::set_file_mtime(dest.join("f"), filetime::FileTime::from_unix_time(1, 0)).unwrap();
let delta = compare(&capture, &dest, &[], Decide::Named(BTreeSet::new()))
.await
.unwrap();
assert!(!delta.has_work());
}
#[tokio::test]
async fn applying_makes_the_live_tree_match_the_capture() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "PG_VERSION", "16");
write(&capture, "base/1/2345", "the captured page");
write(&capture, "global/pg_control", "control");
write(&dest, "PG_VERSION", "16");
write(&dest, "base/1/2345", "a page written after the freeze");
write(&dest, "base/1/9999", "a relation created after the freeze");
roll_back(&capture, &dest, &[]).await;
assert_eq!(std::fs::read_to_string(dest.join("base/1/2345")).unwrap(), "the captured page");
assert_eq!(std::fs::read_to_string(dest.join("global/pg_control")).unwrap(), "control");
assert!(!dest.join("base/1/9999").exists(), "post-freeze relation must go");
}
#[tokio::test]
async fn applying_twice_converges() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "base/1/2345", "the captured page");
write(&dest, "base/1/2345", "diverged");
write(&dest, "stray", "written after the freeze");
roll_back(&capture, &dest, &[]).await;
roll_back(&capture, &dest, &[]).await;
let delta = compare(&capture, &dest, &[], Decide::Compare).await.unwrap();
assert!(!delta.has_work());
assert_eq!(std::fs::read_to_string(dest.join("base/1/2345")).unwrap(), "the captured page");
assert!(!dest.join("stray").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn a_symlink_is_restored_as_a_link_not_its_target() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
std::fs::create_dir_all(capture.join("pg_tblspc")).unwrap();
std::fs::create_dir_all(&dest).unwrap();
std::os::unix::fs::symlink("/mnt/fast/tblspc", capture.join("pg_tblspc/16400")).unwrap();
roll_back(&capture, &dest, &[]).await;
let link = dest.join("pg_tblspc/16400");
assert!(link.symlink_metadata().unwrap().is_symlink());
assert_eq!(std::fs::read_link(&link).unwrap(), Path::new("/mnt/fast/tblspc"));
}
#[cfg(unix)]
#[tokio::test]
async fn a_captured_directory_over_a_live_symlink_never_reaches_its_target() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(capture.join("pg_wal")).unwrap();
std::fs::write(capture.join("pg_wal/000001"), "captured wal").unwrap();
std::fs::create_dir_all(&dest).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("important"), "data outside the tree").unwrap();
std::os::unix::fs::symlink(&outside, dest.join("pg_wal")).unwrap();
let delta = compare(&capture, &dest, &[], Decide::Compare).await.unwrap();
assert_eq!(
removed(&delta),
vec![Path::new("pg_wal")],
"only the link itself goes, never anything through it"
);
apply(&capture, &dest, &delta).await.unwrap();
assert!(
outside.join("important").exists(),
"data outside the restored tree must survive"
);
assert_eq!(
std::fs::read_to_string(dest.join("pg_wal/000001")).unwrap(),
"captured wal"
);
}
#[tokio::test]
async fn a_captured_directory_over_a_live_file_does_not_abort_the_comparison() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
std::fs::create_dir_all(capture.join("pg_notify")).unwrap();
std::fs::write(capture.join("pg_notify/0000"), "captured").unwrap();
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("pg_notify"), "a file where a directory belongs").unwrap();
roll_back(&capture, &dest, &[]).await;
assert!(dest.join("pg_notify").is_dir());
assert_eq!(std::fs::read_to_string(dest.join("pg_notify/0000")).unwrap(), "captured");
}
#[tokio::test]
async fn an_empty_captured_directory_comes_back() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
std::fs::create_dir_all(capture.join("pg_notify")).unwrap();
std::fs::create_dir_all(&dest).unwrap();
roll_back(&capture, &dest, &[]).await;
assert!(dest.join("pg_notify").is_dir());
}
#[tokio::test]
async fn an_entry_that_changed_kind_is_replaced_rather_than_written_through() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let dest = tmp.path().join("dest");
write(&capture, "thing", "a file in the capture");
std::fs::create_dir_all(dest.join("thing")).unwrap();
std::fs::write(dest.join("thing/inside"), "should not survive").unwrap();
roll_back(&capture, &dest, &[]).await;
assert!(dest.join("thing").is_file());
assert_eq!(
std::fs::read_to_string(dest.join("thing")).unwrap(),
"a file in the capture"
);
}
#[test]
fn content_comparison_stops_at_the_first_difference() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a");
let b = tmp.path().join("b");
let ours = vec![b'x'; 4 * COMPARE_CHUNK];
let mut theirs = ours.clone();
theirs[0] = b'y';
std::fs::write(&a, &ours).unwrap();
std::fs::write(&b, &theirs).unwrap();
let mut buf_a = vec![0u8; COMPARE_CHUNK];
let mut buf_b = vec![0u8; COMPARE_CHUNK];
let result = same_contents(&a, &b, &mut buf_a, &mut buf_b);
assert!(!result.same);
assert!(
result.bytes <= 2 * COMPARE_CHUNK as u64,
"read {} bytes; should have stopped after the first chunk of each",
result.bytes
);
}
#[test]
fn content_comparison_reads_identical_files_whole() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a");
let b = tmp.path().join("b");
std::fs::write(&a, vec![b'x'; 3000]).unwrap();
std::fs::write(&b, vec![b'x'; 3000]).unwrap();
let mut buf_a = vec![0u8; COMPARE_CHUNK];
let mut buf_b = vec![0u8; COMPARE_CHUNK];
let result = same_contents(&a, &b, &mut buf_a, &mut buf_b);
assert!(result.same);
assert_eq!(result.bytes, 6000, "both sides, in full");
}
#[test]
fn an_unreadable_side_counts_as_a_difference() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a");
std::fs::write(&a, "x").unwrap();
let mut buf_a = vec![0u8; 64];
let mut buf_b = vec![0u8; 64];
assert!(!same_contents(&a, &tmp.path().join("missing"), &mut buf_a, &mut buf_b).same);
}
#[test]
fn net_growth_nets_removals_off_against_copies() {
let delta = Delta {
copy_bytes: 1000,
displaced_bytes: 400,
remove_bytes: 200,
..Default::default()
};
assert_eq!(delta.net_growth(), 400);
assert_eq!(delta.written_bytes(), 1000);
}
#[test]
fn a_delta_that_frees_more_than_it_adds_needs_no_room() {
let delta = Delta {
copy_bytes: 100,
displaced_bytes: 100,
remove_bytes: 5000,
..Default::default()
};
assert_eq!(delta.net_growth(), 0);
}
#[tokio::test]
async fn an_empty_capture_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
std::fs::create_dir_all(&capture).unwrap();
let err = ensure_readable(&capture).await.unwrap_err().to_string();
assert!(err.contains("erase the cluster"), "got: {err}");
}
#[tokio::test]
async fn an_absent_capture_is_refused() {
let err = ensure_readable(Path::new("/nonexistent/capture"))
.await
.unwrap_err()
.to_string();
assert!(err.contains("is not there"), "got: {err}");
}
}