use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
Dir,
File,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Prepared {
Migrated,
Fresh,
Existing,
}
pub const RETIRED_SUFFIX: &str = ".pre-ext4";
const PARTIAL_SUFFIX: &str = ".partial";
pub fn prepare_entry(
volume_root: &Path,
target: &Path,
name: &str,
kind: EntryKind,
) -> io::Result<Prepared> {
let final_path = volume_root.join(name);
let partial = volume_root.join(format!("{name}{PARTIAL_SUFFIX}"));
let prepared = if final_path.symlink_metadata().is_ok() {
Prepared::Existing
} else {
remove_existing(&partial)?;
if has_content(target, kind) {
copy_entry(target, &partial, kind)?;
fs::rename(&partial, &final_path)?;
fs::File::open(volume_root)?.sync_all()?;
Prepared::Migrated
} else {
create_entry(&final_path, kind)?;
Prepared::Fresh
}
};
if has_content(target, kind) {
fs::rename(target, free_retired_path(target)?)?;
}
ensure_stub(target, kind)?;
Ok(prepared)
}
fn has_content(path: &Path, kind: EntryKind) -> bool {
match kind {
EntryKind::Dir => fs::read_dir(path).is_ok_and(|mut dir| dir.next().is_some()),
EntryKind::File => fs::metadata(path).is_ok_and(|meta| meta.len() > 0),
}
}
fn remove_existing(path: &Path) -> io::Result<()> {
match path.symlink_metadata() {
Ok(meta) if meta.is_dir() => fs::remove_dir_all(path),
Ok(_) => fs::remove_file(path),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn create_entry(path: &Path, kind: EntryKind) -> io::Result<()> {
match kind {
EntryKind::Dir => fs::create_dir_all(path),
EntryKind::File => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map(|_| ())
}
}
}
fn ensure_stub(target: &Path, kind: EntryKind) -> io::Result<()> {
create_entry(target, kind)
}
fn copy_entry(src: &Path, dst: &Path, kind: EntryKind) -> io::Result<()> {
match kind {
EntryKind::Dir => copy_dir_synced(src, dst),
EntryKind::File => {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
copy_file_synced(src, dst)
}
}
}
fn copy_dir_synced(src: &Path, dst: &Path) -> io::Result<()> {
fs::create_dir_all(dst)?;
fs::set_permissions(dst, fs::metadata(src)?.permissions())?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let to = dst.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_dir() {
copy_dir_synced(&entry.path(), &to)?;
} else if file_type.is_symlink() {
std::os::unix::fs::symlink(fs::read_link(entry.path())?, &to)?;
} else {
copy_file_synced(&entry.path(), &to)?;
}
}
fs::File::open(dst)?.sync_all()
}
fn copy_file_synced(src: &Path, dst: &Path) -> io::Result<()> {
fs::copy(src, dst)?;
fs::File::open(dst)?.sync_all()
}
fn free_retired_path(target: &Path) -> io::Result<PathBuf> {
let base = path_with_suffix(target, RETIRED_SUFFIX);
if base.symlink_metadata().is_err() {
return Ok(base);
}
for n in 1..100u32 {
let candidate = path_with_suffix(target, &format!("{RETIRED_SUFFIX}.{n}"));
if candidate.symlink_metadata().is_err() {
return Ok(candidate);
}
}
Err(io::Error::other(format!(
"no free {RETIRED_SUFFIX} backup name next to {}",
target.display()
)))
}
fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut os = path.as_os_str().to_owned();
os.push(suffix);
PathBuf::from(os)
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> (tempfile::TempDir, PathBuf, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let volume = tmp.path().join("volume");
let data = tmp.path().join("data");
fs::create_dir_all(&volume).unwrap();
fs::create_dir_all(&data).unwrap();
(tmp, volume, data)
}
fn seed_dir(dir: &Path) {
fs::create_dir_all(dir.join("sub")).unwrap();
fs::write(dir.join("meta.db"), b"bolt").unwrap();
fs::write(dir.join("sub/inner.json"), b"{}").unwrap();
}
#[test]
fn fresh_install_creates_empty_entries() {
let (_tmp, volume, data) = setup();
let target = data.join("network");
let out = prepare_entry(&volume, &target, "docker-network", EntryKind::Dir).unwrap();
assert_eq!(out, Prepared::Fresh);
assert!(volume.join("docker-network").is_dir());
assert!(target.is_dir(), "mountpoint stub must exist");
let file_target = data.join("overlayfs/metadata.db");
let out = prepare_entry(
&volume,
&file_target,
"snapshotter-metadata.db",
EntryKind::File,
)
.unwrap();
assert_eq!(out, Prepared::Fresh);
assert_eq!(
fs::metadata(volume.join("snapshotter-metadata.db"))
.unwrap()
.len(),
0
);
assert_eq!(fs::metadata(&file_target).unwrap().len(), 0);
}
#[test]
fn populated_source_is_migrated_and_retired() {
let (_tmp, volume, data) = setup();
let target = data.join("network");
seed_dir(&target);
let out = prepare_entry(&volume, &target, "docker-network", EntryKind::Dir).unwrap();
assert_eq!(out, Prepared::Migrated);
let migrated = volume.join("docker-network");
assert_eq!(fs::read(migrated.join("meta.db")).unwrap(), b"bolt");
assert_eq!(fs::read(migrated.join("sub/inner.json")).unwrap(), b"{}");
assert!(data.join("network.pre-ext4").join("meta.db").exists());
assert!(fs::read_dir(&target).unwrap().next().is_none());
}
#[test]
fn torn_copy_is_discarded_and_redone() {
let (_tmp, volume, data) = setup();
let target = data.join("network");
seed_dir(&target);
fs::create_dir_all(volume.join("docker-network.partial")).unwrap();
fs::write(volume.join("docker-network.partial/meta.db"), b"torn").unwrap();
let out = prepare_entry(&volume, &target, "docker-network", EntryKind::Dir).unwrap();
assert_eq!(out, Prepared::Migrated);
assert!(!volume.join("docker-network.partial").exists());
assert_eq!(
fs::read(volume.join("docker-network/meta.db")).unwrap(),
b"bolt"
);
}
#[test]
fn crash_between_copy_and_retire_converges() {
let (_tmp, volume, data) = setup();
let target = data.join("network");
seed_dir(&target);
fs::create_dir_all(volume.join("docker-network")).unwrap();
fs::write(volume.join("docker-network/meta.db"), b"bolt").unwrap();
let out = prepare_entry(&volume, &target, "docker-network", EntryKind::Dir).unwrap();
assert_eq!(out, Prepared::Existing);
assert!(data.join("network.pre-ext4/meta.db").exists());
assert!(fs::read_dir(&target).unwrap().next().is_none());
}
#[test]
fn replay_after_full_migration_is_a_noop() {
let (_tmp, volume, data) = setup();
let target = data.join("network");
seed_dir(&target);
prepare_entry(&volume, &target, "docker-network", EntryKind::Dir).unwrap();
let out = prepare_entry(&volume, &target, "docker-network", EntryKind::Dir).unwrap();
assert_eq!(out, Prepared::Existing);
assert!(
!data.join("network.pre-ext4.1").exists(),
"no second backup"
);
}
#[test]
fn blank_volume_after_retire_starts_fresh_not_stale() {
let (_tmp, volume, data) = setup();
let target = data.join("network");
seed_dir(&target);
prepare_entry(&volume, &target, "docker-network", EntryKind::Dir).unwrap();
fs::remove_dir_all(volume.join("docker-network")).unwrap();
let out = prepare_entry(&volume, &target, "docker-network", EntryKind::Dir).unwrap();
assert_eq!(out, Prepared::Fresh);
assert!(
fs::read_dir(volume.join("docker-network"))
.unwrap()
.next()
.is_none()
);
}
#[test]
fn interim_state_retires_under_numbered_backup() {
let (_tmp, volume, data) = setup();
let target = data.join("network");
seed_dir(&target);
prepare_entry(&volume, &target, "docker-network", EntryKind::Dir).unwrap();
fs::write(target.join("meta.db"), b"interim").unwrap();
let out = prepare_entry(&volume, &target, "docker-network", EntryKind::Dir).unwrap();
assert_eq!(out, Prepared::Existing);
assert_eq!(
fs::read(data.join("network.pre-ext4.1/meta.db")).unwrap(),
b"interim"
);
assert!(fs::read_dir(&target).unwrap().next().is_none());
}
#[test]
fn file_entry_migrates_and_stubs() {
let (_tmp, volume, data) = setup();
let target = data.join("overlayfs/metadata.db");
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&target, b"boltdb-pages").unwrap();
fs::write(volume.join("snapshotter-metadata.db.partial"), b"torn").unwrap();
let out =
prepare_entry(&volume, &target, "snapshotter-metadata.db", EntryKind::File).unwrap();
assert_eq!(out, Prepared::Migrated);
assert!(!volume.join("snapshotter-metadata.db.partial").exists());
assert_eq!(
fs::read(volume.join("snapshotter-metadata.db")).unwrap(),
b"boltdb-pages"
);
assert_eq!(
fs::read(data.join("overlayfs/metadata.db.pre-ext4")).unwrap(),
b"boltdb-pages"
);
assert_eq!(fs::metadata(&target).unwrap().len(), 0, "0-byte stub");
}
}