use crate::error::{FsError, PostPublishStep, Result};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum DirPublishMethod {
ExclusiveRename,
Exchange,
AsideRename,
EmptyRemovedThenExclusiveRename,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum DirPublishPolicy {
#[default]
Replace,
AbsentOnly,
AbsentOrEmpty,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct PublishedDir {
pub replaced: bool,
pub method: DirPublishMethod,
}
pub fn publish_dir(src: &Path, dest: &Path) -> Result<PublishedDir> {
publish_dir_with(src, dest, DirPublishPolicy::Replace)
}
pub fn publish_dir_with(src: &Path, dest: &Path, policy: DirPublishPolicy) -> Result<PublishedDir> {
check_operands(src, dest)?;
match policy {
DirPublishPolicy::Replace => publish_replacing(src, dest),
DirPublishPolicy::AbsentOnly => publish_strict(src, dest, false),
DirPublishPolicy::AbsentOrEmpty => publish_strict(src, dest, true),
}
}
fn publish_strict(src: &Path, dest: &Path, allow_empty: bool) -> Result<PublishedDir> {
let mut removed_empty = false;
for pass in 0..4 {
match rename_noreplace_native(src, dest) {
Ok(()) => {
sync_parents(src, dest)?;
return Ok(PublishedDir {
replaced: removed_empty,
method: if removed_empty {
DirPublishMethod::EmptyRemovedThenExclusiveRename
} else {
DirPublishMethod::ExclusiveRename
},
});
}
Err(FsError::AlreadyExists(_)) if !allow_empty => {
return Err(FsError::AlreadyExists(dest.to_path_buf()))
}
Err(FsError::AlreadyExists(_)) => {}
Err(e) => {
if removed_empty {
let _ = std::fs::create_dir(dest);
}
return Err(e);
}
}
match std::fs::symlink_metadata(dest) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(FsError::Symlink(dest.to_path_buf()))
}
Ok(meta) if !meta.is_dir() => return Err(FsError::NotADirectory(dest.to_path_buf())),
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => return Err(FsError::io(dest, e)),
}
if pass == 3 {
if removed_empty {
let _ = std::fs::create_dir(dest);
}
return Err(FsError::io(
dest,
io::Error::other(
"the destination kept appearing and disappearing while it was being published",
),
));
}
match std::fs::remove_dir(dest) {
Ok(()) => removed_empty = true,
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) if dir_not_empty(&e) => return Err(FsError::AlreadyExists(dest.to_path_buf())),
Err(e) => return Err(FsError::io(dest, e)),
}
}
if removed_empty {
let _ = std::fs::create_dir(dest);
}
Err(FsError::io(
dest,
io::Error::other(
"the destination kept appearing and disappearing while it was being published",
),
))
}
fn dir_not_empty(error: &io::Error) -> bool {
if error.kind() == io::ErrorKind::DirectoryNotEmpty {
return true;
}
#[cfg(unix)]
if let Some(code) = error.raw_os_error() {
return code == libc::ENOTEMPTY || code == libc::EEXIST;
}
false
}
fn publish_replacing(src: &Path, dest: &Path) -> Result<PublishedDir> {
for _ in 0..4 {
match rename_dir_exclusive(src, dest) {
Ok(()) => {
sync_parents(src, dest)?;
return Ok(PublishedDir {
replaced: false,
method: DirPublishMethod::ExclusiveRename,
});
}
Err(FsError::AlreadyExists(_)) => {}
Err(e) => return Err(e),
}
match std::fs::symlink_metadata(dest) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(FsError::Symlink(dest.to_path_buf()))
}
Ok(meta) if !meta.is_dir() => return Err(FsError::NotADirectory(dest.to_path_buf())),
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => return Err(FsError::io(dest, e)),
}
match exchange(src, dest) {
Ok(()) => {
let cleanup = std::fs::remove_dir_all(src);
sync_parents(src, dest)?;
finish_replace(dest, src, cleanup)?;
return Ok(PublishedDir {
replaced: true,
method: DirPublishMethod::Exchange,
});
}
Err(e) if e.kind() == io::ErrorKind::NotFound && !src.exists() => {
return Err(FsError::io(src, e))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) if crate::swap::is_swap_unsupported(&e) => {}
Err(e) => return Err(FsError::io(dest, e)),
}
match replace_via_aside(src, dest) {
Ok(()) => {
return Ok(PublishedDir {
replaced: true,
method: DirPublishMethod::AsideRename,
})
}
Err(AsideError::DestVanished) => continue,
Err(AsideError::Failed(e)) => return Err(e),
}
}
Err(FsError::io(
dest,
io::Error::other(
"the destination kept appearing and disappearing while it was being replaced",
),
))
}
fn check_operands(src: &Path, dest: &Path) -> Result<()> {
match std::fs::symlink_metadata(src) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(FsError::Symlink(src.to_path_buf()))
}
Ok(meta) if !meta.is_dir() => return Err(FsError::NotADirectory(src.to_path_buf())),
Ok(_) => {}
Err(e) => return Err(FsError::io(src, e)),
}
let name = dest
.file_name()
.ok_or_else(|| FsError::BadName(dest.display().to_string()))?;
crate::atomic::reject_reserved_name(&name.to_string_lossy())?;
let (abs_src, abs_dest) = (absolute(src)?, absolute(dest)?);
if abs_src == abs_dest || abs_dest.starts_with(&abs_src) || abs_src.starts_with(&abs_dest) {
return Err(FsError::io(
dest,
io::Error::new(
io::ErrorKind::InvalidInput,
"the source and destination directories are the same, or one is inside the other",
),
));
}
if let (Ok(a), Ok(b)) = (
crate::file_key::FileKey::of_path(src),
crate::file_key::FileKey::of_path(dest),
) {
if a == b {
return Err(FsError::io(
dest,
io::Error::new(
io::ErrorKind::InvalidInput,
"the source and destination are the same directory",
),
));
}
}
Ok(())
}
fn absolute(path: &Path) -> Result<PathBuf> {
std::path::absolute(path).map_err(|e| FsError::io(path, e))
}
fn parent_of(path: &Path) -> PathBuf {
match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
}
}
fn sync_parents(src: &Path, dest: &Path) -> Result<()> {
let (dest_parent, src_parent) = (parent_of(dest), parent_of(src));
crate::atomic::try_sync_dir(&dest_parent).map_err(|source| {
FsError::ReplacedButDirSyncFailed {
path: dest.to_path_buf(),
source,
}
})?;
if src_parent != dest_parent {
crate::atomic::try_sync_dir(&src_parent).map_err(|source| {
FsError::ReplacedButDirSyncFailed {
path: dest.to_path_buf(),
source,
}
})?;
}
Ok(())
}
fn finish_replace(dest: &Path, old_tree: &Path, cleanup: io::Result<()>) -> Result<()> {
match cleanup {
Ok(()) => Ok(()),
Err(e) => Err(FsError::ReplacedButPostStepFailed {
path: dest.to_path_buf(),
step: PostPublishStep::OldTreeCleanup,
source: io::Error::new(
e.kind(),
format!(
"the replaced directory remains at {}: {e}",
old_tree.display()
),
),
}),
}
}
fn exchange(a: &Path, b: &Path) -> io::Result<()> {
#[cfg(target_os = "macos")]
{
crate::swap::swap_paths(a, b)
}
#[cfg(target_os = "linux")]
{
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
const RENAME_EXCHANGE: libc::c_uint = 2;
let invalid = |_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte");
let ca = CString::new(a.as_os_str().as_bytes()).map_err(invalid)?;
let cb = CString::new(b.as_os_str().as_bytes()).map_err(invalid)?;
let rc = unsafe {
libc::syscall(
libc::SYS_renameat2,
libc::AT_FDCWD,
ca.as_ptr(),
libc::AT_FDCWD,
cb.as_ptr(),
RENAME_EXCHANGE,
)
};
if rc == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = (a, b);
Err(io::Error::new(
io::ErrorKind::Unsupported,
"an atomic directory exchange is not available on this platform",
))
}
}
fn rename_dir_exclusive(src: &Path, dest: &Path) -> Result<()> {
match crate::atomic::rename_exclusive_reporting(src, dest) {
Ok(_) => Ok(()),
Err(e @ FsError::AlreadyExists(_)) => Err(e),
Err(first) => {
match std::fs::symlink_metadata(dest) {
Ok(_) => return Err(FsError::AlreadyExists(dest.to_path_buf())),
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(_) => return Err(first),
}
std::fs::rename(src, dest).map_err(|e| FsError::io(dest, e))
}
}
}
#[cfg(target_os = "linux")]
fn rename_noreplace_native(src: &Path, dest: &Path) -> Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
const RENAME_NOREPLACE: libc::c_uint = 1;
let c_path = |path: &Path| {
CString::new(path.as_os_str().as_bytes()).map_err(|_| {
FsError::io(
path,
io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"),
)
})
};
let (csrc, cdest) = (c_path(src)?, c_path(dest)?);
let rc = unsafe {
libc::syscall(
libc::SYS_renameat2,
libc::AT_FDCWD,
csrc.as_ptr(),
libc::AT_FDCWD,
cdest.as_ptr(),
RENAME_NOREPLACE,
)
};
if rc == 0 {
return Ok(());
}
Err(noreplace_error(
dest,
io::Error::last_os_error(),
"renameat2(RENAME_NOREPLACE) is not available here (old kernel or a filesystem without it)",
))
}
#[cfg(target_vendor = "apple")]
fn rename_noreplace_native(src: &Path, dest: &Path) -> Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
const RENAME_EXCL: libc::c_uint = 0x0000_0004;
let c_path = |path: &Path| {
CString::new(path.as_os_str().as_bytes()).map_err(|_| {
FsError::io(
path,
io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"),
)
})
};
let (csrc, cdest) = (c_path(src)?, c_path(dest)?);
let rc = unsafe { libc::renamex_np(csrc.as_ptr(), cdest.as_ptr(), RENAME_EXCL) };
if rc == 0 {
return Ok(());
}
Err(noreplace_error(
dest,
io::Error::last_os_error(),
"renamex_np(RENAME_EXCL) is not available on this volume (HFS+, FAT, some shares)",
))
}
#[cfg(windows)]
fn rename_noreplace_native(src: &Path, dest: &Path) -> Result<()> {
use std::os::windows::ffi::OsStrExt;
#[link(name = "kernel32")]
unsafe extern "system" {
fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
}
let wide =
|path: &Path| -> Vec<u16> { path.as_os_str().encode_wide().chain(Some(0)).collect() };
let (wsrc, wdest) = (wide(src), wide(dest));
if unsafe { MoveFileExW(wsrc.as_ptr(), wdest.as_ptr(), 0) } != 0 {
return Ok(());
}
Err(noreplace_error(
dest,
io::Error::last_os_error(),
"MoveFileExW is not available for this directory",
))
}
#[cfg(not(any(target_os = "linux", target_vendor = "apple", windows)))]
fn rename_noreplace_native(src: &Path, dest: &Path) -> Result<()> {
let _ = (src, dest);
Err(FsError::Unavailable(
"an atomic no-replace directory rename is not available on this platform",
))
}
#[cfg(any(target_os = "linux", target_vendor = "apple", windows))]
fn noreplace_error(dest: &Path, error: io::Error, unavailable: &'static str) -> FsError {
if error.kind() == io::ErrorKind::AlreadyExists {
return FsError::AlreadyExists(dest.to_path_buf());
}
#[cfg(unix)]
if matches!(
error.raw_os_error(),
Some(libc::ENOSYS) | Some(libc::EINVAL) | Some(libc::ENOTSUP)
) {
return FsError::Unavailable(unavailable);
}
#[cfg(not(unix))]
let _ = unavailable;
FsError::io(dest, error)
}
enum AsideError {
DestVanished,
Failed(FsError),
}
fn aside_name(dest: &Path) -> PathBuf {
let name = dest
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let mut end = name.len().min(64);
while !name.is_char_boundary(end) {
end -= 1;
}
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
parent_of(dest).join(format!(
".{}.rkold-{}-{n}",
&name[..end],
std::process::id()
))
}
fn replace_via_aside(src: &Path, dest: &Path) -> std::result::Result<(), AsideError> {
let aside = aside_name(dest);
match std::fs::rename(dest, &aside) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(AsideError::DestVanished),
Err(e) => return Err(AsideError::Failed(FsError::io(dest, e))),
}
match rename_dir_exclusive(src, dest) {
Ok(()) => {
let cleanup = std::fs::remove_dir_all(&aside);
let synced = sync_parents(src, dest);
finish_replace(dest, &aside, cleanup).map_err(AsideError::Failed)?;
synced.map_err(AsideError::Failed)
}
Err(publish_error) => {
match rename_dir_exclusive(&aside, dest) {
Ok(()) => Err(AsideError::Failed(publish_error)),
Err(restore_error) => Err(AsideError::Failed(FsError::io(
dest,
io::Error::other(format!(
"publishing the new directory failed ({publish_error}) and the previous \
directory could not be restored from {} ({restore_error})",
aside.display()
)),
))),
}
}
}
}
fn retained_name(dest: &Path) -> Result<PathBuf> {
let name = dest
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| FsError::BadName(dest.display().to_string()))?;
let short: String = name.chars().take(100).collect();
Ok(parent_of(dest).join(format!(".{short}.rkprev")))
}
#[derive(Debug)]
#[must_use = "a retained publish stays undecided until commit() or rollback()"]
pub struct RetainedPublish {
dest: PathBuf,
previous: Option<PathBuf>,
}
impl RetainedPublish {
pub fn previous(&self) -> Option<&Path> {
self.previous.as_deref()
}
pub fn dest(&self) -> &Path {
&self.dest
}
pub fn commit(self) -> Result<()> {
let Some(previous) = self.previous else {
return Ok(());
};
let report = crate::tree::remove_tree(&previous, &crate::tree::RemoveOptions::new());
match report {
Ok(r) if r.retained == 0 => Ok(()),
Ok(_) => Err(FsError::ReplacedButPostStepFailed {
path: self.dest,
step: PostPublishStep::OldTreeCleanup,
source: io::Error::other(format!(
"part of the previous tree at {} could not be removed",
previous.display()
)),
}),
Err(FsError::Io { source, .. }) => Err(FsError::ReplacedButPostStepFailed {
path: self.dest,
step: PostPublishStep::OldTreeCleanup,
source,
}),
Err(e) => Err(e),
}
}
pub fn rollback(self) -> Result<()> {
let failed = failed_name(&self.dest)?;
match &self.previous {
Some(previous) => {
if std::fs::symlink_metadata(&self.dest).is_ok() {
std::fs::rename(&self.dest, &failed).map_err(|e| FsError::io(&self.dest, e))?;
}
if let Err(e) = crate::atomic::rename_exclusive(previous, &self.dest) {
if failed.exists() {
let _ = crate::atomic::rename_exclusive(&failed, &self.dest);
}
return Err(e);
}
if failed.exists() {
let _ = crate::tree::remove_tree(&failed, &crate::tree::RemoveOptions::new());
}
let _ = crate::atomic::try_sync_dir(&parent_of(&self.dest));
Ok(())
}
None => {
crate::tree::remove_tree(&self.dest, &crate::tree::RemoveOptions::new()).map(|_| ())
}
}
}
}
fn failed_name(dest: &Path) -> Result<PathBuf> {
let name = dest
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| FsError::BadName(dest.display().to_string()))?;
let short: String = name.chars().take(100).collect();
Ok(parent_of(dest).join(format!(
".{short}.rkfailed-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
)))
}
pub fn publish_dir_retaining(src: &Path, dest: &Path) -> Result<RetainedPublish> {
check_operands(src, dest)?;
let marker = retained_name(dest)?;
if std::fs::symlink_metadata(&marker).is_ok() {
return Err(FsError::AlreadyExists(marker));
}
match std::fs::symlink_metadata(dest) {
Err(e) if e.kind() == io::ErrorKind::NotFound => {
rename_dir_exclusive(src, dest)?;
sync_parents(src, dest)?;
Ok(RetainedPublish {
dest: dest.to_path_buf(),
previous: None,
})
}
Err(e) => Err(FsError::io(dest, e)),
Ok(meta) if meta.file_type().is_symlink() => Err(FsError::Symlink(dest.to_path_buf())),
Ok(meta) if !meta.is_dir() => Err(FsError::NotADirectory(dest.to_path_buf())),
Ok(_) => {
crate::atomic::rename_exclusive(dest, &marker)?;
match rename_dir_exclusive(src, dest) {
Ok(()) => {
sync_parents(src, dest)?;
Ok(RetainedPublish {
dest: dest.to_path_buf(),
previous: Some(marker),
})
}
Err(publish_error) => match rename_dir_exclusive(&marker, dest) {
Ok(()) => Err(publish_error),
Err(restore_error) => Err(FsError::io(
dest,
io::Error::other(format!(
"publishing the new directory failed ({publish_error}) and the \
previous directory could not be restored from {} ({restore_error})",
marker.display()
)),
)),
},
}
}
}
}
pub fn pending_retained(dest: &Path) -> Result<Option<RetainedPublish>> {
let marker = retained_name(dest)?;
match std::fs::symlink_metadata(&marker) {
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(FsError::io(&marker, e)),
Ok(meta) if meta.file_type().is_symlink() => Err(FsError::Symlink(marker)),
Ok(meta) if !meta.is_dir() => Err(FsError::NotADirectory(marker)),
Ok(_) => Ok(Some(RetainedPublish {
dest: dest.to_path_buf(),
previous: Some(marker),
})),
}
}