use crate::tree::TreeError;
use filetime::FileTime;
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::fs;
use std::io;
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirReplace {
Any,
EmptyOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MissingParent {
Create,
Stop,
}
#[derive(Debug)]
pub enum Source<'a> {
Bytes(&'a [u8]),
File(&'a Path),
}
enum Undo {
Created(PathBuf),
CreatedDir(PathBuf),
Overwritten {
path: PathBuf,
copy: PathBuf,
mode: u32,
mtime: FileTime,
},
MovedAside {
path: PathBuf,
aside: PathBuf,
},
DirMode {
path: PathBuf,
mode: u32,
},
DirAttrsSet {
path: PathBuf,
mode: u32,
mtime: FileTime,
},
}
struct SettleDir {
path: PathBuf,
mode: Option<u32>,
mtime: Option<FileTime>,
prior: Option<(u32, FileTime)>,
}
pub struct TreeTxn {
root: PathBuf,
root_canonical: PathBuf,
dirs: DirReplace,
undo: Vec<Undo>,
backups: HashMap<PathBuf, PathBuf>,
next_backup: u64,
settle: Vec<SettleDir>,
damaged: Vec<String>,
}
pub fn transact<T>(
root: &Path,
dirs: DirReplace,
f: impl FnOnce(&mut TreeTxn) -> Result<T, TreeError>,
) -> Result<T, TreeError> {
let mut txn = TreeTxn {
root: root.to_path_buf(),
root_canonical: root.canonicalize()?,
dirs,
undo: Vec::new(),
backups: HashMap::new(),
next_backup: 0,
settle: Vec::new(),
damaged: Vec::new(),
};
match f(&mut txn).and_then(|value| txn.settle_dirs().map(|()| value)) {
Ok(value) => {
txn.commit();
Ok(value)
}
Err(cause) => Err(txn.roll_back(cause)),
}
}
fn holds(
path: &Path,
meta: &fs::Metadata,
source: &Source<'_>,
mode: u32,
mtime: FileTime,
) -> Result<bool, TreeError> {
if meta.permissions().mode() & 0o7777 != mode & 0o7777
|| FileTime::from_last_modification_time(meta) != mtime
{
return Ok(false);
}
let expected_len = match source {
Source::Bytes(bytes) => bytes.len() as u64,
Source::File(src) => fs::metadata(src)?.len(),
};
if meta.len() != expected_len {
return Ok(false);
}
let current = fs::read(path)?;
Ok(match source {
Source::Bytes(bytes) => current == *bytes,
Source::File(src) => current == fs::read(src)?,
})
}
impl TreeTxn {
pub fn write_file(
&mut self,
rel: &Path,
source: Source<'_>,
mode: u32,
mtime: FileTime,
) -> Result<(), TreeError> {
check_relative(rel)?;
self.walk_parent(rel, MissingParent::Create)?;
let target = self.root.join(rel);
match fs::symlink_metadata(&target) {
Err(e) if e.kind() == io::ErrorKind::NotFound => {
self.undo.push(Undo::Created(target.clone()));
}
Err(e) => return Err(e.into()),
Ok(meta) if meta.file_type().is_symlink() => {
let resolved = self.resolve_in_root(&target, rel)?;
self.back_up_file(&resolved)?;
}
Ok(meta) if meta.is_file() => {
if holds(&target, &meta, &source, mode, mtime)? {
return Ok(());
}
self.back_up_file(&target)?
}
Ok(meta) => {
if meta.is_dir() {
self.check_replaceable_dir(&target, rel)?;
}
self.move_aside(&target)?;
self.undo.push(Undo::Created(target.clone()));
}
}
match source {
Source::Bytes(bytes) => fs::write(&target, bytes)?,
Source::File(path) => {
fs::copy(path, &target)?;
}
}
fs::set_permissions(&target, fs::Permissions::from_mode(mode))?;
filetime::set_file_mtime(&target, mtime)?;
Ok(())
}
pub fn create_dir(&mut self, rel: &Path, mode: u32, mtime: FileTime) -> Result<(), TreeError> {
check_relative(rel)?;
self.walk_parent(rel, MissingParent::Create)?;
let target = self.root.join(rel);
let occupied = || TreeError::NotADirectory {
path: rel.to_path_buf(),
};
match fs::symlink_metadata(&target) {
Err(e) if e.kind() == io::ErrorKind::NotFound => {
fs::create_dir(&target)?;
self.undo.push(Undo::CreatedDir(target.clone()));
self.settle.push(SettleDir {
path: target,
mode: Some(mode),
mtime: Some(mtime),
prior: None,
});
Ok(())
}
Err(e) => Err(e.into()),
Ok(meta) if meta.is_dir() => Ok(()),
Ok(meta) if meta.file_type().is_symlink() => {
if self.resolve_in_root(&target, rel)?.is_dir() {
Ok(())
} else {
Err(occupied())
}
}
Ok(_) => Err(occupied()),
}
}
pub fn create_symlink(
&mut self,
rel: &Path,
target: &Path,
mtime: FileTime,
) -> Result<(), TreeError> {
check_relative(rel)?;
self.walk_parent(rel, MissingParent::Create)?;
let path = self.root.join(rel);
match fs::symlink_metadata(&path) {
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
Ok(_) => {
return Err(TreeError::Occupied {
path: rel.to_path_buf(),
})
}
}
std::os::unix::fs::symlink(target, &path)?;
self.undo.push(Undo::Created(path.clone()));
let atime = FileTime::from_last_access_time(&fs::symlink_metadata(&path)?);
filetime::set_symlink_file_times(&path, atime, mtime)?;
Ok(())
}
pub fn set_dir_attrs(
&mut self,
rel: &Path,
mode: Option<u32>,
mtime: Option<FileTime>,
) -> Result<(), TreeError> {
check_relative(rel)?;
let not_a_dir = || TreeError::NotADirectory {
path: rel.to_path_buf(),
};
if !self.walk_parent(rel, MissingParent::Stop)? {
return Err(not_a_dir());
}
let path = self.root.join(rel);
let meta = match fs::symlink_metadata(&path) {
Ok(meta) if meta.is_dir() => meta,
Ok(_) => return Err(not_a_dir()),
Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(not_a_dir()),
Err(e) => return Err(e.into()),
};
let prior_mode = meta.permissions().mode() & 0o7777;
let euid = unsafe { libc::geteuid() };
if euid != 0 && meta.uid() != euid {
return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
}
refuse_unsettable(&path)?;
let prior_mtime = FileTime::from_last_modification_time(&meta);
self.undo.push(Undo::DirAttrsSet {
path: path.clone(),
mode: prior_mode,
mtime: prior_mtime,
});
self.settle.push(SettleDir {
path,
mode,
mtime,
prior: Some((prior_mode, prior_mtime)),
});
Ok(())
}
fn settle_dirs(&mut self) -> Result<(), TreeError> {
let mut dirs = std::mem::take(&mut self.settle);
dirs.sort_by_key(|dir| std::cmp::Reverse(dir.path.components().count()));
for dir in &dirs {
if let Some(backup) = self.backups.remove(&dir.path) {
if let Err(e) = remove_backup(&backup) {
self.damaged
.push(format!("partly removed backup {}: {e}", backup.display()));
self.backups.insert(dir.path.clone(), backup);
return Err(e.into());
}
}
}
for dir in dirs {
if let Some(mode) = dir.mode {
fs::set_permissions(&dir.path, fs::Permissions::from_mode(mode))?;
self.undo.push(Undo::DirMode {
path: dir.path.clone(),
mode: dir.prior.map_or(0o700, |(prior_mode, _)| prior_mode),
});
}
if let Some(mtime) = dir.mtime.or(dir.prior.map(|(_, prior_mtime)| prior_mtime)) {
set_dir_mtime(&dir.path, mtime)?;
}
}
Ok(())
}
pub fn remove(&mut self, rel: &Path) -> Result<(), TreeError> {
check_relative(rel)?;
if !self.walk_parent(rel, MissingParent::Stop)? {
return Ok(());
}
let target = self.root.join(rel);
match fs::symlink_metadata(&target) {
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
Ok(meta) => {
if meta.is_dir() {
self.check_replaceable_dir(&target, rel)?;
}
self.move_aside(&target)
}
}
}
fn walk_parent(&mut self, rel: &Path, missing: MissingParent) -> Result<bool, TreeError> {
let Some(parent) = rel.parent() else {
return Ok(true);
};
let mut current = self.root.clone();
for component in parent.components() {
current.push(component);
let not_a_dir = || TreeError::NotADirectory {
path: current
.strip_prefix(&self.root)
.unwrap_or(¤t)
.to_path_buf(),
};
let is_dir = match fs::symlink_metadata(¤t) {
Err(e) if e.kind() == io::ErrorKind::NotFound => {
if missing == MissingParent::Stop {
return Ok(false);
}
fs::create_dir(¤t)?;
self.undo.push(Undo::CreatedDir(current.clone()));
true
}
Err(e) => return Err(e.into()),
Ok(meta) if meta.file_type().is_symlink() => {
self.resolve_in_root(¤t, rel)?.is_dir()
}
Ok(meta) => meta.is_dir(),
};
if !is_dir {
return match missing {
MissingParent::Stop => Ok(false),
MissingParent::Create => Err(not_a_dir()),
};
}
}
Ok(true)
}
fn resolve_in_root(&self, link: &Path, rel: &Path) -> Result<PathBuf, TreeError> {
let escape = || TreeError::PathEscape {
path: rel.to_path_buf(),
};
let resolved = link.canonicalize().map_err(|_| escape())?;
if !resolved.starts_with(&self.root_canonical) {
return Err(escape());
}
Ok(resolved)
}
fn check_replaceable_dir(&self, dir: &Path, rel: &Path) -> Result<(), TreeError> {
if self.dirs == DirReplace::EmptyOnly && fs::read_dir(dir)?.next().is_some() {
return Err(TreeError::NonEmptyDirectory {
path: rel.to_path_buf(),
});
}
Ok(())
}
fn back_up_file(&mut self, path: &Path) -> Result<(), TreeError> {
fs::OpenOptions::new().write(true).open(path)?;
let meta = fs::metadata(path)?;
let copy = self.backup_slot_in(std::env::temp_dir())?;
fs::copy(path, ©)?;
self.undo.push(Undo::Overwritten {
path: path.to_path_buf(),
copy,
mode: meta.permissions().mode() & 0o7777,
mtime: FileTime::from_last_modification_time(&meta),
});
Ok(())
}
fn move_aside(&mut self, path: &Path) -> Result<(), TreeError> {
let aside = self.backup_slot(path)?;
fs::rename(path, &aside)?;
self.undo.push(Undo::MovedAside {
path: path.to_path_buf(),
aside,
});
Ok(())
}
fn backup_slot(&mut self, path: &Path) -> io::Result<PathBuf> {
let parent = path
.parent()
.ok_or_else(|| io::Error::other(format!("no parent for {}", path.display())))?
.to_path_buf();
self.backup_slot_in(parent)
}
fn backup_slot_in(&mut self, parent: PathBuf) -> io::Result<PathBuf> {
let dir = match self.backups.entry(parent) {
Entry::Occupied(e) => e.into_mut(),
Entry::Vacant(e) => {
let dir = tempfile::Builder::new()
.prefix(".verdant-undo-")
.tempdir_in(e.key())?
.keep();
e.insert(dir)
}
};
self.next_backup += 1;
Ok(dir.join(self.next_backup.to_string()))
}
fn commit(self) {
for dir in self.backups.into_values() {
if let Err(e) = remove_backup(&dir) {
eprintln!(
"verdant: could not remove replay backup {}: {e}",
dir.display()
);
}
}
}
fn drop_emptied_backup(&mut self, dir: &Path, failures: &mut Vec<String>) {
if let Some(backup) = self.backups.remove(dir) {
if let Err(e) = fs::remove_dir(&backup) {
failures.push(format!("{}: {e}", backup.display()));
self.backups.insert(dir.to_path_buf(), backup);
}
}
}
fn roll_back(mut self, cause: TreeError) -> TreeError {
let mut failures = std::mem::take(&mut self.damaged);
while let Some(step) = self.undo.pop() {
if let Undo::DirAttrsSet { path, .. } = &step {
if failures.is_empty() {
self.drop_emptied_backup(path, &mut failures);
}
}
if let Err((path, e)) = undo(step) {
failures.push(format!("{}: {e}", path.display()));
}
}
if failures.is_empty() {
self.commit();
return cause;
}
for dir in self.backups.into_values() {
failures.push(format!("backups kept in {}", dir.display()));
}
TreeError::RollbackIncomplete {
cause: Box::new(cause),
failures,
}
}
}
fn undo(step: Undo) -> Result<(), (PathBuf, io::Error)> {
match step {
Undo::Created(path) => match fs::remove_file(&path) {
Err(e) if e.kind() != io::ErrorKind::NotFound => Err((path, e)),
_ => Ok(()),
},
Undo::CreatedDir(path) => fs::remove_dir(&path).map_err(|e| (path, e)),
Undo::Overwritten {
path,
copy,
mode,
mtime,
} => fs::set_permissions(&path, fs::Permissions::from_mode(mode | 0o200))
.and_then(|()| fs::copy(©, &path))
.and_then(|_| set_mode_checked(&path, mode))
.and_then(|()| filetime::set_file_mtime(&path, mtime))
.map_err(|e| (path, e)),
Undo::MovedAside { path, aside } => fs::rename(&aside, &path).map_err(|e| (path, e)),
Undo::DirMode { path, mode } => restore_dir_mode(&path, mode).map_err(|e| (path, e)),
Undo::DirAttrsSet { path, mode, mtime } => restore_dir_mode(&path, mode)
.and_then(|()| {
let now = fs::symlink_metadata(&path)?;
if FileTime::from_last_modification_time(&now) == mtime {
Ok(())
} else {
set_dir_mtime(&path, mtime)
}
})
.map_err(|e| (path, e)),
}
}
fn set_dir_mtime(path: &Path, mtime: FileTime) -> io::Result<()> {
let c = std::ffi::CString::new(path.as_os_str().as_bytes())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let times = [
libc::timespec {
tv_sec: 0,
tv_nsec: libc::UTIME_OMIT,
},
libc::timespec {
tv_sec: mtime.unix_seconds() as libc::time_t,
tv_nsec: mtime.nanoseconds() as libc::c_long,
},
];
if unsafe { libc::utimensat(libc::AT_FDCWD, c.as_ptr(), times.as_ptr(), 0) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn restore_dir_mode(path: &Path, mode: u32) -> io::Result<()> {
if fs::symlink_metadata(path)?.permissions().mode() & 0o7777 == mode {
Ok(())
} else {
set_mode_checked(path, mode)
}
}
fn set_mode_checked(path: &Path, mode: u32) -> io::Result<()> {
fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
#[cfg(test)]
if CHMOD_CLEARS_SETGID.get() {
fs::set_permissions(path, fs::Permissions::from_mode(mode & !0o2000))?;
}
let now = fs::symlink_metadata(path)?.permissions().mode() & 0o7777;
if now == mode & 0o7777 {
Ok(())
} else {
Err(io::Error::other(format!(
"mode {now:o} where {:o} was restored",
mode & 0o7777
)))
}
}
#[cfg(test)]
thread_local! {
static BACKUP_REMOVAL_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
static CHMOD_CLEARS_SETGID: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
static INJECTED_DIR_FLAGS: std::cell::Cell<Option<(libc::c_int, libc::c_ulong)>> =
const { std::cell::Cell::new(None) };
}
const FS_IMMUTABLE_FL: libc::c_int = 0x10;
const FS_APPEND_FL: libc::c_int = 0x20;
fn refuse_unsettable(dir: &Path) -> io::Result<()> {
let (inode, mount) = dir_flags(dir)?;
if inode & (FS_APPEND_FL | FS_IMMUTABLE_FL) != 0 {
return Err(io::Error::from_raw_os_error(libc::EPERM));
}
if mount & libc::ST_RDONLY != 0 {
return Err(io::Error::from_raw_os_error(libc::EROFS));
}
Ok(())
}
fn dir_flags(dir: &Path) -> io::Result<(libc::c_int, libc::c_ulong)> {
#[cfg(test)]
if let Some(flags) = INJECTED_DIR_FLAGS.get() {
return Ok(flags);
}
let c = std::ffi::CString::new(dir.as_os_str().as_bytes())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let mut stx: libc::statx = unsafe { std::mem::zeroed() };
let rc = unsafe {
libc::statx(
libc::AT_FDCWD,
c.as_ptr(),
libc::AT_SYMLINK_NOFOLLOW,
libc::STATX_BASIC_STATS,
&mut stx,
)
};
if rc != 0 {
return Err(io::Error::last_os_error());
}
let wanted = (libc::STATX_ATTR_APPEND | libc::STATX_ATTR_IMMUTABLE) as u64;
let inode = if stx.stx_attributes_mask & wanted == wanted {
(stx.stx_attributes & wanted) as libc::c_int
} else {
ioctl_flags(dir)?
};
let mut mount: libc::statvfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statvfs(c.as_ptr(), &mut mount) } != 0 {
return Err(io::Error::last_os_error());
}
Ok((inode, mount.f_flag))
}
fn ioctl_flags(dir: &Path) -> io::Result<libc::c_int> {
let file = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
.open(dir)?;
let mut inode: libc::c_int = 0;
if unsafe { libc::ioctl(file.as_raw_fd(), libc::FS_IOC_GETFLAGS, &mut inode) } != 0 {
let e = io::Error::last_os_error();
match e.raw_os_error() {
Some(libc::ENOTTY | libc::EOPNOTSUPP | libc::EINVAL | libc::ENOSYS) => return Ok(0),
_ => return Err(e),
}
}
Ok(inode)
}
fn remove_backup(backup: &Path) -> io::Result<()> {
give_owner_rwx(backup)?;
#[cfg(test)]
if BACKUP_REMOVAL_FAILS.get() {
return Err(io::Error::other("injected"));
}
fs::remove_dir_all(backup)
}
fn give_owner_rwx(dir: &Path) -> io::Result<()> {
let mode = fs::symlink_metadata(dir)?.permissions().mode() & 0o7777;
if mode & 0o700 != 0o700 {
fs::set_permissions(dir, fs::Permissions::from_mode(mode | 0o700))?;
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
give_owner_rwx(&entry.path())?;
}
}
Ok(())
}
fn check_relative(rel: &Path) -> Result<(), TreeError> {
let normal = rel.components().next().is_some()
&& rel.components().all(|c| matches!(c, Component::Normal(_)));
if normal {
Ok(())
} else {
Err(TreeError::PathEscape {
path: rel.to_path_buf(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
type Attempt<'a> = &'a dyn Fn(&mut TreeTxn) -> Result<(), TreeError>;
fn at(secs: i64) -> FileTime {
FileTime::from_unix_time(secs, 0)
}
#[test]
fn overwriting_a_file_in_place_leaves_its_directory_unchanged() {
let root = tempfile::TempDir::new().unwrap();
fs::create_dir(root.path().join("dir")).unwrap();
fs::write(root.path().join("dir/out.txt"), b"old").unwrap();
filetime::set_file_mtime(root.path().join("dir"), at(1)).unwrap();
transact(root.path(), DirReplace::Any, |txn| {
txn.write_file(
Path::new("dir/out.txt"),
Source::Bytes(b"new"),
0o644,
at(2),
)
})
.unwrap();
assert_eq!(fs::read(root.path().join("dir/out.txt")).unwrap(), b"new");
let dir_mtime =
FileTime::from_last_modification_time(&fs::metadata(root.path().join("dir")).unwrap());
assert_eq!(dir_mtime, at(1), "the backup must not touch the directory");
}
#[test]
fn an_undo_that_fails_reports_it_and_keeps_the_backup() {
let root = tempfile::TempDir::new().unwrap();
fs::create_dir(root.path().join("locked")).unwrap();
fs::write(root.path().join("locked/kept.txt"), b"kept").unwrap();
let err = transact(root.path(), DirReplace::Any, |txn| {
txn.remove(Path::new("locked/kept.txt"))?;
fs::set_permissions(
root.path().join("locked"),
fs::Permissions::from_mode(0o555),
)?;
Err::<(), _>(TreeError::BlobMissing {
path: PathBuf::from("later.txt"),
})
})
.expect_err("the closure failed");
fs::set_permissions(
root.path().join("locked"),
fs::Permissions::from_mode(0o755),
)
.unwrap();
let TreeError::RollbackIncomplete { cause, failures } = err else {
panic!("unexpected error variant: {err:?}");
};
assert!(matches!(*cause, TreeError::BlobMissing { .. }));
assert!(
failures.iter().any(|f| f.starts_with("backups kept in ")),
"{failures:?}"
);
let backup = fs::read_dir(root.path().join("locked"))
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| {
p.file_name()
.unwrap()
.to_string_lossy()
.starts_with(".verdant-undo-")
})
.expect("the backup directory must survive");
assert_eq!(fs::read(backup.join("1")).unwrap(), b"kept");
}
#[test]
fn a_panic_mid_apply_keeps_the_backups() {
let root = tempfile::TempDir::new().unwrap();
fs::create_dir(root.path().join("dist")).unwrap();
fs::write(root.path().join("dist/app.js"), b"built").unwrap();
let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
transact::<()>(root.path(), DirReplace::Any, |txn| {
txn.remove(Path::new("dist"))?;
panic!("a bug inside the closure");
})
}));
assert!(panicked.is_err());
let backup = fs::read_dir(root.path())
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| {
p.file_name()
.unwrap()
.to_string_lossy()
.starts_with(".verdant-undo-")
})
.expect("the moved-aside directory must survive a panic");
assert_eq!(fs::read(backup.join("1/app.js")).unwrap(), b"built");
}
#[test]
fn a_parent_that_is_a_file_or_a_link_to_one_is_refused() {
let root = tempfile::TempDir::new().unwrap();
fs::write(root.path().join("out"), b"was a file").unwrap();
std::os::unix::fs::symlink("out", root.path().join("lnk")).unwrap();
for parent in ["out", "lnk"] {
let file = Path::new(parent).join("inner.txt");
let dir = Path::new(parent).join("d");
let link = Path::new(parent).join("l");
let attempts: [Attempt; 3] = [
&|txn| txn.write_file(&file, Source::Bytes(b"x"), 0o644, at(1)),
&|txn| txn.create_dir(&dir, 0o755, at(1)),
&|txn| txn.create_symlink(&link, Path::new("t"), at(1)),
];
for attempt in attempts {
let err = transact(root.path(), DirReplace::Any, |txn| {
txn.create_dir(Path::new("made"), 0o755, at(1))?;
attempt(txn)
})
.expect_err("a fresh run would get ENOTDIR");
assert!(matches!(err, TreeError::NotADirectory { .. }), "{err:?}");
assert_eq!(fs::read(root.path().join("out")).unwrap(), b"was a file");
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 2);
}
}
}
#[test]
fn a_symlink_is_created_with_its_text_and_mtime() {
let root = tempfile::TempDir::new().unwrap();
transact(root.path(), DirReplace::EmptyOnly, |txn| {
txn.create_symlink(Path::new("a/dangling"), Path::new("../nowhere"), at(5))?;
txn.create_symlink(Path::new("abs"), Path::new("/etc/hostname"), at(6))
})
.unwrap();
let link = root.path().join("a/dangling");
assert_eq!(fs::read_link(&link).unwrap(), Path::new("../nowhere"));
let meta = fs::symlink_metadata(&link).unwrap();
assert_eq!(FileTime::from_last_modification_time(&meta), at(5));
assert_eq!(
fs::read_link(root.path().join("abs")).unwrap(),
Path::new("/etc/hostname")
);
}
#[test]
fn a_symlink_over_an_entry_is_refused_unless_it_was_removed_first() {
let root = tempfile::TempDir::new().unwrap();
fs::write(root.path().join("f"), b"kept").unwrap();
fs::create_dir(root.path().join("d")).unwrap();
for name in ["f", "d"] {
let err = transact(root.path(), DirReplace::EmptyOnly, |txn| {
txn.create_symlink(Path::new("new"), Path::new("t"), at(1))?;
txn.create_symlink(Path::new(name), Path::new("t"), at(1))
})
.expect_err("a fresh ln -s would fail with EEXIST");
assert!(matches!(err, TreeError::Occupied { .. }), "{err:?}");
assert!(fs::symlink_metadata(root.path().join("new")).is_err());
}
assert_eq!(fs::read(root.path().join("f")).unwrap(), b"kept");
transact(root.path(), DirReplace::EmptyOnly, |txn| {
txn.remove(Path::new("f"))?;
txn.create_symlink(Path::new("f"), Path::new("t"), at(1))
})
.unwrap();
assert_eq!(
fs::read_link(root.path().join("f")).unwrap(),
Path::new("t")
);
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 2);
}
#[test]
fn an_existing_directory_takes_the_changed_attributes_after_its_contents() {
let root = tempfile::TempDir::new().unwrap();
let dir = root.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("old"), b"x").unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o751)).unwrap();
filetime::set_file_mtime(&dir, at(3)).unwrap();
fs::create_dir(root.path().join("touched")).unwrap();
fs::set_permissions(
root.path().join("touched"),
fs::Permissions::from_mode(0o751),
)
.unwrap();
transact(root.path(), DirReplace::EmptyOnly, |txn| {
txn.set_dir_attrs(Path::new("d"), Some(0o555), Some(at(9)))?;
txn.set_dir_attrs(Path::new("touched"), None, Some(at(8)))?;
txn.remove(Path::new("d/old"))?;
txn.write_file(Path::new("d/new"), Source::Bytes(b"y"), 0o644, at(1))
})
.unwrap();
let meta = fs::metadata(&dir).unwrap();
assert_eq!(meta.permissions().mode() & 0o7777, 0o555);
assert_eq!(FileTime::from_last_modification_time(&meta), at(9));
let names: Vec<_> = fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(names, ["new"]);
let touched = fs::metadata(root.path().join("touched")).unwrap();
assert_eq!(touched.permissions().mode() & 0o7777, 0o751);
assert_eq!(FileTime::from_last_modification_time(&touched), at(8));
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
}
#[test]
fn changed_attributes_are_undone_when_a_later_step_fails() {
let root = tempfile::TempDir::new().unwrap();
let dir = root.path().join("d");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o751)).unwrap();
filetime::set_file_mtime(&dir, at(3)).unwrap();
let err = transact(root.path(), DirReplace::Any, |txn| {
txn.set_dir_attrs(Path::new("d"), Some(0o700), Some(at(9)))?;
txn.create_dir(Path::new("z"), 0o755, at(1))?;
txn.remove(Path::new("z"))
})
.expect_err("settling the removed z fails");
assert!(matches!(err, TreeError::Io(_)), "{err:?}");
let meta = fs::metadata(&dir).unwrap();
assert_eq!(meta.permissions().mode() & 0o7777, 0o751);
assert_eq!(FileTime::from_last_modification_time(&meta), at(3));
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 1);
}
fn undo_dirs(root: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
for entry in fs::read_dir(root).unwrap() {
let path = entry.unwrap().path();
if path
.file_name()
.unwrap()
.to_string_lossy()
.starts_with(".verdant-undo-")
{
found.push(path.clone());
}
if fs::symlink_metadata(&path).unwrap().is_dir() {
found.extend(undo_dirs(&path));
}
}
found
}
#[test]
fn a_removed_directory_holding_a_read_only_subdir_leaves_no_backup() {
let root = tempfile::TempDir::new().unwrap();
let dir = root.path().join("d");
for removed in [dir.join("x"), root.path().join("top")] {
fs::create_dir_all(removed.join("ro")).unwrap();
fs::write(removed.join("ro/f"), b"x").unwrap();
fs::set_permissions(removed.join("ro"), fs::Permissions::from_mode(0o555)).unwrap();
}
let settled = transact(root.path(), DirReplace::Any, |txn| {
txn.set_dir_attrs(Path::new("d"), Some(0o750), Some(at(9)))?;
txn.remove(Path::new("d/x"))?;
txn.remove(Path::new("top"))
});
let leftover = undo_dirs(root.path());
for backup in &leftover {
fs::set_permissions(backup, fs::Permissions::from_mode(0o700)).unwrap();
}
settled.unwrap();
assert_eq!(leftover, Vec::<PathBuf>::new());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
let meta = fs::metadata(&dir).unwrap();
assert_eq!(meta.permissions().mode() & 0o7777, 0o750);
assert_eq!(FileTime::from_last_modification_time(&meta), at(9));
}
#[test]
fn a_backup_removal_that_fails_while_settling_is_never_a_clean_rollback() {
let root = tempfile::TempDir::new().unwrap();
let dir = root.path().join("d");
fs::create_dir_all(dir.join("x")).unwrap();
fs::write(dir.join("x/f"), b"kept").unwrap();
BACKUP_REMOVAL_FAILS.set(true);
let err = transact(root.path(), DirReplace::Any, |txn| {
txn.set_dir_attrs(Path::new("d"), Some(0o750), Some(at(9)))?;
txn.remove(Path::new("d/x"))
})
.expect_err("removing the backup failed");
BACKUP_REMOVAL_FAILS.set(false);
let TreeError::RollbackIncomplete { failures, .. } = err else {
panic!("a partly removed backup must not look clean: {err:?}");
};
let backup = undo_dirs(root.path()).pop().expect("the backup is kept");
let named = format!("partly removed backup {}: injected", backup.display());
assert!(failures.contains(&named), "{failures:?}");
assert!(
failures.contains(&format!("backups kept in {}", backup.display())),
"{failures:?}"
);
}
#[test]
fn an_append_only_immutable_or_read_only_directory_is_refused_before_any_write() {
for flags in [
(FS_APPEND_FL, 0),
(FS_IMMUTABLE_FL, 0),
(0, libc::ST_RDONLY),
] {
let root = tempfile::TempDir::new().unwrap();
let dir = root.path().join("d");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o751)).unwrap();
filetime::set_file_mtime(&dir, at(3)).unwrap();
let ctime = fs::metadata(&dir).unwrap().ctime_nsec();
INJECTED_DIR_FLAGS.set(Some(flags));
let result = transact(root.path(), DirReplace::Any, |txn| {
txn.set_dir_attrs(Path::new("d"), Some(0o700), Some(at(9)))?;
txn.write_file(Path::new("d/new"), Source::Bytes(b"x"), 0o644, at(1))
});
INJECTED_DIR_FLAGS.set(None);
let err = result.expect_err("the directory's attributes could not be put back");
assert!(matches!(err, TreeError::Io(_)), "{flags:?}: {err:?}");
let meta = fs::metadata(&dir).unwrap();
assert_eq!(meta.permissions().mode() & 0o7777, 0o751);
assert_eq!(FileTime::from_last_modification_time(&meta), at(3));
assert_eq!(meta.ctime_nsec(), ctime);
assert!(!dir.join("new").exists());
}
}
#[test]
fn an_undo_that_loses_the_setgid_bit_is_not_a_clean_rollback() {
let root = tempfile::TempDir::new().unwrap();
let dir = root.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(root.path().join("f"), b"old").unwrap();
for path in [&dir, &root.path().join("f")] {
fs::set_permissions(path, fs::Permissions::from_mode(0o2750)).unwrap();
}
let err = transact(root.path(), DirReplace::Any, |txn| {
txn.set_dir_attrs(Path::new("d"), Some(0o700), None)?;
txn.write_file(Path::new("f"), Source::Bytes(b"new"), 0o644, at(1))?;
CHMOD_CLEARS_SETGID.set(true);
txn.create_dir(Path::new("z"), 0o755, at(1))?;
txn.remove(Path::new("z"))
})
.expect_err("settling the removed z fails");
CHMOD_CLEARS_SETGID.set(false);
let TreeError::RollbackIncomplete { failures, .. } = err else {
panic!("a restored mode without S_ISGID must not look clean: {err:?}");
};
for name in ["d", "f"] {
let path = root.path().join(name).display().to_string();
assert!(
failures.iter().any(|f| f.starts_with(&format!("{path}: "))),
"{name}: {failures:?}"
);
}
}
#[test]
fn a_directory_its_owner_cannot_list_still_takes_recorded_attributes() {
let root = tempfile::TempDir::new().unwrap();
let dir = root.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("x"), b"x").unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o311)).unwrap();
let result = transact(root.path(), DirReplace::Any, |txn| {
txn.set_dir_attrs(Path::new("d"), None, Some(at(9)))?;
txn.remove(Path::new("d/x"))
});
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
result.unwrap();
let meta = fs::metadata(&dir).unwrap();
assert_eq!(FileTime::from_last_modification_time(&meta), at(9));
assert!(!dir.join("x").exists());
}
#[test]
fn a_directory_without_inode_flags_reports_none() {
let root = tempfile::TempDir::new().unwrap();
let (inode, mount) = dir_flags(root.path()).unwrap();
assert_eq!(inode & (FS_APPEND_FL | FS_IMMUTABLE_FL), 0);
assert_eq!(mount & libc::ST_RDONLY, 0);
}
#[test]
fn recorded_attributes_survive_a_settle_failure_after_a_write_inside() {
let root = tempfile::TempDir::new().unwrap();
let dir = root.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("old"), b"x").unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o751)).unwrap();
filetime::set_file_mtime(&dir, at(3)).unwrap();
let err = transact(root.path(), DirReplace::Any, |txn| {
txn.set_dir_attrs(Path::new("d"), Some(0o555), Some(at(9)))?;
txn.write_file(Path::new("d/new"), Source::Bytes(b"y"), 0o644, at(1))?;
txn.create_dir(Path::new("z"), 0o755, at(1))?;
txn.remove(Path::new("z"))
})
.expect_err("settling the removed z fails");
assert!(matches!(err, TreeError::Io(_)), "{err:?}");
let meta = fs::metadata(&dir).unwrap();
assert_eq!(meta.permissions().mode() & 0o7777, 0o751);
assert_eq!(FileTime::from_last_modification_time(&meta), at(3));
let names: Vec<_> = fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(names, ["old"]);
}
#[test]
fn a_mode_only_change_keeps_the_mtime_the_directory_had() {
let root = tempfile::TempDir::new().unwrap();
let dir = root.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("x"), b"x").unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o751)).unwrap();
filetime::set_file_mtime(&dir, at(3)).unwrap();
transact(root.path(), DirReplace::Any, |txn| {
txn.set_dir_attrs(Path::new("d"), Some(0o700), None)?;
txn.remove(Path::new("d/x"))
})
.unwrap();
let meta = fs::metadata(&dir).unwrap();
assert_eq!(meta.permissions().mode() & 0o7777, 0o700);
assert_eq!(FileTime::from_last_modification_time(&meta), at(3));
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
}
#[test]
fn a_refused_replay_that_never_touched_a_recorded_directory_leaves_its_ctime() {
let root = tempfile::TempDir::new().unwrap();
let dir = root.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(root.path().join("taken"), b"x").unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
let before = fs::metadata(&dir).unwrap();
let err = transact(root.path(), DirReplace::Any, |txn| {
txn.set_dir_attrs(Path::new("d"), Some(0o700), Some(at(4)))?;
txn.create_symlink(Path::new("taken"), Path::new("t"), at(1))
})
.expect_err("the link name is taken");
assert!(matches!(err, TreeError::Occupied { .. }), "{err:?}");
let after = fs::metadata(&dir).unwrap();
assert_eq!(
(after.ctime(), after.ctime_nsec()),
(before.ctime(), before.ctime_nsec())
);
assert_eq!(after.mode(), before.mode());
}
#[test]
fn attributes_for_a_directory_that_is_no_longer_one_are_refused() {
let root = tempfile::TempDir::new().unwrap();
fs::write(root.path().join("f"), b"x").unwrap();
fs::create_dir(root.path().join("real")).unwrap();
std::os::unix::fs::symlink("real", root.path().join("lnk")).unwrap();
for name in ["f", "lnk", "absent", "f/below"] {
let err = transact(root.path(), DirReplace::EmptyOnly, |txn| {
txn.set_dir_attrs(Path::new(name), Some(0o700), None)
})
.expect_err("the run saw a directory there");
assert!(
matches!(err, TreeError::NotADirectory { .. }),
"{name}: {err:?}"
);
}
assert!(!root.path().join("absent").exists());
}
fn read_only(path: &Path) -> bool {
fs::set_permissions(path, fs::Permissions::from_mode(0o444)).unwrap();
fs::OpenOptions::new().write(true).open(path).is_err()
}
#[test]
fn a_read_only_target_fails_without_touching_the_tree() {
let root = tempfile::TempDir::new().unwrap();
fs::write(root.path().join("ro"), b"old").unwrap();
if !read_only(&root.path().join("ro")) {
eprintln!("skipping: running as a user that writes read-only files");
return;
}
let err = transact(root.path(), DirReplace::Any, |txn| {
txn.write_file(Path::new("ro"), Source::Bytes(b"new"), 0o644, at(1))
})
.expect_err("a read-only file cannot be written");
assert!(matches!(err, TreeError::Io(_)), "{err:?}");
assert_eq!(fs::read(root.path().join("ro")).unwrap(), b"old");
assert_eq!(
fs::read_dir(root.path()).unwrap().count(),
1,
"no backups left"
);
}
#[test]
fn a_write_that_leaves_a_file_read_only_is_undone() {
let root = tempfile::TempDir::new().unwrap();
fs::write(root.path().join("a"), b"old").unwrap();
fs::set_permissions(root.path().join("a"), fs::Permissions::from_mode(0o640)).unwrap();
let err = transact(root.path(), DirReplace::Any, |txn| {
txn.write_file(Path::new("a"), Source::Bytes(b"new"), 0o444, at(1))?;
Err::<(), _>(TreeError::BlobMissing {
path: PathBuf::from("later.txt"),
})
})
.expect_err("the closure failed");
assert!(matches!(err, TreeError::BlobMissing { .. }), "{err:?}");
assert_eq!(fs::read(root.path().join("a")).unwrap(), b"old");
let mode = fs::metadata(root.path().join("a"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o7777, 0o640);
}
#[test]
fn a_path_leaving_the_root_is_refused() {
let root = tempfile::TempDir::new().unwrap();
for rel in ["../x", "/etc/x", "a/../../x", ""] {
let err = transact(root.path(), DirReplace::Any, |txn| {
txn.write_file(Path::new(rel), Source::Bytes(b"x"), 0o644, at(1))
})
.expect_err(rel);
assert!(
matches!(err, TreeError::PathEscape { .. }),
"{rel}: {err:?}"
);
}
}
#[test]
fn an_overwrite_keeps_the_inode_so_hard_links_see_it() {
let root = tempfile::TempDir::new().unwrap();
fs::write(root.path().join("a"), b"old").unwrap();
fs::hard_link(root.path().join("a"), root.path().join("b")).unwrap();
transact(root.path(), DirReplace::Any, |txn| {
txn.write_file(Path::new("a"), Source::Bytes(b"new"), 0o644, at(1))
})
.unwrap();
assert_eq!(fs::read(root.path().join("b")).unwrap(), b"new");
assert_eq!(
fs::read_dir(root.path()).unwrap().count(),
2,
"no backups left"
);
}
#[test]
fn a_write_that_changes_nothing_leaves_the_file_untouched() {
let root = tempfile::TempDir::new().unwrap();
let path = root.path().join("a");
let write = |bytes: &'static [u8], mode: u32, mtime: i64| {
transact(root.path(), DirReplace::Any, |txn| {
txn.write_file(Path::new("a"), Source::Bytes(bytes), mode, at(mtime))
})
.unwrap();
let m = fs::metadata(&path).unwrap();
(m.ctime(), m.ctime_nsec())
};
let first = write(b"same", 0o644, 1);
std::thread::sleep(std::time::Duration::from_millis(20));
assert_eq!(write(b"same", 0o644, 1), first);
for changed in [
write(b"diff", 0o644, 1),
write(b"diff", 0o600, 1),
write(b"diff", 0o600, 2),
] {
assert_ne!(changed, first);
}
assert_eq!(fs::read(&path).unwrap(), b"diff");
}
#[test]
fn a_created_directory_gets_its_mode_and_mtime_after_its_contents() {
let root = tempfile::TempDir::new().unwrap();
transact(root.path(), DirReplace::EmptyOnly, |txn| {
txn.create_dir(Path::new("ro"), 0o555, at(7))?;
txn.create_dir(Path::new("ro/inner"), 0o500, at(8))?;
txn.write_file(Path::new("ro/inner/f"), Source::Bytes(b"x"), 0o644, at(9))
})
.unwrap();
let ro = fs::metadata(root.path().join("ro")).unwrap();
let inner = fs::metadata(root.path().join("ro/inner")).unwrap();
assert_eq!(ro.permissions().mode() & 0o7777, 0o555);
assert_eq!(FileTime::from_last_modification_time(&ro), at(7));
assert_eq!(inner.permissions().mode() & 0o7777, 0o500);
assert_eq!(FileTime::from_last_modification_time(&inner), at(8));
assert_eq!(fs::read(root.path().join("ro/inner/f")).unwrap(), b"x");
fs::set_permissions(
root.path().join("ro/inner"),
fs::Permissions::from_mode(0o700),
)
.unwrap();
fs::set_permissions(root.path().join("ro"), fs::Permissions::from_mode(0o700)).unwrap();
}
#[test]
fn an_existing_directory_is_left_as_it_is() {
let root = tempfile::TempDir::new().unwrap();
let dir = root.path().join("d");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o751)).unwrap();
filetime::set_file_mtime(&dir, at(3)).unwrap();
fs::create_dir(root.path().join("target")).unwrap();
std::os::unix::fs::symlink("target", root.path().join("link")).unwrap();
transact(root.path(), DirReplace::EmptyOnly, |txn| {
txn.create_dir(Path::new("d"), 0o700, at(5))?;
txn.create_dir(Path::new("link"), 0o700, at(5))
})
.unwrap();
let meta = fs::metadata(&dir).unwrap();
assert_eq!(meta.permissions().mode() & 0o7777, 0o751);
assert_eq!(FileTime::from_last_modification_time(&meta), at(3));
assert!(fs::symlink_metadata(root.path().join("link"))
.unwrap()
.file_type()
.is_symlink());
}
#[test]
fn a_directory_over_a_file_or_a_dangling_link_is_refused() {
let root = tempfile::TempDir::new().unwrap();
fs::write(root.path().join("file"), b"kept").unwrap();
std::os::unix::fs::symlink("nowhere", root.path().join("dangling")).unwrap();
for name in ["file", "dangling"] {
let err = transact(root.path(), DirReplace::Any, |txn| {
txn.create_dir(Path::new("made"), 0o755, at(1))?;
txn.create_dir(Path::new(name), 0o755, at(1))
})
.expect_err("an occupied name must be refused");
assert!(
matches!(
err,
TreeError::NotADirectory { .. } | TreeError::PathEscape { .. }
),
"{name}: {err:?}"
);
assert!(!root.path().join("made").exists(), "{name}");
}
assert_eq!(fs::read(root.path().join("file")).unwrap(), b"kept");
assert_eq!(
fs::read_link(root.path().join("dangling")).unwrap(),
Path::new("nowhere")
);
}
#[test]
fn a_failure_after_a_mode_was_set_still_removes_the_created_directories() {
let root = tempfile::TempDir::new().unwrap();
let err = transact(root.path(), DirReplace::Any, |txn| {
txn.create_dir(Path::new("x/y"), 0o500, at(1))?;
txn.write_file(Path::new("x/y/f"), Source::Bytes(b"x"), 0o644, at(1))?;
txn.create_dir(Path::new("z"), 0o755, at(1))?;
txn.remove(Path::new("z"))
})
.expect_err("setting the mode of the removed z fails");
assert!(matches!(err, TreeError::Io(_)), "{err:?}");
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 0);
}
}