// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use crate::disk::error::DiskError; use crate::disk::error::Result; use crate::disk::error_conv::to_file_error; use futures::TryStreamExt; use parking_lot::Mutex; use rustfs_utils::path::SLASH_SEPARATOR; use std::{ collections::HashMap, io, path::{Component, Path, PathBuf}, sync::{Arc, LazyLock, Weak}, }; use tokio::fs; use tokio::sync::{ Mutex as AsyncMutex, OwnedMutexGuard, OwnedRwLockReadGuard, OwnedSemaphorePermit, RwLock, Semaphore, SemaphorePermit, }; use tracing::warn; /// Check path length according to OS limits. pub fn check_path_length(path_name: &str) -> Result<()> { // Apple OS X path length is limited to 1016 if cfg!(target_os = "macos") && path_name.len() > 1016 { return Err(DiskError::FileNameTooLong); } // Disallow more than 1024 characters on windows, there // are no known name_max limits on Windows. if cfg!(target_os = "windows") && path_name.len() > 1024 { return Err(DiskError::FileNameTooLong); } // On Unix we reject paths if they are just '.', '..' or '/' let invalid_paths = [".", "..", "/"]; if invalid_paths.contains(&path_name) { return Err(DiskError::FileAccessDenied); } // Check each path segment length is > 255 on all Unix // platforms, look for this value as NAME_MAX in // /usr/include/linux/limits.h let mut count = 0usize; for c in path_name.chars() { match c { '/' => count = 0, '\\' if cfg!(target_os = "windows") => count = 0, // Reset _ => { count += 1; if count > 255 { return Err(DiskError::FileNameTooLong); } } } } // Success. Ok(()) } /// Test-only recorder of every directory passed to [`fsync_dir_std`]. /// /// Durability regressions are invisible to ordinary behavior tests (the data /// is on disk either way), so unit tests assert directly on which directories /// were fsynced. Paths are recorded globally; tests must match on paths under /// their own unique tempdir to stay robust against parallel test execution. #[cfg(test)] pub(crate) mod fsync_dir_recorder { use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Mutex; type Hook = Box; static RECORDED: Mutex> = Mutex::new(Vec::new()); static LIMITED: Mutex> = Mutex::new(Vec::new()); static BEFORE_LIMITED: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); fn record_path(paths: &Mutex>, path: &Path, description: &str) { let mut paths = paths.lock().expect(description); paths.push(path.to_path_buf()); if let Ok(canonical) = path.canonicalize() && canonical != path { paths.push(canonical); } } fn contains_path(paths: &[PathBuf], path: &Path) -> bool { let canonical = path.canonicalize().ok(); paths .iter() .any(|recorded| recorded == path || canonical.as_ref().is_some_and(|canonical| recorded == canonical)) } pub(crate) fn record(dir: &Path) { record_path(&RECORDED, dir, "fsync dir recorder"); } pub(crate) fn was_fsynced(dir: &Path) -> bool { contains_path(&RECORDED.lock().expect("fsync dir recorder poisoned"), dir) } pub(crate) fn record_limited(dir: &Path) { record_path(&LIMITED, dir, "limited fsync dir recorder"); let hook = BEFORE_LIMITED.lock().expect("limited fsync hook poisoned").remove(dir); if let Some(hook) = hook { hook(); } } pub(crate) fn was_limited(dir: &Path) -> bool { contains_path(&LIMITED.lock().expect("limited fsync dir recorder poisoned"), dir) } pub(crate) fn set_before_limited(dir: &Path, hook: impl FnOnce() + Send + 'static) { BEFORE_LIMITED .lock() .expect("limited fsync hook poisoned") .insert(dir.to_path_buf(), Box::new(hook)); } } #[cfg(all(test, windows))] pub(crate) mod windows_rename_test_hooks { use super::*; type Hook = Box; static BEFORE_SOURCE_WRITE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static BEFORE_PUBLICATION: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static BEFORE_RENAME_RETRY: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static GUARD_GENERATIONS: LazyLock>>> = LazyLock::new(|| Mutex::new(HashMap::new())); pub(crate) fn install_before_source_write(path: &Path, hook: impl FnOnce() + Send + 'static) { BEFORE_SOURCE_WRITE.lock().insert(path.to_path_buf(), Box::new(hook)); } pub(crate) fn run_before_source_write(path: &Path) { if let Some(hook) = BEFORE_SOURCE_WRITE.lock().remove(path) { hook(); } } pub(crate) fn install_before_publication(path: &Path, hook: impl FnOnce() + Send + 'static) { BEFORE_PUBLICATION.lock().insert(path.to_path_buf(), Box::new(hook)); } pub(crate) fn run_before_publication(path: &Path) { if let Some(hook) = BEFORE_PUBLICATION.lock().remove(path) { hook(); } } pub(crate) fn install_before_rename_retry(path: &Path, hook: impl FnOnce() + Send + 'static) { BEFORE_RENAME_RETRY.lock().insert(path.to_path_buf(), Box::new(hook)); } pub(crate) fn run_before_rename_retry(path: &Path) { if let Some(hook) = BEFORE_RENAME_RETRY.lock().remove(path) { hook(); } } pub(crate) fn observe_guard_generations(path: &Path) { GUARD_GENERATIONS.lock().insert(path.to_path_buf(), Vec::new()); } pub(crate) fn record_guard_generation(path: &Path, generation: u64) { if let Some(generations) = GUARD_GENERATIONS.lock().get_mut(path) { generations.push(generation); } } pub(crate) fn take_guard_generations(path: &Path) -> Vec { GUARD_GENERATIONS.lock().remove(path).unwrap_or_default() } } /// Fsync a directory so recently created or renamed entries survive power loss. /// No-op on non-Unix platforms where directories cannot be opened for syncing. pub fn fsync_dir_std(dir: impl AsRef) -> io::Result<()> { #[cfg(test)] fsync_dir_recorder::record(dir.as_ref()); #[cfg(unix)] { std::fs::File::open(dir.as_ref())?.sync_all()?; } #[cfg(not(unix))] let _ = dir; Ok(()) } /// Async wrapper around [`fsync_dir_std`]; runs the blocking Unix fsync off the runtime. pub async fn fsync_dir(dir: impl AsRef) -> io::Result<()> { #[cfg(unix)] { let dir = dir.as_ref().to_path_buf(); tokio::task::spawn_blocking(move || fsync_dir_std(dir)).await? } #[cfg(not(unix))] { fsync_dir_std(dir) } } // Small object directories are cheaper to flush in one blocking task. Multipart // directories fan out only once enough files can amortize per-task scheduling. const PARALLEL_FILE_SYNC_THRESHOLD: usize = 16; pub(crate) const MAX_PARALLEL_FILE_SYNCS: usize = 16; // Scale aggregate fan-out for wider nodes while reserving at least half of the // configured Tokio blocking pool for unrelated filesystem work. const MIN_GLOBAL_FILE_SYNCS: usize = 64; const MAX_GLOBAL_FILE_SYNCS: usize = 512; #[cfg(test)] const TEST_GLOBAL_FILE_SYNCS: usize = 64; static FILE_SYNC_PERMITS: LazyLock = LazyLock::new(|| Semaphore::new(global_file_sync_limit())); static DISK_FILE_SYNC_LIMITERS: LazyLock>>> = LazyLock::new(|| Mutex::new(HashMap::new())); static DISK_VOLUME_MUTATION_LOCKS: LazyLock>>>> = LazyLock::new(|| Mutex::new(HashMap::new())); type NamespaceMutationLock = AsyncMutex<()>; type NamespaceMutationLockRegistry = HashMap>; static DISK_NAMESPACE_MUTATION_LOCKS: LazyLock> = LazyLock::new(|| Mutex::new(HashMap::new())); fn default_global_file_sync_limit(cpu_count: usize, max_blocking_threads: usize) -> usize { let cpu_scaled = cpu_count .max(1) .saturating_mul(MAX_PARALLEL_FILE_SYNCS) .clamp(MIN_GLOBAL_FILE_SYNCS, MAX_GLOBAL_FILE_SYNCS); cpu_scaled.min((max_blocking_threads.max(1) / 2).max(1)) } #[cfg(not(test))] fn global_file_sync_limit() -> usize { let max_blocking_threads = rustfs_utils::get_env_usize(rustfs_config::ENV_MAX_BLOCKING_THREADS, rustfs_config::DEFAULT_MAX_BLOCKING_THREADS); default_global_file_sync_limit(num_cpus::get(), max_blocking_threads) } #[cfg(test)] fn global_file_sync_limit() -> usize { TEST_GLOBAL_FILE_SYNCS } /// Reuse a disk's limiter across reconnects while detached sync calls still hold it. pub(crate) fn disk_file_sync_limiter(root: &Path) -> Arc { let mut limiters = DISK_FILE_SYNC_LIMITERS.lock(); limiters.retain(|_, limiter| limiter.strong_count() > 0); if let Some(limiter) = limiters.get(root).and_then(Weak::upgrade) { return limiter; } let limiter = Arc::new(Semaphore::new(MAX_PARALLEL_FILE_SYNCS)); limiters.insert(root.to_path_buf(), Arc::downgrade(&limiter)); limiter } /// Serialize a bucket's local metadata commits with physical bucket removal. /// /// The key includes the canonical disk root, so independently reconnected /// [`LocalDisk`](super::local::LocalDisk) instances share the same lock while /// disconnected disks do not keep the registry alive. pub(crate) fn disk_volume_mutation_lock(root: &Path, volume: &str) -> Arc> { let key = root.join(volume); let mut locks = DISK_VOLUME_MUTATION_LOCKS.lock(); locks.retain(|_, lock| lock.strong_count() > 0); if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) { return lock; } let lock = Arc::new(RwLock::new(())); locks.insert(key, Arc::downgrade(&lock)); lock } fn disk_namespace_mutation_lock(path: &Path) -> Arc { let mut locks = DISK_NAMESPACE_MUTATION_LOCKS.lock(); locks.retain(|_, lock| lock.strong_count() > 0); if let Some(lock) = locks.get(path).and_then(Weak::upgrade) { return lock; } let lock = Arc::new(AsyncMutex::new(())); locks.insert(path.to_path_buf(), Arc::downgrade(&lock)); lock } /// Keeps a namespace transaction serialized even when its async waiter is /// cancelled while a blocking filesystem call is still running. pub(crate) struct NamespaceMutationLease { _namespace_guard: OwnedMutexGuard<()>, _volume_guard: Option>, external_guard: Mutex>>, } impl NamespaceMutationLease { pub(crate) fn attach_external_guard(&self, guard: Arc) { *self.external_guard.lock() = Some(guard); } } async fn acquire_namespace_mutation_lease(path: &Path) -> Arc { Arc::new(NamespaceMutationLease { _namespace_guard: disk_namespace_mutation_lock(path).lock_owned().await, _volume_guard: None, external_guard: Mutex::new(None), }) } /// Acquire object serialization before the volume read lock. Bucket deletion /// only acquires the volume write lock, so this order cannot form a lock cycle. pub(crate) async fn acquire_rename_data_mutation_lease( root: &Path, volume: &str, destination_object: &Path, ) -> Arc { let namespace_guard = disk_namespace_mutation_lock(destination_object).lock_owned().await; let volume_guard = disk_volume_mutation_lock(root, volume).read_owned().await; Arc::new(NamespaceMutationLease { _namespace_guard: namespace_guard, _volume_guard: Some(volume_guard), external_guard: Mutex::new(None), }) } /// Always acquire the per-disk permit before the process-wide permit. Keeping /// this order uniform prevents one slow disk from reserving global capacity /// while it waits for its own concurrency slot. async fn acquire_file_sync_permits(disk_permits: Arc) -> io::Result<(OwnedSemaphorePermit, SemaphorePermit<'static>)> { let disk_permit = disk_permits .acquire_owned() .await .map_err(|_| io::Error::other("disk file sync concurrency limiter closed"))?; let global_permit = FILE_SYNC_PERMITS .acquire() .await .map_err(|_| io::Error::other("global file sync concurrency limiter closed"))?; Ok((disk_permit, global_permit)) } /// Keep the per-disk permit with the blocking syscall so cancellation cannot /// amplify work on a wedged disk. The global permit stays with the async waiter, /// allowing healthy disks to make progress after a timed-out request is dropped. async fn run_file_sync_blocking(disk_permits: Arc, work: F) -> io::Result where T: Send + 'static, F: FnOnce() -> io::Result + Send + 'static, { let (disk_permit, global_permit) = acquire_file_sync_permits(disk_permits).await?; let result = tokio::task::spawn_blocking(move || { let _disk_permit = disk_permit; work() }) .await; drop(global_permit); result? } #[cfg(test)] pub(crate) mod file_sync_probe { use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Condvar, Mutex, RwLock}; use std::time::Duration; use tokio::sync::Notify; use tokio::time::timeout; static ROOTS: RwLock> = RwLock::new(Vec::new()); static BLOCK_MUTEX: Mutex<()> = Mutex::new(()); static BLOCK_CONDVAR: Condvar = Condvar::new(); static ACTIVE_CHANGED: Notify = Notify::const_new(); static ACTIVE: AtomicUsize = AtomicUsize::new(0); static PEAK: AtomicUsize = AtomicUsize::new(0); static ATTEMPTS: AtomicUsize = AtomicUsize::new(0); static FAIL_ON_ATTEMPT: AtomicUsize = AtomicUsize::new(usize::MAX); static BLOCK: AtomicBool = AtomicBool::new(false); const WAIT_TIMEOUT: Duration = Duration::from_secs(30); pub(crate) struct ProbeGuard; pub(super) struct ActiveGuard { fail: bool, } impl ActiveGuard { pub(super) fn should_fail(&self) -> bool { self.fail } } impl Drop for ActiveGuard { fn drop(&mut self) { ACTIVE.fetch_sub(1, Ordering::SeqCst); ACTIVE_CHANGED.notify_waiters(); } } impl Drop for ProbeGuard { fn drop(&mut self) { release(); FAIL_ON_ATTEMPT.store(usize::MAX, Ordering::SeqCst); ROOTS.write().expect("file sync probe lock poisoned").clear(); } } fn configure(root: &Path, fail_on_attempt: Option, block: bool) -> ProbeGuard { ACTIVE.store(0, Ordering::SeqCst); PEAK.store(0, Ordering::SeqCst); ATTEMPTS.store(0, Ordering::SeqCst); FAIL_ON_ATTEMPT.store(fail_on_attempt.unwrap_or(usize::MAX), Ordering::SeqCst); { let _guard = BLOCK_MUTEX.lock().expect("file sync probe blocker poisoned"); BLOCK.store(block, Ordering::SeqCst); } let mut roots = vec![root.to_path_buf()]; if let Ok(canonical) = root.canonicalize() && canonical != root { roots.push(canonical); } *ROOTS.write().expect("file sync probe lock poisoned") = roots; ProbeGuard } pub(super) fn set(root: &Path) -> ProbeGuard { configure(root, None, false) } pub(super) fn set_failing(root: &Path) -> ProbeGuard { configure(root, Some(1), false) } pub(super) fn set_failing_blocking(root: &Path) -> ProbeGuard { configure(root, Some(1), true) } pub(crate) fn set_blocking(root: &Path) -> ProbeGuard { configure(root, None, true) } pub(super) fn enter(path: &Path) -> Option { let canonical_path = path.canonicalize().ok(); let enabled = ROOTS.read().expect("file sync probe lock poisoned").iter().any(|root| { path.starts_with(root) || canonical_path.as_ref().is_some_and(|canonical| canonical.starts_with(root)) }); if !enabled { return None; } let attempt = ATTEMPTS.fetch_add(1, Ordering::SeqCst) + 1; let active = ACTIVE.fetch_add(1, Ordering::SeqCst) + 1; PEAK.fetch_max(active, Ordering::SeqCst); ACTIVE_CHANGED.notify_waiters(); let fail = attempt == FAIL_ON_ATTEMPT.load(Ordering::SeqCst); if !fail { let guard = BLOCK_MUTEX.lock().expect("file sync probe blocker poisoned"); drop( BLOCK_CONDVAR .wait_while(guard, |_| BLOCK.load(Ordering::SeqCst)) .expect("file sync probe blocker poisoned"), ); } Some(ActiveGuard { fail }) } pub(crate) fn peak() -> usize { PEAK.load(Ordering::SeqCst) } pub(super) fn attempts() -> usize { ATTEMPTS.load(Ordering::SeqCst) } pub(crate) async fn wait_for_active(target: usize) { timeout(WAIT_TIMEOUT, async { loop { let changed = ACTIVE_CHANGED.notified(); if ACTIVE.load(Ordering::SeqCst) >= target { return; } changed.await; } }) .await .unwrap_or_else(|_| { panic!( "timed out waiting for {target} active file sync probes; active={}, peak={}, attempts={}", ACTIVE.load(Ordering::SeqCst), PEAK.load(Ordering::SeqCst), ATTEMPTS.load(Ordering::SeqCst) ) }); } pub(super) async fn wait_for_idle() { timeout(WAIT_TIMEOUT, async { loop { let changed = ACTIVE_CHANGED.notified(); if ACTIVE.load(Ordering::SeqCst) == 0 { return; } changed.await; } }) .await .unwrap_or_else(|_| { panic!( "timed out waiting for file sync probes to become idle; active={}, peak={}, attempts={}", ACTIVE.load(Ordering::SeqCst), PEAK.load(Ordering::SeqCst), ATTEMPTS.load(Ordering::SeqCst) ) }); } pub(crate) fn release() { let _guard = BLOCK_MUTEX.lock().expect("file sync probe blocker poisoned"); BLOCK.store(false, Ordering::SeqCst); BLOCK_CONDVAR.notify_all(); } } pub(crate) fn sync_file(path: &Path) -> io::Result<()> { #[cfg(test)] let _probe = file_sync_probe::enter(path); #[cfg(test)] if _probe.as_ref().is_some_and(file_sync_probe::ActiveGuard::should_fail) { return Err(io::Error::other("injected file sync failure")); } #[cfg(windows)] let file = std::fs::OpenOptions::new().write(true).open(path)?; #[cfg(not(windows))] let file = std::fs::File::open(path)?; file.sync_data() } fn sync_files(paths: &[PathBuf]) -> io::Result<()> { for path in paths { sync_file(path)?; } Ok(()) } fn regular_files(dir: &Path) -> io::Result> { let mut files = Vec::with_capacity(PARALLEL_FILE_SYNC_THRESHOLD); for entry in std::fs::read_dir(dir)? { let entry = entry?; if entry.file_type()?.is_file() { files.push(entry.path()); } } Ok(files) } /// Fdatasync every regular file directly inside `dir`, then fsync the directory /// itself. pub fn sync_dir_files_std(dir: impl AsRef) -> io::Result<()> { for entry in std::fs::read_dir(dir.as_ref())? { let entry = entry?; if entry.file_type()?.is_file() { sync_file(&entry.path())?; } } fsync_dir_std(dir) } /// Async wrapper around [`sync_dir_files_std`]. Large directories flush files /// concurrently, bounded both per directory and process-wide. pub async fn sync_dir_files(dir: impl AsRef) -> io::Result<()> { sync_dir_files_with_limiter(dir, Arc::new(Semaphore::new(MAX_PARALLEL_FILE_SYNCS))).await } pub(crate) async fn sync_dir_files_with_limiter(dir: impl AsRef, disk_permits: Arc) -> io::Result<()> { let dir = dir.as_ref().to_path_buf(); let scan_dir = dir.clone(); let files = run_file_sync_blocking(disk_permits.clone(), move || { let files = regular_files(&scan_dir)?; if files.len() < PARALLEL_FILE_SYNC_THRESHOLD { sync_files(&files)?; fsync_dir_std(scan_dir)?; return Ok(None); } Ok::<_, io::Error>(Some(files)) }) .await?; let Some(files) = files else { return Ok(()); }; futures::stream::iter(files.into_iter().map(Ok::<_, io::Error>)) .try_for_each_concurrent(MAX_PARALLEL_FILE_SYNCS, |path| { let disk_permits = disk_permits.clone(); async move { run_file_sync_blocking(disk_permits, move || sync_file(&path)).await } }) .await?; run_file_sync_blocking(disk_permits, move || fsync_dir_std(dir)).await } /// Check if the given disk path is the root disk. /// On Windows, always return false. /// On Unix, compare the disk paths. #[tracing::instrument(level = "debug", skip_all)] pub fn is_root_disk(disk_path: &str, root_disk: &str) -> Result { if cfg!(target_os = "windows") { return Ok(false); } rustfs_utils::os::same_disk(disk_path, root_disk).map_err(|e| to_file_error(e).into()) } /// Create a directory and all its parent components if they are missing. #[tracing::instrument(level = "debug", skip_all)] pub async fn make_dir_all(path: impl AsRef, base_dir: impl AsRef) -> Result<()> { check_path_length(path.as_ref().to_string_lossy().to_string().as_str())?; reliable_mkdir_all(path.as_ref(), base_dir.as_ref()) .await .map_err(to_file_error)?; Ok(()) } /// Check if a directory is empty. /// Only reads one entry to determine if the directory is empty. #[tracing::instrument(level = "debug", skip_all)] pub async fn is_empty_dir(path: impl AsRef) -> bool { read_dir(path.as_ref(), 1).await.is_ok_and(|v| v.is_empty()) } const READ_DIR_PROBE_RAW_LIMIT: usize = 256; pub(crate) struct ReadDirProbe { pub entries: Vec, pub complete: bool, } pub(crate) fn read_dir_probe(path: impl AsRef, entry_limit: usize) -> io::Result { let mut dir = std::fs::read_dir(path)?; let mut entries = Vec::with_capacity(entry_limit.min(READ_DIR_PROBE_RAW_LIMIT)); for _ in 0..READ_DIR_PROBE_RAW_LIMIT { let Some(entry) = dir.next() else { return Ok(ReadDirProbe { entries, complete: true }); }; let entry = entry?; let name = entry.file_name().to_string_lossy().to_string(); if name.is_empty() || name == "." || name == ".." { continue; } let file_type = entry.file_type()?; if file_type.is_file() { entries.push(name); } else if file_type.is_dir() { entries.push(format!("{name}{SLASH_SEPARATOR}")); } else { continue; } if entries.len() == entry_limit { return Ok(ReadDirProbe { entries, complete: false, }); } } Ok(ReadDirProbe { entries, complete: false, }) } // read_dir count read limit. when count == 0 unlimit. /// Return file names in the directory. #[tracing::instrument(level = "debug", skip_all)] pub async fn read_dir(path: impl AsRef, count: i32) -> std::io::Result> { let mut entries = fs::read_dir(path.as_ref()).await?; let mut volumes = Vec::new(); let mut count = count; while let Some(entry) = entries.next_entry().await? { let name = entry.file_name().to_string_lossy().to_string(); if name.is_empty() || name == "." || name == ".." { continue; } let file_type = entry.file_type().await?; if file_type.is_file() { volumes.push(name); } else if file_type.is_dir() { volumes.push(format!("{name}{SLASH_SEPARATOR}")); } else { // Entries we don't return (symlinks, sockets, fifos) must not consume // the limit: is_empty_dir/list_dir(count=1) would misreport otherwise. continue; } count -= 1; if count == 0 { break; } } Ok(volumes) } #[tracing::instrument(level = "debug", skip_all)] pub async fn rename_all( src_file_path: impl AsRef, dst_file_path: impl AsRef, base_dir: impl AsRef, publication_root: &PublicationRoot, ) -> Result<()> { reliable_rename(src_file_path, dst_file_path.as_ref(), base_dir, publication_root) .await .map_err(to_file_error)?; Ok(()) } pub(crate) async fn rename_all_with_lease( src_file_path: impl AsRef, dst_file_path: impl AsRef, base_dir: impl AsRef, publication_root: &PublicationRoot, lease: Arc, ) -> Result<()> { reliable_rename_inner_with_lease( src_file_path.as_ref().to_path_buf(), dst_file_path.as_ref().to_path_buf(), base_dir.as_ref().to_path_buf(), publication_root.clone(), true, lease, ) .await .map_err(to_file_error)?; Ok(()) } #[cfg(windows)] #[tracing::instrument(level = "debug", skip_all)] pub(crate) async fn rename_all_with_commit_guard( src_file_path: impl AsRef, dst_file_path: impl AsRef, base_dir: impl AsRef, _publication_root: &PublicationRoot, commit_guard: &RenameCommitGuard, lease: Arc, ) -> Result<()> { let src_file_path = src_file_path.as_ref().to_path_buf(); let dst_file_path = dst_file_path.as_ref().to_path_buf(); let base_dir = base_dir.as_ref().to_path_buf(); let commit_guard = commit_guard.clone(); let operation = { let src_file_path = src_file_path.clone(); let dst_file_path = dst_file_path.clone(); move || rename_with_commit_guard_std(&src_file_path, &dst_file_path, &commit_guard) }; let result = run_blocking_namespace_operation(lease, operation).await; if let Err(err) = &result { warn_reliable_rename_failure(&src_file_path, &dst_file_path, &base_dir, err); } result.map_err(to_file_error)?; Ok(()) } pub(crate) struct PreparedRenameSource { path: PathBuf, #[cfg(windows)] source: winapi_util::Handle, #[cfg(not(windows))] source: std::fs::File, #[cfg(unix)] device: u64, #[cfg(unix)] inode: u64, } impl PreparedRenameSource { pub(crate) fn write_all(&mut self, data: &[u8], sync: bool) -> io::Result<()> { #[cfg(all(test, windows))] windows_rename_test_hooks::run_before_source_write(&self.path); #[cfg(windows)] std::io::Write::write_all(self.source.as_file_mut(), data)?; #[cfg(not(windows))] std::io::Write::write_all(&mut self.source, data)?; if sync { #[cfg(windows)] self.source.as_file().sync_data()?; #[cfg(not(windows))] self.source.sync_data()?; } Ok(()) } } pub(crate) fn create_prepared_rename_source_with_commit_guard( src_file_path: &Path, dst_file_path: &Path, commit_guard: &RenameCommitGuard, ) -> io::Result { #[cfg(windows)] { if src_file_path.parent() != Some(commit_guard.source_parent.as_path()) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "rename source parent does not match its commit guard", )); } if dst_file_path.parent() != Some(commit_guard.destination_parent.as_path()) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "rename destination parent does not match its commit guard", )); } let source_name = src_file_path .file_name() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename source must have a file name"))?; let source = create_windows_superseding_file(commit_guard.source_parent_guard.last_handle()?, source_name)?; return Ok(PreparedRenameSource { path: src_file_path.to_path_buf(), source, }); } #[cfg(not(windows))] { let _ = (dst_file_path, commit_guard); let source = std::fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .open(src_file_path)?; #[cfg(unix)] let metadata = source.metadata()?; #[cfg(unix)] use std::os::unix::fs::MetadataExt; Ok(PreparedRenameSource { path: src_file_path.to_path_buf(), source, #[cfg(unix)] device: metadata.dev(), #[cfg(unix)] inode: metadata.ino(), }) } } #[cfg(windows)] pub(crate) fn read_destination_file_with_commit_guard( file_path: &Path, commit_guard: &RenameCommitGuard, ) -> io::Result>> { if file_path.parent() != Some(commit_guard.destination_parent.as_path()) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "destination file parent does not match its commit guard", )); } read_windows_relative_file(file_path, &commit_guard.destination_parent_guard) } #[cfg(windows)] #[tracing::instrument(level = "debug", skip_all)] pub(crate) async fn rename_all_with_prepared_source( prepared_source: PreparedRenameSource, src_file_path: impl AsRef, dst_file_path: impl AsRef, base_dir: impl AsRef, _publication_root: &PublicationRoot, commit_guard: &RenameCommitGuard, lease: Arc, ) -> Result<()> { let src_file_path = src_file_path.as_ref().to_path_buf(); let dst_file_path = dst_file_path.as_ref().to_path_buf(); let base_dir = base_dir.as_ref().to_path_buf(); let commit_guard = commit_guard.clone(); let operation = { let src_file_path = src_file_path.clone(); let dst_file_path = dst_file_path.clone(); move || rename_prepared_source_with_commit_guard_std(&prepared_source, &src_file_path, &dst_file_path, &commit_guard) }; let result = run_blocking_namespace_operation(lease, operation).await; if let Err(err) = &result { warn_reliable_rename_failure(&src_file_path, &dst_file_path, &base_dir, err); } result.map_err(to_file_error)?; Ok(()) } #[cfg(not(windows))] pub(crate) async fn rename_all_with_prepared_source( prepared_source: PreparedRenameSource, src_file_path: impl AsRef, dst_file_path: impl AsRef, base_dir: impl AsRef, publication_root: &PublicationRoot, _commit_guard: &RenameCommitGuard, lease: Arc, ) -> Result<()> { let src_file_path = src_file_path.as_ref().to_path_buf(); let dst_file_path = dst_file_path.as_ref().to_path_buf(); let base_dir = base_dir.as_ref().to_path_buf(); let publication_root = publication_root.clone(); let operation = { let src_file_path = src_file_path.clone(); let dst_file_path = dst_file_path.clone(); let base_dir = base_dir.clone(); move || { validate_prepared_rename_source(&prepared_source, &src_file_path)?; let (preparation, attempt) = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?; rename_prepared(&src_file_path, &dst_file_path, &preparation, attempt) } }; let result = run_blocking_namespace_operation(lease, operation).await; if let Err(err) = &result { warn_reliable_rename_failure(&src_file_path, &dst_file_path, &base_dir, err); } result.map_err(to_file_error)?; Ok(()) } #[cfg(not(windows))] pub(crate) async fn rename_all_with_commit_guard( src_file_path: impl AsRef, dst_file_path: impl AsRef, base_dir: impl AsRef, publication_root: &PublicationRoot, _commit_guard: &RenameCommitGuard, lease: Arc, ) -> Result<()> { rename_all_with_lease(src_file_path, dst_file_path, base_dir, publication_root, lease).await } #[tracing::instrument(level = "debug", skip_all)] pub async fn rename_all_ignore_missing_source( src_file_path: impl AsRef, dst_file_path: impl AsRef, base_dir: impl AsRef, publication_root: &PublicationRoot, ) -> Result<()> { let src_file_path = src_file_path.as_ref(); match reliable_rename_inner(src_file_path, dst_file_path.as_ref(), base_dir, publication_root, false).await { Ok(()) => Ok(()), Err(err) if err.kind() == io::ErrorKind::NotFound && rename_source_is_missing(src_file_path, publication_root) => Ok(()), Err(err) => Err(to_file_error(err).into()), } } #[cfg(windows)] pub(crate) fn rename_source_is_missing(src_file_path: &Path, publication_root: &PublicationRoot) -> bool { let Some(source_parent) = src_file_path.parent() else { return false; }; let source_parent_guard = match lock_windows_directory_tree(source_parent, None, publication_root) { Ok(guard) => guard, Err(err) => return err.kind() == io::ErrorKind::NotFound, }; match open_windows_rename_source_identity(src_file_path, &source_parent_guard) { Ok(_) => false, Err(err) => err.kind() == io::ErrorKind::NotFound, } } #[cfg(not(windows))] pub(crate) fn rename_source_is_missing(src_file_path: &Path, _publication_root: &PublicationRoot) -> bool { matches!(std::fs::symlink_metadata(src_file_path), Err(err) if err.kind() == io::ErrorKind::NotFound) } async fn reliable_rename( src_file_path: impl AsRef, dst_file_path: impl AsRef, base_dir: impl AsRef, publication_root: &PublicationRoot, ) -> io::Result<()> { reliable_rename_inner(src_file_path, dst_file_path, base_dir, publication_root, true).await } async fn reliable_rename_inner( src_file_path: impl AsRef, dst_file_path: impl AsRef, base_dir: impl AsRef, publication_root: &PublicationRoot, warn_on_missing_source: bool, ) -> io::Result<()> { let src_file_path = src_file_path.as_ref().to_path_buf(); let dst_file_path = dst_file_path.as_ref().to_path_buf(); let base_dir = base_dir.as_ref().to_path_buf(); let lease = acquire_namespace_mutation_lease(&dst_file_path).await; reliable_rename_inner_with_lease( src_file_path, dst_file_path, base_dir, publication_root.clone(), warn_on_missing_source, lease, ) .await } async fn reliable_rename_inner_with_lease( src_file_path: PathBuf, dst_file_path: PathBuf, base_dir: PathBuf, publication_root: PublicationRoot, warn_on_missing_source: bool, lease: Arc, ) -> io::Result<()> { let operation = { let src_file_path = src_file_path.clone(); let dst_file_path = dst_file_path.clone(); let base_dir = base_dir.clone(); move || { let (preparation, attempt) = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?; rename_prepared(&src_file_path, &dst_file_path, &preparation, attempt) } }; let result = run_blocking_namespace_operation(lease, operation).await; if let Err(err) = &result && (warn_on_missing_source || err.kind() != io::ErrorKind::NotFound) { warn_reliable_rename_failure(&src_file_path, &dst_file_path, &base_dir, err); } result } #[cfg(not(windows))] fn validate_prepared_rename_source(prepared_source: &PreparedRenameSource, src_file_path: &Path) -> io::Result<()> { if prepared_source.path != src_file_path { return Err(io::Error::new( io::ErrorKind::InvalidInput, "prepared rename source does not match the requested source path", )); } #[cfg(unix)] { use std::os::unix::fs::MetadataExt; let metadata = std::fs::symlink_metadata(src_file_path)?; if metadata.dev() != prepared_source.device || metadata.ino() != prepared_source.inode { return Err(io::Error::new( io::ErrorKind::InvalidData, "rename source identity changed while publication was prepared", )); } } Ok(()) } #[cfg(windows)] fn rename_with_commit_guard_std(src_file_path: &Path, dst_file_path: &Path, commit_guard: &RenameCommitGuard) -> io::Result<()> { let prepared_source = PreparedRenameSource { path: src_file_path.to_path_buf(), source: prepare_windows_rename_source(src_file_path, dst_file_path, commit_guard)?, }; rename_prepared_source_with_commit_guard_std(&prepared_source, src_file_path, dst_file_path, commit_guard) } #[cfg(windows)] fn prepare_windows_rename_source( src_file_path: &Path, _dst_file_path: &Path, commit_guard: &RenameCommitGuard, ) -> io::Result { if src_file_path.parent() != Some(commit_guard.source_parent.as_path()) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "rename source parent does not match its commit guard", )); } let (source_identity_anchor, expected_source_identity) = open_windows_rename_source_identity(src_file_path, &commit_guard.source_parent_guard)?; let mut attempt = 0; let source = loop { match open_windows_rename_source(src_file_path, &commit_guard.source_parent_guard) { Ok(source) => break source, Err(err) if should_retry_rename(&err, attempt) => { #[cfg(test)] windows_rename_test_hooks::run_before_rename_retry(_dst_file_path); attempt += 1; } Err(err) => return Err(err), } }; if windows_file_identity(&source)? != expected_source_identity { return Err(io::Error::new( io::ErrorKind::InvalidData, "rename source identity changed while publication was prepared", )); } drop(source_identity_anchor); Ok(source) } #[cfg(windows)] fn rename_prepared_source_with_commit_guard_std( prepared_source: &PreparedRenameSource, src_file_path: &Path, dst_file_path: &Path, commit_guard: &RenameCommitGuard, ) -> io::Result<()> { if prepared_source.path != src_file_path { return Err(io::Error::new( io::ErrorKind::InvalidInput, "prepared rename source does not match the requested source path", )); } if src_file_path.parent() != Some(commit_guard.source_parent.as_path()) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "rename source parent does not match its commit guard", )); } if dst_file_path.parent() != Some(commit_guard.destination_parent.as_path()) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "rename destination parent does not match its commit guard", )); } rename_windows_prepared(dst_file_path, &commit_guard.destination_parent_guard, &prepared_source.source, 0) } /// Run a blocking namespace operation without making its async waiter /// uncancellable. The owned lease moves into the closure, so a timed-out task /// cannot release transaction serialization before the syscall returns. pub(crate) async fn run_blocking_namespace_operation( lease: Arc, operation: impl FnOnce() -> io::Result + Send + 'static, ) -> io::Result { tokio::task::spawn_blocking(move || { let _lease = lease; operation() }) .await .map_err(|err| io::Error::other(format!("blocking namespace operation failed: {err}")))? } /// Admit one strict inline commit under the disk sync limit. The caller already /// owns the namespace lease, establishing namespace -> disk ordering. Holding /// admission across adjacent durability barriers prevents one transaction from /// repeatedly joining the disk semaphore tail. pub(crate) struct FileSyncAdmission { disk_permit: Arc, } pub(crate) async fn acquire_file_sync_admission(disk_permits: Arc) -> io::Result { let disk_permit = disk_permits .acquire_owned() .await .map_err(|_| io::Error::other("disk file sync concurrency limiter closed"))?; Ok(FileSyncAdmission { disk_permit: Arc::new(disk_permit), }) } /// Keep the disk admission and namespace lease with the blocking syscall if /// the async waiter is cancelled. The process-wide admission remains with the /// waiter so cancellation cannot starve healthy disks. pub(crate) async fn run_blocking_namespace_file_sync_operation( lease: Arc, admission: &FileSyncAdmission, operation: impl FnOnce() -> io::Result + Send + 'static, ) -> io::Result { run_blocking_namespace_file_sync_operation_with_global(lease, admission, &FILE_SYNC_PERMITS, operation).await } async fn run_blocking_namespace_file_sync_operation_with_global( lease: Arc, admission: &FileSyncAdmission, global_permits: &Semaphore, operation: impl FnOnce() -> io::Result + Send + 'static, ) -> io::Result { let global_permit = global_permits .acquire() .await .map_err(|_| io::Error::other("global file sync concurrency limiter closed"))?; let disk_permit = admission.disk_permit.clone(); let result = tokio::task::spawn_blocking(move || { let _lease = lease; let _disk_permit = disk_permit; operation() }) .await; drop(global_permit); result.map_err(|err| io::Error::other(format!("blocking namespace file sync operation failed: {err}")))? } pub(crate) async fn fsync_dir_with_namespace_file_sync_limit( dir: impl AsRef, lease: Arc, admission: &FileSyncAdmission, ) -> io::Result<()> { #[cfg(unix)] { let dir = dir.as_ref().to_path_buf(); run_blocking_namespace_file_sync_operation(lease, admission, move || { #[cfg(test)] fsync_dir_recorder::record_limited(&dir); fsync_dir_std(dir) }) .await } #[cfg(not(unix))] { let _ = (lease, admission); fsync_dir_std(dir) } } struct RenamePreparation { parent_guard: Option, #[cfg(windows)] _source_parent_guard: ExistingBaseDirectoryGuard, #[cfg(windows)] source: winapi_util::Handle, } #[cfg(not(windows))] fn prepare_rename_with_retry( src_file_path: &Path, dst_file_path: &Path, base_dir: &Path, publication_root: &PublicationRoot, ) -> io::Result<(RenamePreparation, usize)> { let mut attempt = 0; loop { match prepare_rename(src_file_path, dst_file_path, base_dir, publication_root) { Ok(preparation) => return Ok((preparation, attempt)), Err(err) if should_retry_rename(&err, attempt) => { attempt += 1; } Err(err) => return Err(err), } } } #[cfg(windows)] fn prepare_rename_with_retry( src_file_path: &Path, dst_file_path: &Path, base_dir: &Path, publication_root: &PublicationRoot, ) -> io::Result<(RenamePreparation, usize)> { let source_parent = src_file_path .parent() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename source must have a parent directory"))?; let destination_parent = dst_file_path.parent(); let mut attempt = 0; let prepare_destination_parent = |attempt: &mut usize| -> io::Result> { loop { let result = destination_parent .map(|parent| mkdir_all_below_existing_base_std(parent, base_dir, publication_root)) .transpose(); match result { Ok(parent_guard) => break Ok(parent_guard), Err(err) if should_retry_rename(&err, *attempt) => { #[cfg(test)] windows_rename_test_hooks::run_before_rename_retry(dst_file_path); *attempt += 1; } Err(err) => break Err(err), } } }; let same_parent = match destination_parent { Some(destination_parent) => { publication_root.relative_path(source_parent)? == publication_root.relative_path(destination_parent)? } None => false, }; let (source_parent_guard, parent_guard, source_identity_anchor, expected_source_identity) = if same_parent { let parent_guard = prepare_destination_parent(&mut attempt)?; let source_parent_guard = parent_guard .as_ref() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename destination must have a parent directory"))? .clone(); let (source_identity_anchor, expected_source_identity) = open_windows_rename_source_identity(src_file_path, &source_parent_guard)?; (source_parent_guard, parent_guard, source_identity_anchor, expected_source_identity) } else { let source_parent_guard = lock_windows_directory_tree(source_parent, destination_parent, publication_root)?; let (source_identity_anchor, expected_source_identity) = open_windows_rename_source_identity(src_file_path, &source_parent_guard)?; let parent_guard = prepare_destination_parent(&mut attempt)?; (source_parent_guard, parent_guard, source_identity_anchor, expected_source_identity) }; let source = loop { match open_windows_rename_source(src_file_path, &source_parent_guard) { Ok(source) => break source, Err(err) if should_retry_rename(&err, attempt) => { #[cfg(test)] windows_rename_test_hooks::run_before_rename_retry(dst_file_path); attempt += 1; } Err(err) => return Err(err), } }; if windows_file_identity(&source)? != expected_source_identity { return Err(io::Error::new( io::ErrorKind::InvalidData, "rename source identity changed while publication was prepared", )); } drop(source_identity_anchor); Ok(( RenamePreparation { parent_guard, _source_parent_guard: source_parent_guard, source, }, attempt, )) } #[cfg(not(windows))] fn prepare_rename( _src_file_path: &Path, dst_file_path: &Path, base_dir: &Path, publication_root: &PublicationRoot, ) -> io::Result { let parent_guard = dst_file_path .parent() .map(|parent| mkdir_all_below_existing_base_std(parent, base_dir, publication_root)) .transpose()?; Ok(RenamePreparation { parent_guard }) } fn rename_prepared( _src_file_path: &Path, dst_file_path: &Path, preparation: &RenamePreparation, attempt: usize, ) -> io::Result<()> { #[cfg(windows)] { let parent_guard = preparation .parent_guard .as_ref() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename destination must have a parent directory"))?; rename_windows_prepared(dst_file_path, parent_guard, &preparation.source, attempt) } #[cfg(not(windows))] { let mut attempt = attempt; loop { let rename_result = rename_into_existing_parent(_src_file_path, dst_file_path, preparation.parent_guard.as_ref()); match rename_result { Ok(()) => return Ok(()), Err(err) if should_retry_rename(&err, attempt) => { attempt += 1; } Err(err) => return Err(err), } } } } #[cfg(windows)] fn rename_windows_prepared( dst_file_path: &Path, parent_guard: &ExistingBaseDirectoryGuard, source: &winapi_util::Handle, mut attempt: usize, ) -> io::Result<()> { loop { #[cfg(test)] windows_rename_test_hooks::record_guard_generation(dst_file_path, parent_guard.generation); match rename_into_existing_parent(dst_file_path, Some(parent_guard), source) { Ok(()) => return Ok(()), Err(err) if should_retry_rename(&err, attempt) => { #[cfg(test)] windows_rename_test_hooks::run_before_rename_retry(dst_file_path); attempt += 1; } Err(err) => return Err(err), } } } #[cfg(unix)] fn rename_into_existing_parent( src_file_path: &Path, dst_file_path: &Path, parent_guard: Option<&ExistingBaseDirectoryGuard>, ) -> io::Result<()> { use rustix::fs::{Mode, OFlags, open, renameat}; let Some(parent_guard) = parent_guard else { return super::fs::rename_std(src_file_path, dst_file_path); }; let src_parent = src_file_path .parent() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename source must have a parent directory"))?; let src_name = src_file_path .file_name() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename source must have a file name"))?; let dst_name = dst_file_path .file_name() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename destination must have a file name"))?; let src_parent = open( src_parent, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), ) .map_err(io::Error::from)?; let dst_parent = parent_guard .last() .ok_or_else(|| io::Error::other("rename destination parent guard is empty"))?; renameat(&src_parent, src_name, dst_parent, dst_name).map_err(io::Error::from) } #[cfg(windows)] // SAFETY: this helper builds the variable-length FILE_RENAME_INFORMATION buffer // with checked sizes and passes borrowed live handles only to synchronous NT calls. #[allow(unsafe_code)] fn rename_into_existing_parent( dst_file_path: &Path, parent_guard: Option<&ExistingBaseDirectoryGuard>, source: &winapi_util::Handle, ) -> io::Result<()> { use std::{ mem::size_of, os::windows::{ffi::OsStrExt, io::AsRawHandle}, }; use windows_sys::{ Wdk::Storage::FileSystem::{ FILE_RENAME_INFORMATION, FILE_RENAME_INFORMATION_0, FileRenameInformation, FileRenameInformationEx, NtSetInformationFile, }, Win32::{ Foundation::{ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, RtlNtStatusToDosError}, System::IO::IO_STATUS_BLOCK, }, }; let parent_guard = parent_guard .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename destination must have a parent directory"))?; #[cfg(test)] windows_rename_test_hooks::run_before_publication(dst_file_path); let dst_parent = parent_guard.last_handle()?; let dst_name = dst_file_path .file_name() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename destination must have a file name"))?; let dst_name = dst_name.encode_wide().collect::>(); if dst_name.is_empty() || dst_name.contains(&0) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "rename destination file name is empty or contains a NUL", )); } let file_name_bytes = dst_name .len() .checked_mul(size_of::()) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename destination file name is too long"))?; let file_name_length = u32::try_from(file_name_bytes) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "rename destination file name is too long"))?; let buffer_size = size_of::() .checked_add(file_name_bytes) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename information buffer is too large"))?; let buffer_size_u32 = u32::try_from(buffer_size) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "rename information buffer is too large"))?; let words = buffer_size.div_ceil(size_of::()); let mut buffer = vec![0usize; words]; let rename_info = buffer.as_mut_ptr().cast::(); // SAFETY: `buffer` is aligned for FILE_RENAME_INFORMATION and large enough for // its header, the complete UTF-16 name, and trailing zeroed storage. // `dst_parent` and `source` remain live until the synchronous call returns. unsafe { (*rename_info).Anonymous = FILE_RENAME_INFORMATION_0 { ReplaceIfExists: true }; (*rename_info).RootDirectory = dst_parent.as_raw_handle(); (*rename_info).FileNameLength = file_name_length; std::ptr::copy_nonoverlapping( dst_name.as_ptr(), std::ptr::addr_of_mut!((*rename_info).FileName).cast::(), dst_name.len(), ); } // Keep the target relative to the retained parent handle so publication // cannot be redirected by replacing a pathname component. let mut io_status = IO_STATUS_BLOCK::default(); let status = unsafe { NtSetInformationFile( source.as_raw_handle(), &mut io_status, rename_info.cast(), buffer_size_u32, FileRenameInformation, ) }; if status >= 0 { return Ok(()); } let status_error = |status| { let code = unsafe { RtlNtStatusToDosError(status) }; let error = match i32::try_from(code) { Ok(code) => io::Error::from_raw_os_error(code), Err(_) => io::Error::other(format!("Windows rename failed with NTSTATUS {status:#x}")), }; (code, error) }; let (legacy_error_code, legacy_error) = status_error(status); if !matches!(legacy_error_code, ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION) { return Err(legacy_error); } // Match std::fs::rename's Windows fallback for read-only or open // destinations while retaining the guarded, handle-relative target. Older // FileRenameInformationEx implementations reject IGNORE_READONLY; retry // without only that optional flag so open-destination replacement remains // compatible while read-only destinations still fail explicitly there. windows_extended_rename_with_compatibility_fallback(legacy_error, |flags| { unsafe { (*rename_info).Anonymous = FILE_RENAME_INFORMATION_0 { Flags: flags }; } let status = unsafe { NtSetInformationFile( source.as_raw_handle(), &mut io_status, rename_info.cast(), buffer_size_u32, FileRenameInformationEx, ) }; if status >= 0 { Ok(()) } else { Err(status_error(status).1) } }) } #[cfg(windows)] fn windows_extended_rename_with_compatibility_fallback( legacy_error: io::Error, mut rename: impl FnMut(u32) -> io::Result<()>, ) -> io::Result<()> { use windows_sys::{ Wdk::Storage::FileSystem::{ FILE_RENAME_IGNORE_READONLY_ATTRIBUTE, FILE_RENAME_POSIX_SEMANTICS, FILE_RENAME_REPLACE_IF_EXISTS, }, Win32::Foundation::{ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, ERROR_NOT_SUPPORTED}, }; let compatible_flags = FILE_RENAME_REPLACE_IF_EXISTS | FILE_RENAME_POSIX_SEMANTICS; let result = match rename(compatible_flags | FILE_RENAME_IGNORE_READONLY_ATTRIBUTE) { Err(err) if err .raw_os_error() .and_then(|code| u32::try_from(code).ok()) .is_some_and(|code| matches!(code, ERROR_INVALID_FUNCTION | ERROR_INVALID_PARAMETER | ERROR_NOT_SUPPORTED)) => { rename(compatible_flags) } result => result, }; match result { Err(err) if err .raw_os_error() .and_then(|code| u32::try_from(code).ok()) .is_some_and(|code| matches!(code, ERROR_INVALID_FUNCTION | ERROR_INVALID_PARAMETER | ERROR_NOT_SUPPORTED)) => { Err(legacy_error) } result => result, } } #[cfg(all(not(unix), not(windows)))] fn rename_into_existing_parent( src_file_path: &Path, dst_file_path: &Path, _parent_guard: Option<&ExistingBaseDirectoryGuard>, ) -> io::Result<()> { super::fs::rename_std(src_file_path, dst_file_path) } #[cfg(windows)] #[derive(Clone)] struct WindowsDirectoryHandle { handle: Arc, } /// Stable root for namespace-changing disk operations. /// /// Windows opens the configured endpoint once and keeps that directory identity /// pinned for the lifetime of the disk. Publication then resolves every source /// and destination component relative to this handle instead of re-entering the /// mutable pathname namespace. Other platforms retain the path so callers use a /// uniform API while their existing `openat`/`renameat` guards remain unchanged. #[derive(Clone)] pub(crate) struct PublicationRoot { path: PathBuf, #[cfg(windows)] configured_path: PathBuf, #[cfg(windows)] directory: WindowsDirectoryHandle, } impl PublicationRoot { pub(crate) fn new(path: &Path) -> io::Result { if !path.is_absolute() { return Err(io::Error::new(io::ErrorKind::InvalidInput, "publication root must be absolute")); } #[cfg(windows)] let (resolved_path, directory) = open_windows_publication_root(path)?; Ok(Self { #[cfg(not(windows))] path: path.to_path_buf(), #[cfg(windows)] path: resolved_path, #[cfg(windows)] configured_path: path.to_path_buf(), #[cfg(windows)] directory, }) } pub(crate) fn path(&self) -> &Path { &self.path } #[cfg(windows)] fn relative_path<'a>(&self, path: &'a Path) -> io::Result<&'a Path> { // The configured path only derives a suffix; traversal stays rooted at the pinned directory handle. path.strip_prefix(&self.path) .or_else(|_| path.strip_prefix(&self.configured_path)) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path must remain below its publication root")) } } #[cfg(windows)] #[derive(Clone)] pub(crate) struct ExistingBaseDirectoryGuard { handles: Vec, #[cfg(test)] generation: u64, } #[cfg(all(test, windows))] static WINDOWS_DIRECTORY_GUARD_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); #[cfg(windows)] impl ExistingBaseDirectoryGuard { fn new(handles: Vec) -> Self { Self { handles, #[cfg(test)] generation: WINDOWS_DIRECTORY_GUARD_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed), } } fn last_handle(&self) -> io::Result<&winapi_util::Handle> { self.handles .last() .map(|directory| directory.handle.as_ref()) .ok_or_else(|| io::Error::other("Windows directory guard is empty")) } } #[cfg(unix)] pub(crate) type ExistingBaseDirectoryGuard = Vec; #[cfg(all(not(unix), not(windows)))] pub(crate) type ExistingBaseDirectoryGuard = (); #[derive(Clone)] pub(crate) struct RenameCommitGuard { #[cfg(windows)] source_parent: PathBuf, #[cfg(windows)] destination_parent: PathBuf, #[cfg(windows)] source_parent_guard: ExistingBaseDirectoryGuard, #[cfg(windows)] destination_parent_guard: ExistingBaseDirectoryGuard, } pub(crate) struct RenameDestinationPathGuard { #[cfg(windows)] directory: PathBuf, #[cfg(windows)] _directory_guard: ExistingBaseDirectoryGuard, } impl RenameDestinationPathGuard { pub(crate) fn write_file_for_path_access( &self, file_path: &Path, data: &[u8], sync_file: bool, sync_parent: bool, ) -> io::Result<()> { #[cfg(windows)] { if file_path.parent() != Some(self.directory.as_path()) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "guarded destination file must be an immediate child of its directory", )); } file_path .file_name() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "guarded destination file must have a name"))?; let staging_name = format!(".rustfs-write-{}", uuid::Uuid::new_v4()); let WindowsStagedFile { mut writer, publication } = create_windows_staged_file(self._directory_guard.last_handle()?, staging_name.as_ref())?; let write_result: io::Result<()> = (|| { std::io::Write::write_all(writer.as_file_mut(), data)?; if sync_file { writer.as_file().sync_data()?; } Ok(()) })(); if let Err(write_err) = write_result { if let Err(cleanup_err) = set_windows_file_delete_on_close(&publication, true) { return Err(io::Error::new( write_err.kind(), format!("{write_err}; failed to schedule staged file cleanup: {cleanup_err}"), )); } return Err(write_err); } // Windows rejects replacement while the staged entry still has an // active data writer, even though that writer shares deletion. Keep // the separate publication handle as the identity anchor and close // the writer before issuing the handle-relative rename. drop(writer); if let Err(rename_err) = rename_windows_prepared(file_path, &self._directory_guard, &publication, 0) { if let Err(cleanup_err) = set_windows_file_delete_on_close(&publication, true) { return Err(io::Error::new( rename_err.kind(), format!("{rename_err}; failed to schedule staged file cleanup: {cleanup_err}"), )); } return Err(rename_err); } drop(publication); if sync_parent { fsync_dir_std(&self.directory)?; } return Ok(()); } #[cfg(not(windows))] { let _ = self; let mut file = std::fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .open(file_path)?; std::io::Write::write_all(&mut file, data)?; if sync_file { file.sync_data()?; } if sync_parent && let Some(parent) = file_path.parent() { fsync_dir_std(parent)?; } Ok(()) } } } impl RenameCommitGuard { #[cfg(windows)] pub(crate) fn lock_source_directory_for_path_access(&self, directory: &Path) -> io::Result { if directory != self.source_parent { return Err(io::Error::new( io::ErrorKind::InvalidInput, "guarded source directory does not match the rename source parent", )); } Ok(RenameDestinationPathGuard { directory: directory.to_path_buf(), _directory_guard: self.source_parent_guard.clone(), }) } pub(crate) fn lock_destination_directory_for_path_access(&self, directory: &Path) -> io::Result { self.destination_directory_guard(directory, false) } pub(crate) fn create_destination_directory_for_path_access( &self, directory: &Path, ) -> io::Result { self.destination_directory_guard(directory, true) } /// Reopen a destination tree for handle-relative child publication. /// Ancestors remain write-exclusive while the final parent shares writes /// required by the kernel's relative rename. Delete sharing stays omitted /// throughout, so every retained directory identity remains pinned. fn destination_directory_guard(&self, directory: &Path, create_missing: bool) -> io::Result { #[cfg(windows)] { use windows_sys::Wdk::Storage::FileSystem::{FILE_OPEN, FILE_OPEN_IF}; use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE}; let relative = directory.strip_prefix(&self.destination_parent).map_err(|_| { io::Error::new( io::ErrorKind::InvalidInput, "guarded path must remain below the rename destination parent", ) })?; for component in relative.components() { if !matches!(component, Component::Normal(_) | Component::CurDir) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "guarded destination path contains an invalid component", )); } } let component = self.destination_parent.file_name().ok_or_else(|| { io::Error::new( io::ErrorKind::PermissionDenied, "cannot safely reopen the publication root for pathname access", ) })?; let parent_index = self.destination_parent_guard.handles.len().checked_sub(2).ok_or_else(|| { io::Error::new( io::ErrorKind::PermissionDenied, "cannot safely reopen the publication root for pathname access", ) })?; let mut handles = self.destination_parent_guard.handles[..=parent_index].to_vec(); let parent = handles .last() .ok_or_else(|| io::Error::other("Windows destination guard lost its parent handle"))?; let mut relative_components = relative .components() .filter_map(|component| match component { Component::Normal(component) => Some(component), _ => None, }) .peekable(); let destination_parent_share = if relative_components.peek().is_none() { FILE_SHARE_READ | FILE_SHARE_WRITE } else { FILE_SHARE_READ }; handles.push(open_windows_relative_directory_component( parent, component, FILE_OPEN, destination_parent_share, )?); while let Some(component) = relative_components.next() { let parent = handles .last() .ok_or_else(|| io::Error::other("Windows destination path guard lost its parent handle"))?; let disposition = if create_missing { FILE_OPEN_IF } else { FILE_OPEN }; let share_access = if relative_components.peek().is_none() { FILE_SHARE_READ | FILE_SHARE_WRITE } else { FILE_SHARE_READ }; handles.push(open_windows_relative_directory_component(parent, component, disposition, share_access)?); } Ok(RenameDestinationPathGuard { directory: directory.to_path_buf(), _directory_guard: ExistingBaseDirectoryGuard::new(handles), }) } #[cfg(not(windows))] { let _ = (self, directory, create_missing); Ok(RenameDestinationPathGuard {}) } } } pub(crate) fn prepare_rename_commit_guard( source_parent: &Path, destination_parent: &Path, destination_base: &Path, publication_root: &PublicationRoot, ) -> io::Result { #[cfg(windows)] { // A same-directory rename must use one shared-write parent handle: // retaining a second read-only-share handle would block the kernel's // relative target open. Delete sharing stays excluded, so identity is // still pinned. Distinct source trees remain strict. let same_parent = publication_root.relative_path(source_parent)? == publication_root.relative_path(destination_parent)?; let (source_parent_guard, destination_parent_guard) = if same_parent { let destination_parent_guard = mkdir_all_below_existing_base_std(destination_parent, destination_base, publication_root)?; (destination_parent_guard.clone(), destination_parent_guard) } else { // The source parent also hosts private rollback staging files. // Their handle-relative publication needs write sharing on this // final directory while delete sharing remains excluded. let source_parent_guard = lock_windows_directory_tree(source_parent, Some(source_parent), publication_root)?; let destination_parent_guard = mkdir_all_below_existing_base_std(destination_parent, destination_base, publication_root)?; (source_parent_guard, destination_parent_guard) }; Ok(RenameCommitGuard { source_parent: source_parent.to_path_buf(), destination_parent: destination_parent.to_path_buf(), source_parent_guard, destination_parent_guard, }) } #[cfg(not(windows))] { let _ = (source_parent, destination_parent, destination_base, publication_root); Ok(RenameCommitGuard {}) } } #[cfg(windows)] fn open_windows_publication_root(path: &Path) -> io::Result<(PathBuf, WindowsDirectoryHandle)> { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::{ FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, }; // Follow a configured endpoint mount/junction once, then pin the resolved // directory identity. The configured root is the trust boundary: allow // ordinary writes beneath it, but omit delete sharing so its directory entry // cannot be replaced while this disk is active. Publication resolves all // children relative to the retained identity. let file = std::fs::OpenOptions::new() .access_mode(FILE_TRAVERSE | FILE_READ_ATTRIBUTES) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) .open(path)?; let directory = windows_directory_handle(winapi_util::Handle::from_file(file))?; let resolved_path = windows_final_path(directory.handle.as_ref())?; Ok((resolved_path, directory)) } #[cfg(windows)] fn windows_final_path(handle: &winapi_util::Handle) -> io::Result { use windows_sys::Win32::Storage::FileSystem::{FILE_NAME_NORMALIZED, VOLUME_NAME_DOS, VOLUME_NAME_GUID, VOLUME_NAME_NT}; windows_final_path_with_fallbacks( windows_final_path_with_flags(handle, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS), || windows_final_path_with_flags(handle, FILE_NAME_NORMALIZED | VOLUME_NAME_GUID), || windows_final_path_with_flags(handle, FILE_NAME_NORMALIZED | VOLUME_NAME_NT).and_then(windows_nt_path_to_global_root), ) } #[cfg(windows)] fn windows_final_path_with_fallbacks( dos_path: io::Result, guid_path: impl FnOnce() -> io::Result, nt_path: impl FnOnce() -> io::Result, ) -> io::Result { use windows_sys::Win32::Foundation::ERROR_PATH_NOT_FOUND; match dos_path { Ok(path) => Ok(path), Err(err) if err .raw_os_error() .and_then(|code| u32::try_from(code).ok()) .is_some_and(|code| code == ERROR_PATH_NOT_FOUND) => { match guid_path() { Ok(path) => Ok(path), Err(err) if err .raw_os_error() .and_then(|code| u32::try_from(code).ok()) .is_some_and(|code| code == ERROR_PATH_NOT_FOUND) => { nt_path() } Err(err) => Err(err), } } Err(err) => Err(err), } } #[cfg(windows)] fn windows_nt_path_to_global_root(path: PathBuf) -> io::Result { use std::ffi::OsString; if !matches!(path.components().next(), Some(Component::RootDir)) { return Err(io::Error::new(io::ErrorKind::InvalidData, "Windows NT final path is not rooted")); } let mut global_root = OsString::from(r"\\?\GLOBALROOT"); global_root.push(path.as_os_str()); Ok(PathBuf::from(global_root)) } #[cfg(windows)] // SAFETY: the output buffer is owned and sized in UTF-16 code units, and the // borrowed root handle remains live for both synchronous queries. #[allow(unsafe_code)] fn windows_final_path_with_flags(handle: &winapi_util::Handle, flags: u32) -> io::Result { use std::{ffi::OsString, os::windows::ffi::OsStringExt, os::windows::io::AsRawHandle}; use windows_sys::Win32::Storage::FileSystem::GetFinalPathNameByHandleW; let required = unsafe { GetFinalPathNameByHandleW(handle.as_raw_handle(), std::ptr::null_mut(), 0, flags) }; if required == 0 { return Err(io::Error::last_os_error()); } let capacity = required .checked_add(1) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Windows final path length overflow"))?; let capacity_usize = usize::try_from(capacity) .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Windows final path length exceeds usize"))?; let mut buffer = vec![0u16; capacity_usize]; let length = unsafe { GetFinalPathNameByHandleW(handle.as_raw_handle(), buffer.as_mut_ptr(), capacity, flags) }; if length == 0 { return Err(io::Error::last_os_error()); } if length >= capacity { return Err(io::Error::new( io::ErrorKind::InvalidData, "Windows final path changed while it was queried", )); } buffer.truncate( usize::try_from(length) .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Windows final path length exceeds usize"))?, ); let path = PathBuf::from(OsString::from_wide(&buffer)); Ok(rustfs_utils::simplified(&path).to_path_buf()) } #[cfg(windows)] fn windows_directory_handle(handle: winapi_util::Handle) -> io::Result { use windows_sys::Win32::Storage::FileSystem::{FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT}; let info = windows_file_attribute_tag(&handle)?; if info.FileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 || info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { return Err(io::Error::from(io::ErrorKind::NotADirectory)); } Ok(WindowsDirectoryHandle { handle: Arc::new(handle), }) } #[cfg(windows)] // SAFETY: the output buffer has the exact FILE_ATTRIBUTE_TAG_INFO layout and // the borrowed handle remains live for the synchronous query. #[allow(unsafe_code)] fn windows_file_attribute_tag( handle: &winapi_util::Handle, ) -> io::Result { use std::{mem::size_of, os::windows::io::AsRawHandle}; use windows_sys::Win32::{ Foundation::{ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, ERROR_NOT_SUPPORTED}, Storage::FileSystem::{ FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, FileAttributeTagInfo, GetFileInformationByHandleEx, }, }; let mut info = FILE_ATTRIBUTE_TAG_INFO::default(); let info_size = u32::try_from(size_of::()) .map_err(|_| io::Error::other("Windows file attribute tag information size exceeds u32"))?; let queried = unsafe { GetFileInformationByHandleEx( handle.as_raw_handle(), FileAttributeTagInfo, std::ptr::addr_of_mut!(info).cast(), info_size, ) }; if queried != 0 { return Ok(info); } let err = io::Error::last_os_error(); let unsupported = err .raw_os_error() .and_then(|code| u32::try_from(code).ok()) .is_some_and(|code| matches!(code, ERROR_INVALID_FUNCTION | ERROR_INVALID_PARAMETER | ERROR_NOT_SUPPORTED)); if !unsupported { return Err(err); } // Some local Windows filesystems do not implement FileAttributeTagInfo. // The legacy handle query is enough for ordinary entries; fail closed for // reparse points because it cannot identify a safe tag. let legacy = winapi_util::file::information(handle)?; let attributes = u32::try_from(legacy.file_attributes()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Windows file attributes exceed u32"))?; if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { return Err(err); } Ok(FILE_ATTRIBUTE_TAG_INFO { FileAttributes: attributes, ReparseTag: 0, }) } #[cfg(windows)] fn lock_windows_directory_tree( path: &Path, shared_write_ancestor: Option<&Path>, publication_root: &PublicationRoot, ) -> io::Result { use windows_sys::Wdk::Storage::FileSystem::FILE_OPEN; use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE}; let relative = publication_root.relative_path(path)?; let shared_write_relative = shared_write_ancestor .map(|ancestor| publication_root.relative_path(ancestor)) .transpose()? .filter(|ancestor| relative.starts_with(*ancestor)); let mut handles = Vec::with_capacity(relative.components().count().saturating_add(1)); handles.push(publication_root.directory.clone()); let mut opened_relative = PathBuf::new(); for component in relative.components() { let Component::Normal(component) = component else { if matches!(component, Component::CurDir) { continue; } return Err(io::Error::new( io::ErrorKind::InvalidInput, "Windows guarded path contains an invalid component", )); }; let parent = handles .last() .ok_or_else(|| io::Error::other("Windows directory guard lost its root handle"))?; opened_relative.push(component); let child = if shared_write_relative.is_some_and(|ancestor| opened_relative.as_path() == ancestor) { // A handle-relative rename opens the target parent for write. When // that parent is also a source ancestor, this source-side handle // must share write access or the transaction blocks itself. Delete // sharing remains omitted, so the directory identity stays pinned. open_windows_relative_directory_component(parent, component, FILE_OPEN, FILE_SHARE_READ | FILE_SHARE_WRITE)? } else { open_windows_directory_component(parent, component, FILE_OPEN)? }; handles.push(child); } Ok(ExistingBaseDirectoryGuard::new(handles)) } #[cfg(windows)] // SAFETY: the object attributes borrow a checked UTF-16 component and live // parent handle for the duration of the synchronous NtCreateFile call. #[allow(unsafe_code)] fn open_windows_relative( parent: &winapi_util::Handle, component: &std::ffi::OsStr, desired_access: u32, share_access: u32, create_disposition: u32, create_options: u32, file_attributes: u32, dont_reparse: bool, ) -> io::Result { use std::{ mem::size_of, os::windows::{ffi::OsStrExt, io::AsRawHandle, io::FromRawHandle}, }; use windows_sys::{ Wdk::{Foundation::OBJECT_ATTRIBUTES, Storage::FileSystem::NtCreateFile}, Win32::{ Foundation::{HANDLE, OBJ_CASE_INSENSITIVE, OBJ_DONT_REPARSE, RtlNtStatusToDosError, UNICODE_STRING}, System::IO::IO_STATUS_BLOCK, }, }; let mut name = component.encode_wide().collect::>(); if name.is_empty() || name.contains(&0) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "relative Windows file name is empty or contains a NUL", )); } let name_bytes = name .len() .checked_mul(size_of::()) .and_then(|length| u16::try_from(length).ok()) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "relative Windows file name is too long"))?; let unicode_name = UNICODE_STRING { Length: name_bytes, MaximumLength: name_bytes, Buffer: name.as_mut_ptr(), }; let object_attributes_length = u32::try_from(size_of::()) .map_err(|_| io::Error::other("Windows object attributes size exceeds u32"))?; let object_attributes = OBJECT_ATTRIBUTES { Length: object_attributes_length, RootDirectory: parent.as_raw_handle(), ObjectName: &unicode_name, Attributes: OBJ_CASE_INSENSITIVE | if dont_reparse { OBJ_DONT_REPARSE } else { 0 }, SecurityDescriptor: std::ptr::null(), SecurityQualityOfService: std::ptr::null(), }; let mut handle: HANDLE = std::ptr::null_mut(); let mut io_status = IO_STATUS_BLOCK::default(); let status = unsafe { NtCreateFile( &mut handle, desired_access, &object_attributes, &mut io_status, std::ptr::null(), file_attributes, share_access, create_disposition, create_options, std::ptr::null(), 0, ) }; if status < 0 { return match i32::try_from(unsafe { RtlNtStatusToDosError(status) }) { Ok(code) => Err(io::Error::from_raw_os_error(code)), Err(_) => Err(io::Error::other(format!("Windows relative open failed with NTSTATUS {status:#x}"))), }; } if handle.is_null() { return Err(io::Error::other("Windows relative open returned an invalid handle")); } Ok(unsafe { winapi_util::Handle::from_raw_handle(handle) }) } #[cfg(windows)] fn create_windows_superseding_file(parent: &winapi_util::Handle, component: &std::ffi::OsStr) -> io::Result { use windows_sys::Wdk::Storage::FileSystem::FILE_SUPERSEDE; create_windows_owned_file(parent, component, FILE_SUPERSEDE) } #[cfg(windows)] struct WindowsStagedFile { writer: winapi_util::Handle, publication: winapi_util::Handle, } #[cfg(windows)] fn create_windows_staged_file(parent: &winapi_util::Handle, component: &std::ffi::OsStr) -> io::Result { use windows_sys::{ Wdk::Storage::FileSystem::{ FILE_CREATE, FILE_NON_DIRECTORY_FILE, FILE_OPEN, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, }, Win32::Storage::FileSystem::{ DELETE, FILE_ATTRIBUTE_NORMAL, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_DATA, SYNCHRONIZE, }, }; // Keep writing and namespace mutation on separate handles. Both handles // must share deletion because Windows requires every open source handle to // allow deletion before a rename. The random staging name and publication // handle retain the exact file identity while excluding other writers. let writer = open_windows_relative( parent, component, SYNCHRONIZE | FILE_READ_ATTRIBUTES | FILE_WRITE_DATA, FILE_SHARE_READ | FILE_SHARE_DELETE, FILE_CREATE, FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT, FILE_ATTRIBUTE_NORMAL, true, )?; validate_windows_owned_file(&writer)?; let expected_identity = windows_file_identity(&writer)?; let publication = open_windows_relative( parent, component, DELETE | SYNCHRONIZE | FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, FILE_OPEN, FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT, 0, false, )?; validate_windows_owned_file(&publication)?; if windows_file_identity(&publication)? != expected_identity { return Err(io::Error::new( io::ErrorKind::InvalidData, "staged Windows metadata identity changed while publication was prepared", )); } Ok(WindowsStagedFile { writer, publication }) } #[cfg(windows)] fn create_windows_owned_file( parent: &winapi_util::Handle, component: &std::ffi::OsStr, create_disposition: u32, ) -> io::Result { use windows_sys::{ Wdk::Storage::FileSystem::{FILE_NON_DIRECTORY_FILE, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT}, Win32::Storage::FileSystem::{ DELETE, FILE_ATTRIBUTE_NORMAL, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, FILE_WRITE_DATA, SYNCHRONIZE, }, }; // Open relative to the retained parent so the caller's disposition cannot // be redirected through a replaced path component or final reparse point. let file = open_windows_relative( parent, component, DELETE | SYNCHRONIZE | FILE_READ_ATTRIBUTES | FILE_WRITE_DATA, FILE_SHARE_READ, create_disposition, FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT, FILE_ATTRIBUTE_NORMAL, true, )?; validate_windows_owned_file(&file)?; Ok(file) } #[cfg(windows)] fn validate_windows_owned_file(file: &winapi_util::Handle) -> io::Result<()> { use windows_sys::Win32::Storage::FileSystem::{FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT}; let info = windows_file_attribute_tag(file)?; if info.FileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0 { return Err(io::Error::new( io::ErrorKind::PermissionDenied, "guarded Windows metadata entry is not an ordinary file", )); } if winapi_util::file::information(file)?.number_of_links() != 1 { return Err(io::Error::new( io::ErrorKind::PermissionDenied, "guarded Windows metadata entry retained an unexpected hard link", )); } Ok(()) } #[cfg(windows)] // SAFETY: the disposition buffer has the exact kernel layout and the borrowed // file handle remains live for the synchronous NtSetInformationFile call. #[allow(unsafe_code)] fn set_windows_file_delete_on_close(file: &winapi_util::Handle, delete_file: bool) -> io::Result<()> { use std::{mem::size_of, os::windows::io::AsRawHandle}; use windows_sys::{ Wdk::Storage::FileSystem::{FILE_DISPOSITION_INFORMATION, FileDispositionInformation, NtSetInformationFile}, Win32::{Foundation::RtlNtStatusToDosError, System::IO::IO_STATUS_BLOCK}, }; let mut disposition = FILE_DISPOSITION_INFORMATION { DeleteFile: delete_file }; let length = u32::try_from(size_of::()) .map_err(|_| io::Error::other("Windows file disposition size exceeds u32"))?; let mut io_status = IO_STATUS_BLOCK::default(); let status = unsafe { NtSetInformationFile( file.as_raw_handle(), &mut io_status, std::ptr::addr_of_mut!(disposition).cast(), length, FileDispositionInformation, ) }; if status >= 0 { return Ok(()); } let code = unsafe { RtlNtStatusToDosError(status) }; match i32::try_from(code) { Ok(code) => Err(io::Error::from_raw_os_error(code)), Err(_) => Err(io::Error::other(format!("Windows file disposition failed with NTSTATUS {status:#x}"))), } } #[cfg(windows)] fn read_windows_relative_file(file_path: &Path, parent_guard: &ExistingBaseDirectoryGuard) -> io::Result>> { use windows_sys::{ Wdk::Storage::FileSystem::{FILE_NON_DIRECTORY_FILE, FILE_OPEN, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT}, Win32::Storage::FileSystem::{ FILE_READ_ATTRIBUTES, FILE_READ_DATA, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, SYNCHRONIZE, }, }; let file_name = file_path .file_name() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "destination metadata must have a file name"))?; let identity_anchor = match open_windows_relative( parent_guard.last_handle()?, file_name, SYNCHRONIZE | FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, FILE_OPEN, FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT, 0, false, ) { Ok(file) => file, Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), Err(err) => return Err(err), }; let anchor_info = windows_file_attribute_tag(&identity_anchor)?; if !windows_rename_source_is_allowed(anchor_info.FileAttributes, anchor_info.ReparseTag) { return Err(io::Error::new(io::ErrorKind::PermissionDenied, WINDOWS_RENAME_SOURCE_REPARSE_ERROR)); } let expected_identity = windows_file_identity(&identity_anchor)?; // Data Dedup entries must be opened normally for reads. The reparse-point // anchor above validates the tag first, and the identity comparison below // rejects any final-entry substitution between the two opens. let mut file = open_windows_relative( parent_guard.last_handle()?, file_name, SYNCHRONIZE | FILE_READ_ATTRIBUTES | FILE_READ_DATA, FILE_SHARE_READ, FILE_OPEN, FILE_NON_DIRECTORY_FILE | FILE_SYNCHRONOUS_IO_NONALERT, 0, false, )?; if windows_file_identity(&file)? != expected_identity { return Err(io::Error::new( io::ErrorKind::InvalidData, "destination metadata identity changed while it was opened", )); } drop(identity_anchor); let file_size = winapi_util::file::information(&file)?.file_size(); let capacity = usize::try_from(file_size) .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "destination metadata size exceeds usize"))?; let mut data = Vec::new(); data.try_reserve_exact(capacity) .map_err(|err| io::Error::other(format!("failed to reserve destination metadata buffer: {err}")))?; std::io::Read::read_to_end(file.as_file_mut(), &mut data)?; Ok(Some(data)) } #[cfg(windows)] fn open_windows_directory_component( parent: &WindowsDirectoryHandle, component: &std::ffi::OsStr, create_disposition: u32, ) -> io::Result { use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; open_windows_relative_directory_component(parent, component, create_disposition, FILE_SHARE_READ) } #[cfg(windows)] fn open_windows_relative_directory_component( parent: &WindowsDirectoryHandle, component: &std::ffi::OsStr, create_disposition: u32, share_access: u32, ) -> io::Result { use windows_sys::{ Wdk::Storage::FileSystem::{FILE_DIRECTORY_FILE, FILE_OPEN_REPARSE_POINT}, Win32::Storage::FileSystem::{ FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_READ_ATTRIBUTES, FILE_TRAVERSE, }, }; let anchor = open_windows_relative( &parent.handle, component, FILE_TRAVERSE | FILE_READ_ATTRIBUTES, share_access, create_disposition, FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT, FILE_ATTRIBUTE_DIRECTORY, true, )?; let info = windows_file_attribute_tag(&anchor)?; if info.FileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 { return Err(io::Error::from(io::ErrorKind::NotADirectory)); } if info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == 0 { return Ok(WindowsDirectoryHandle { handle: Arc::new(anchor), }); } Err(io::Error::new( io::ErrorKind::PermissionDenied, "guarded Windows path contains a reparse point below its publication root", )) } #[cfg(windows)] const WINDOWS_RENAME_SOURCE_REPARSE_ERROR: &str = "rename source must be an ordinary file or a Windows data-dedup entry"; #[cfg(windows)] fn open_windows_rename_source( src_file_path: &Path, source_parent_guard: &ExistingBaseDirectoryGuard, ) -> io::Result { use windows_sys::{ Wdk::Storage::FileSystem::{FILE_OPEN, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT}, Win32::Storage::FileSystem::{DELETE, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, SYNCHRONIZE}, }; let src_name = src_file_path .file_name() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename source must have a file name"))?; // The parent tree is already pinned and reparse-free. Open the final entry // itself so an approved Data Dedup reparse point can be tag-validated below. let source = open_windows_relative( source_parent_guard.last_handle()?, src_name, DELETE | SYNCHRONIZE | FILE_READ_ATTRIBUTES, FILE_SHARE_READ, FILE_OPEN, FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT, 0, false, )?; let source_info = windows_file_attribute_tag(&source)?; if !windows_rename_source_is_allowed(source_info.FileAttributes, source_info.ReparseTag) { return Err(io::Error::new(io::ErrorKind::PermissionDenied, WINDOWS_RENAME_SOURCE_REPARSE_ERROR)); } Ok(source) } #[cfg(windows)] fn open_windows_rename_source_identity( src_file_path: &Path, source_parent_guard: &ExistingBaseDirectoryGuard, ) -> io::Result<(winapi_util::Handle, (u64, [u8; 16]))> { use windows_sys::{ Wdk::Storage::FileSystem::{FILE_OPEN, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT}, Win32::Storage::FileSystem::{FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, SYNCHRONIZE}, }; let src_name = src_file_path .file_name() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename source must have a file name"))?; // Match the rename handle: bypass final-entry reparse processing, then // admit only ordinary files or the Data Dedup tag below. let source = open_windows_relative( source_parent_guard.last_handle()?, src_name, SYNCHRONIZE | FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, FILE_OPEN, FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT, 0, false, )?; let source_info = windows_file_attribute_tag(&source)?; if !windows_rename_source_is_allowed(source_info.FileAttributes, source_info.ReparseTag) { return Err(io::Error::new(io::ErrorKind::PermissionDenied, WINDOWS_RENAME_SOURCE_REPARSE_ERROR)); } let identity = windows_file_identity(&source)?; Ok((source, identity)) } #[cfg(windows)] // SAFETY: FILE_ID_INFO is an initialized fixed-size output buffer and the // borrowed handle remains live for the synchronous query. #[allow(unsafe_code)] fn windows_file_identity(handle: &winapi_util::Handle) -> io::Result<(u64, [u8; 16])> { use std::{mem::size_of, os::windows::io::AsRawHandle}; use windows_sys::Win32::{ Foundation::{ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, ERROR_NOT_SUPPORTED}, Storage::FileSystem::{FILE_ID_INFO, FileIdInfo, GetFileInformationByHandleEx}, }; let mut identity = FILE_ID_INFO::default(); let identity_size = u32::try_from(size_of::()) .map_err(|_| io::Error::other("Windows file identity information size exceeds u32"))?; let queried = unsafe { GetFileInformationByHandleEx(handle.as_raw_handle(), FileIdInfo, std::ptr::addr_of_mut!(identity).cast(), identity_size) }; if queried != 0 { if windows_file_id_is_available(&identity.FileId.Identifier) { return Ok((identity.VolumeSerialNumber, identity.FileId.Identifier)); } return windows_legacy_file_identity(handle); } let err = io::Error::last_os_error(); let unsupported = err .raw_os_error() .and_then(|code| u32::try_from(code).ok()) .is_some_and(|code| matches!(code, ERROR_INVALID_FUNCTION | ERROR_INVALID_PARAMETER | ERROR_NOT_SUPPORTED)); if !unsupported { return Err(err); } windows_legacy_file_identity(handle) } #[cfg(windows)] fn windows_legacy_file_identity(handle: &winapi_util::Handle) -> io::Result<(u64, [u8; 16])> { use std::mem::size_of; // FileIdInfo is unavailable on a few older local filesystems. Keep the // source identity pinned by its live anchor handle and compare the legacy // volume/file index instead of silently disabling the check. let information = winapi_util::file::information(handle)?; let legacy_file_id = information.file_index().to_ne_bytes(); if !windows_file_id_is_available(&legacy_file_id) { return Err(io::Error::new( io::ErrorKind::Unsupported, "Windows filesystem did not provide a stable 64-bit file identity", )); } let mut file_id = [0; 16]; file_id[..size_of::()].copy_from_slice(&legacy_file_id); Ok((information.volume_serial_number(), file_id)) } #[cfg(windows)] fn windows_file_id_is_available(file_id: &[u8]) -> bool { file_id.iter().any(|byte| *byte != 0) && file_id.iter().any(|byte| *byte != u8::MAX) } #[cfg(windows)] fn windows_rename_source_is_allowed(attributes: u32, reparse_tag: u32) -> bool { use windows_sys::Win32::{Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT, System::SystemServices::IO_REPARSE_TAG_DEDUP}; attributes & FILE_ATTRIBUTE_REPARSE_POINT == 0 || reparse_tag == IO_REPARSE_TAG_DEDUP } pub(crate) fn mkdir_all_below_existing_base_std( dir_path: &Path, base_dir: &Path, publication_root: &PublicationRoot, ) -> io::Result { let relative = dir_path .strip_prefix(base_dir) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "rename destination must remain below its base directory"))?; for component in relative.components() { if !matches!(component, Component::Normal(_) | Component::CurDir) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "rename destination contains an invalid path component", )); } } #[cfg(unix)] { let _ = publication_root; use rustix::fs::{Mode, OFlags, mkdirat, open, openat}; use rustix::io::Errno; let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; let mode = Mode::RWXU | Mode::RWXG | Mode::RWXO; let mut parents = vec![open(base_dir, flags, Mode::empty()).map_err(io::Error::from)?]; for component in relative.components() { let Component::Normal(component) = component else { continue; }; let parent = parents .last() .expect("base directory guard should contain the base directory"); match mkdirat(parent, component, mode) { Ok(()) => {} Err(Errno::EXIST) => {} Err(err) => return Err(err.into()), } parents.push(openat(parent, component, flags, Mode::empty()).map_err(io::Error::from)?); } Ok(parents) } #[cfg(windows)] { use windows_sys::Wdk::Storage::FileSystem::{FILE_OPEN, FILE_OPEN_IF}; use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE}; let base_relative = publication_root.relative_path(base_dir)?; let capacity = base_relative .components() .count() .saturating_add(relative.components().count()) .saturating_add(1); let mut handles = Vec::with_capacity(capacity); handles.push(publication_root.directory.clone()); let mut guard = ExistingBaseDirectoryGuard::new(handles); for component in base_relative.components() { let Component::Normal(component) = component else { if matches!(component, Component::CurDir) { continue; } return Err(io::Error::new( io::ErrorKind::InvalidInput, "rename base directory contains an invalid path component", )); }; let parent = guard .handles .last() .ok_or_else(|| io::Error::other("Windows publication root guard is empty"))?; let child = open_windows_directory_component(parent, component, FILE_OPEN)?; guard.handles.push(child); } for component in relative.components() { let Component::Normal(component) = component else { continue; }; let parent = guard .handles .last() .ok_or_else(|| io::Error::other("Windows base directory guard is empty"))?; let child = open_windows_directory_component(parent, component, FILE_OPEN_IF)?; guard.handles.push(child); } // Windows resolves a handle-relative rename by opening the target for // write. Keep every ancestor strict, but let that internal open share // the final parent. Delete sharing remains omitted, so the retained // directory entry cannot be renamed or removed during publication. if guard.handles.len() > 1 { let component = dir_path .file_name() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename destination parent must have a name"))?; let parent_index = guard.handles.len() - 2; let parent = guard .handles .get(parent_index) .ok_or_else(|| io::Error::other("Windows destination guard lost its parent handle"))?; let rename_parent = open_windows_relative_directory_component(parent, component, FILE_OPEN, FILE_SHARE_READ | FILE_SHARE_WRITE)?; *guard .handles .last_mut() .ok_or_else(|| io::Error::other("Windows destination guard is empty"))? = rename_parent; } Ok(guard) } #[cfg(all(not(unix), not(windows)))] { let _ = (relative, publication_root); Err(io::Error::new( io::ErrorKind::Unsupported, "safe recursive directory creation is unavailable on this platform", )) } } fn warn_reliable_rename_failure(src_file_path: &Path, dst_file_path: &Path, base_dir: &Path, err: &io::Error) { warn!( "reliable_rename failed. src_file_path: {:?}, dst_file_path: {:?}, base_dir: {:?}, err: {:?}", src_file_path, dst_file_path, base_dir, err ); } /// Whether a failed `rename` in [`reliable_rename_inner`] should be retried. /// /// Only the first failure is retried, and `NotFound` is never retried: the /// stable parent guard cannot recreate a missing source or base directory, so /// a second attempt is guaranteed to fail identically. This spares speculative /// cleanup renames (e.g. `move_to_trash` on an already-removed tmp path) a /// pointless second syscall. This predicate is shared by the `rename_data` /// commit path via `rename_all`, so any relaxation here must keep genuine /// transient errors retryable. fn should_retry_rename(err: &io::Error, attempt: usize) -> bool { attempt == 0 && err.kind() != io::ErrorKind::NotFound } pub async fn reliable_mkdir_all(path: impl AsRef, base_dir: impl AsRef) -> io::Result<()> { let mut i = 0; let mut base_dir = base_dir.as_ref(); loop { if let Err(e) = os_mkdir_all(path.as_ref(), base_dir).await { if e.kind() == io::ErrorKind::NotFound && i == 0 { i += 1; if let Some(base_parent) = base_dir.parent() && let Some(c) = base_parent.components().next() && c != Component::RootDir { base_dir = base_parent } continue; } return Err(e); } break; } Ok(()) } /// Create a directory and all its parent components if they are missing. /// Without recursion support, fall back to create_dir_all /// This function will not create directories under base_dir. #[tracing::instrument(level = "debug", skip_all)] pub async fn os_mkdir_all(dir_path: impl AsRef, base_dir: impl AsRef) -> io::Result<()> { if !base_dir.as_ref().to_string_lossy().is_empty() && base_dir.as_ref().starts_with(dir_path.as_ref()) { return Ok(()); } if let Err(e) = super::fs::mkdir(dir_path.as_ref()).await { if e.kind() == io::ErrorKind::AlreadyExists { return Ok(()); } if e.kind() != io::ErrorKind::NotFound { return Err(e); } if let Some(parent) = dir_path.as_ref().parent() { // Fall back to creating the missing parent chain only when the direct mkdir proves it is required. if let Err(parent_err) = super::fs::make_dir_all(parent).await && parent_err.kind() != io::ErrorKind::AlreadyExists { return Err(parent_err); } } if let Err(retry_err) = super::fs::mkdir(dir_path.as_ref()).await && retry_err.kind() != io::ErrorKind::AlreadyExists { return Err(retry_err); } } Ok(()) } /// Check if a file exists. /// Returns true if the file exists, false otherwise. #[tracing::instrument(level = "debug", skip_all)] pub fn file_exists(path: impl AsRef) -> bool { std::fs::metadata(path.as_ref()).map(|_| true).unwrap_or(false) } /// Whether an [`io::Error`] means "the directory is not empty". /// /// POSIX lets `rmdir`/`rename` report a non-empty directory as either /// `ENOTEMPTY` or `EEXIST`. Linux uses `ENOTEMPTY` (which Rust surfaces as /// [`io::ErrorKind::DirectoryNotEmpty`]); illumos/Solaris return `EEXIST` /// (errno 17), which Rust surfaces as [`io::ErrorKind::AlreadyExists`] and /// which the `DirectoryNotEmpty` kind therefore never catches. Matching only on /// the kind silently misclassifies the Solaris case as a hard failure, so /// callers that must treat a still-populated directory as benign (deleting the /// object metadata while a rollback-staging dir remains, non-force /// `DeleteBucket` on a populated bucket) have to test the raw errno as well. /// Mirrors MinIO's `isSysErrNotEmpty`. pub fn is_dir_not_empty_error(err: &io::Error) -> bool { // Linux/Windows: ENOTEMPTY / ERROR_DIR_NOT_EMPTY -> DirectoryNotEmpty. if err.kind() == io::ErrorKind::DirectoryNotEmpty { return true; } // illumos/Solaris report a non-empty `rmdir`/`rename` as EEXIST (errno 17), // which Rust surfaces as `AlreadyExists` (so the `DirectoryNotEmpty` kind // never catches it). Confirm against the raw errno directly so the // classification holds regardless of how the platform std maps it. #[cfg(unix)] if matches!(err.raw_os_error(), Some(libc::ENOTEMPTY) | Some(libc::EEXIST)) { return true; } false } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; use std::time::Duration; use tempfile::tempdir; use tracing_subscriber::fmt::MakeWriter; fn file_sync_limiter() -> Arc { Arc::new(Semaphore::new(MAX_PARALLEL_FILE_SYNCS)) } fn test_publication_root(paths: &[&Path]) -> PublicationRoot { let mut common = paths .first() .expect("test publication root requires at least one path") .to_path_buf(); while !paths.iter().all(|path| path.starts_with(&common)) { assert!(common.pop(), "test paths must share an absolute root"); } PublicationRoot::new(&common).expect("test publication root should open") } async fn rename_all( src_file_path: impl AsRef, dst_file_path: impl AsRef, base_dir: impl AsRef, ) -> Result<()> { let src_file_path = src_file_path.as_ref(); let dst_file_path = dst_file_path.as_ref(); let base_dir = base_dir.as_ref(); let publication_root = test_publication_root(&[src_file_path, dst_file_path, base_dir]); super::rename_all(src_file_path, dst_file_path, base_dir, &publication_root).await } async fn rename_all_ignore_missing_source( src_file_path: impl AsRef, dst_file_path: impl AsRef, base_dir: impl AsRef, ) -> Result<()> { let src_file_path = src_file_path.as_ref(); let dst_file_path = dst_file_path.as_ref(); let base_dir = base_dir.as_ref(); let publication_root = test_publication_root(&[src_file_path, dst_file_path, base_dir]); super::rename_all_ignore_missing_source(src_file_path, dst_file_path, base_dir, &publication_root).await } fn mkdir_all_below_existing_base_std(dir_path: &Path, base_dir: &Path) -> io::Result { let publication_root = test_publication_root(&[dir_path, base_dir]); super::mkdir_all_below_existing_base_std(dir_path, base_dir, &publication_root) } #[tokio::test] async fn disk_volume_mutation_lock_is_shared_per_root_and_volume() { let temp_dir = tempdir().expect("create temp dir"); let first = disk_volume_mutation_lock(temp_dir.path(), "bucket"); let second = disk_volume_mutation_lock(temp_dir.path(), "bucket"); let other = disk_volume_mutation_lock(temp_dir.path(), "other-bucket"); assert!(Arc::ptr_eq(&first, &second), "reconnected disks must share a bucket mutation lock"); assert!(!Arc::ptr_eq(&first, &other), "different buckets must not serialize each other"); let _write_guard = first.write().await; assert!(second.try_read().is_err(), "a bucket delete lock must exclude local commits"); } #[derive(Clone, Default)] struct CapturedLogs { buffer: Arc>>, } struct CapturedLogWriter { buffer: Arc>>, } impl CapturedLogs { fn contents(&self) -> String { let buffer = self .buffer .lock() .expect("captured logs mutex should not be poisoned") .clone(); String::from_utf8(buffer).expect("captured logs should be valid UTF-8") } } impl std::io::Write for CapturedLogWriter { fn write(&mut self, buf: &[u8]) -> io::Result { self.buffer .lock() .expect("captured logs mutex should not be poisoned") .extend_from_slice(buf); Ok(buf.len()) } fn flush(&mut self) -> io::Result<()> { Ok(()) } } impl<'a> MakeWriter<'a> for CapturedLogs { type Writer = CapturedLogWriter; fn make_writer(&'a self) -> Self::Writer { CapturedLogWriter { buffer: Arc::clone(&self.buffer), } } } /// Holds a `warn_capture()` capture alive: the thread-local subscriber, plus /// the pin that keeps tracing's process-global callsite-interest cache from /// being decided by some other test's thread. struct WarnCaptureGuard { _subscriber: tracing::subscriber::DefaultGuard, _callsite_pin: tracing::Dispatch, } /// Capture WARN-level output on the current thread; tokio tests here run on /// the current-thread runtime, so the guard covers the whole test body. /// /// The callsite pin matters because `warn_reliable_rename_failure` is a /// single production callsite shared with tests that call `rename_all` /// *without* installing a subscriber — `rename_all_missing_source_returns_file_not_found` /// is one. Whichever thread reaches it first fixes its `Interest` /// process-wide, so without the pin that sibling can cache /// `Interest::never()` and the WARN never fires here at all, leaving the /// "must keep the WARN" assertions staring at empty output. See /// [`crate::test_tracing::pin_callsite_interest_for_test`]. fn warn_capture() -> (CapturedLogs, WarnCaptureGuard) { let logs = CapturedLogs::default(); let subscriber = tracing_subscriber::fmt() .with_max_level(tracing::Level::WARN) .with_writer(logs.clone()) .with_ansi(false) .without_time() .finish(); let guard = WarnCaptureGuard { _subscriber: tracing::subscriber::set_default(subscriber), _callsite_pin: crate::test_tracing::pin_callsite_interest_for_test(), }; (logs, guard) } #[cfg(windows)] fn try_set_windows_mount_point(directory: &winapi_util::Handle, target: &Path) -> io::Result<()> { use std::os::windows::ffi::OsStrExt; const VERBATIM_PREFIX: [u16; 4] = [b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]; const NT_PREFIX: [u16; 4] = [b'\\' as u16, b'?' as u16, b'?' as u16, b'\\' as u16]; let target = std::fs::canonicalize(target)?; let target_name = target.as_os_str().encode_wide().collect::>(); let target_without_prefix = target_name.strip_prefix(&VERBATIM_PREFIX).unwrap_or(&target_name); let substitute_name = NT_PREFIX .into_iter() .chain(target_without_prefix.iter().copied()) .collect::>(); try_set_windows_mount_point_names(directory, &substitute_name, &target_name) } #[cfg(windows)] // SAFETY: this test helper passes a valid live directory handle and a // fully initialized mount-point reparse buffer to synchronous DeviceIoControl. #[allow(unsafe_code)] fn try_set_windows_mount_point_names( directory: &winapi_util::Handle, substitute_name: &[u16], print_name: &[u16], ) -> io::Result<()> { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::System::{ IO::DeviceIoControl, Ioctl::FSCTL_SET_REPARSE_POINT, SystemServices::IO_REPARSE_TAG_MOUNT_POINT, }; const REPARSE_HEADER_SIZE: usize = 8; const MOUNT_POINT_HEADER_SIZE: usize = 8; let substitute_name_bytes = substitute_name .len() .checked_mul(std::mem::size_of::()) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "mount-point target is too long"))?; let print_name_bytes = print_name .len() .checked_mul(std::mem::size_of::()) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "mount-point target is too long"))?; let substitute_name_length = u16::try_from(substitute_name_bytes) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "mount-point target is too long"))?; let print_name_offset = u16::try_from(substitute_name_bytes + std::mem::size_of::()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "mount-point target is too long"))?; let print_name_length = u16::try_from(print_name_bytes) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "mount-point target is too long"))?; let mut path_buffer = Vec::with_capacity(substitute_name_bytes + print_name_bytes + 2 * std::mem::size_of::()); for unit in substitute_name .iter() .copied() .chain([0]) .chain(print_name.iter().copied()) .chain([0]) { path_buffer.extend_from_slice(&unit.to_le_bytes()); } let reparse_data_length = u16::try_from(MOUNT_POINT_HEADER_SIZE + path_buffer.len()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "mount-point reparse buffer is too large"))?; let mut buffer = Vec::with_capacity(REPARSE_HEADER_SIZE + usize::from(reparse_data_length)); buffer.extend_from_slice(&IO_REPARSE_TAG_MOUNT_POINT.to_le_bytes()); buffer.extend_from_slice(&reparse_data_length.to_le_bytes()); buffer.extend_from_slice(&0u16.to_le_bytes()); buffer.extend_from_slice(&0u16.to_le_bytes()); buffer.extend_from_slice(&substitute_name_length.to_le_bytes()); buffer.extend_from_slice(&print_name_offset.to_le_bytes()); buffer.extend_from_slice(&print_name_length.to_le_bytes()); buffer.extend_from_slice(&path_buffer); let input_size = u32::try_from(buffer.len()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "mount-point reparse buffer is too large"))?; let mut bytes_returned = 0; // SAFETY: `directory` and `buffer` remain live for the synchronous // call, and the buffer lengths above match REPARSE_DATA_BUFFER layout. let changed = unsafe { DeviceIoControl( directory.as_raw_handle(), FSCTL_SET_REPARSE_POINT, buffer.as_ptr().cast(), input_size, std::ptr::null_mut(), 0, &mut bytes_returned, std::ptr::null_mut(), ) }; if changed == 0 { Err(io::Error::last_os_error()) } else { Ok(()) } } #[cfg(windows)] // SAFETY: this test helper passes a valid live directory handle and the // documented mount-point delete header to synchronous DeviceIoControl. #[allow(unsafe_code)] fn try_delete_windows_mount_point(directory: &winapi_util::Handle) -> io::Result<()> { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::System::{ IO::DeviceIoControl, Ioctl::FSCTL_DELETE_REPARSE_POINT, SystemServices::IO_REPARSE_TAG_MOUNT_POINT, }; let mut buffer = [0u8; 8]; buffer[..4].copy_from_slice(&IO_REPARSE_TAG_MOUNT_POINT.to_le_bytes()); let mut bytes_returned = 0; // SAFETY: `directory` and `buffer` remain live for the synchronous // call, and the eight-byte input is the documented delete header. let changed = unsafe { DeviceIoControl( directory.as_raw_handle(), FSCTL_DELETE_REPARSE_POINT, buffer.as_ptr().cast(), u32::try_from(buffer.len()).expect("reparse delete header length fits in u32"), std::ptr::null_mut(), 0, &mut bytes_returned, std::ptr::null_mut(), ) }; if changed == 0 { Err(io::Error::last_os_error()) } else { Ok(()) } } #[cfg(unix)] #[test] fn read_dir_probe_bounds_unsupported_entries() { use std::os::unix::fs::symlink; let temp_dir = tempdir().expect("create temp dir"); for index in 0..=READ_DIR_PROBE_RAW_LIMIT { symlink("missing", temp_dir.path().join(format!("ignored-link-{index:04}"))).expect("create symlink"); } let probe = read_dir_probe(temp_dir.path(), 1).expect("probe directory"); assert!(probe.entries.is_empty()); assert!(!probe.complete, "a bounded probe must not claim that an oversized directory is complete"); } #[test] fn global_file_sync_limit_scales_and_preserves_blocking_capacity() { assert_eq!(default_global_file_sync_limit(1, 1024), MIN_GLOBAL_FILE_SYNCS); assert_eq!(default_global_file_sync_limit(16, 1024), 256); assert_eq!(default_global_file_sync_limit(64, 1024), MAX_GLOBAL_FILE_SYNCS); assert_eq!(default_global_file_sync_limit(64, 128), 64); assert_eq!(default_global_file_sync_limit(0, 0), 1); } #[tokio::test] async fn rename_all_missing_source_returns_file_not_found() { let temp_dir = tempdir().expect("create temp dir"); let src = temp_dir.path().join("missing"); let dst = temp_dir.path().join("dst"); let err = rename_all(&src, &dst, temp_dir.path()) .await .expect_err("missing source must fail"); assert!(matches!(err, DiskError::FileNotFound)); assert!(!dst.exists()); } #[tokio::test] async fn rename_all_ignore_missing_source_returns_ok_without_warn() { let temp_dir = tempdir().expect("create temp dir"); let src = temp_dir.path().join("missing"); let dst = temp_dir.path().join("dst"); let (logs, _guard) = warn_capture(); rename_all_ignore_missing_source(&src, &dst, temp_dir.path()) .await .expect("missing cleanup source must be ignored"); assert!(!dst.exists()); assert!(!logs.contents().contains("reliable_rename failed")); } #[tokio::test] async fn rename_all_ignore_missing_source_preserves_a_source_when_the_destination_base_is_missing() { let temp_dir = tempdir().expect("create temp dir"); let src = temp_dir.path().join("source"); let base = temp_dir.path().join("missing-base"); let dst = base.join("destination"); std::fs::write(&src, b"payload").expect("write source"); let err = rename_all_ignore_missing_source(&src, &dst, &base) .await .expect_err("a missing destination base must not masquerade as a missing source"); assert!(matches!(err, DiskError::FileNotFound)); assert_eq!(std::fs::read(&src).expect("source must remain readable"), b"payload"); assert!(!base.exists()); } #[tokio::test] async fn rename_all_missing_source_still_warns() { let temp_dir = tempdir().expect("create temp dir"); let src = temp_dir.path().join("missing"); let dst = temp_dir.path().join("dst"); let (logs, _guard) = warn_capture(); let err = rename_all(&src, &dst, temp_dir.path()) .await .expect_err("missing source must still fail"); assert!(matches!(err, DiskError::FileNotFound)); let captured = logs.contents(); assert!( captured.contains("reliable_rename failed"), "ordinary missing-source failures must keep the WARN, got: {captured}" ); } #[tokio::test] async fn rename_all_real_failure_still_warns() { // Renaming a file onto an existing directory fails on every platform // with a non-NotFound error; genuine failures must keep the WARN. let temp_dir = tempdir().expect("create temp dir"); let src = temp_dir.path().join("src"); std::fs::write(&src, b"payload").expect("write src"); let dst = temp_dir.path().join("dst-dir"); std::fs::create_dir(&dst).expect("create dst dir"); let (logs, _guard) = warn_capture(); rename_all(&src, &dst, temp_dir.path()) .await .expect_err("rename onto an existing directory must fail"); let captured = logs.contents(); assert!( captured.contains("reliable_rename failed"), "genuine rename failure must keep the WARN, got: {captured}" ); } #[test] fn rename_retry_never_retries_not_found() { // NotFound is terminal for the retry loop: the retry does not recreate // the missing source/base, so a second rename would fail identically. let not_found = io::Error::new(io::ErrorKind::NotFound, "missing"); assert!(!should_retry_rename(¬_found, 0)); assert!(!should_retry_rename(¬_found, 1)); } #[test] fn rename_retry_allows_single_retry_for_other_errors() { let denied = io::Error::new(io::ErrorKind::PermissionDenied, "denied"); assert!(should_retry_rename(&denied, 0)); assert!(!should_retry_rename(&denied, 1)); } #[test] fn is_dir_not_empty_error_recognizes_directory_not_empty_kind() { let err = io::Error::from(io::ErrorKind::DirectoryNotEmpty); assert!(is_dir_not_empty_error(&err)); } #[cfg(unix)] #[test] fn is_dir_not_empty_error_recognizes_raw_enotempty() { // Linux/BSD/macOS non-empty rmdir/rename errno. let err = io::Error::from_raw_os_error(libc::ENOTEMPTY); assert!(is_dir_not_empty_error(&err)); } #[cfg(unix)] #[test] fn is_dir_not_empty_error_recognizes_solaris_eexist() { // illumos/Solaris report a non-empty rmdir/rename as EEXIST, which Rust // surfaces as `AlreadyExists` (never `DirectoryNotEmpty`). This is the // core of rustfs/rustfs#4978: matching only the kind misclassified this // benign condition as a hard failure. let err = io::Error::from_raw_os_error(libc::EEXIST); assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); assert!(is_dir_not_empty_error(&err)); } #[test] fn is_dir_not_empty_error_rejects_unrelated_errors() { assert!(!is_dir_not_empty_error(&io::Error::from(io::ErrorKind::NotFound))); assert!(!is_dir_not_empty_error(&io::Error::from(io::ErrorKind::PermissionDenied))); #[cfg(unix)] { assert!(!is_dir_not_empty_error(&io::Error::from_raw_os_error(libc::EACCES))); assert!(!is_dir_not_empty_error(&io::Error::from_raw_os_error(libc::ENOENT))); } } #[tokio::test] async fn is_dir_not_empty_error_matches_real_non_empty_rmdir() { // Validate against the host's actual errno, whatever it is: Linux/macOS // return ENOTEMPTY, illumos/Solaris return EEXIST. The removal must be // classified as "not empty" on every platform. let temp_dir = tempdir().expect("create temp dir"); let populated = temp_dir.path().join("populated"); std::fs::create_dir(&populated).expect("create dir"); std::fs::write(populated.join("child"), b"x").expect("write child"); let err = std::fs::remove_dir(&populated).expect_err("non-empty rmdir must fail"); assert!( is_dir_not_empty_error(&err), "non-empty rmdir must classify as not-empty, got kind {:?} errno {:?}", err.kind(), err.raw_os_error() ); } #[tokio::test] async fn rename_all_moves_existing_directory_tree() { // Guards the rename_data commit path, which funnels through // reliable_rename_inner via rename_all. let temp_dir = tempdir().expect("create temp dir"); let src = temp_dir.path().join("src-dir"); std::fs::create_dir_all(src.join("nested")).expect("create src tree"); std::fs::write(src.join("nested").join("part.1"), b"payload").expect("write part"); let dst = temp_dir.path().join("dst-parent").join("dst-dir"); rename_all(&src, &dst, temp_dir.path()).await.expect("rename must succeed"); assert!(!src.exists()); assert_eq!(std::fs::read(dst.join("nested").join("part.1")).expect("read moved part"), b"payload"); } #[tokio::test] async fn rename_all_does_not_recreate_missing_base_directory() { let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); std::fs::create_dir(&base).expect("create destination base"); let src = temp_dir.path().join("staged-object"); std::fs::write(&src, b"payload").expect("write staged object"); let dst = base.join("object").join("xl.meta"); std::fs::remove_dir(&base).expect("delete destination base before commit"); let err = rename_all(&src, &dst, &base) .await .expect_err("rename must not recreate a deleted destination base"); assert!(matches!(err, DiskError::FileNotFound)); assert!(src.exists(), "failed commit must preserve the staged source"); assert!(!base.exists(), "failed commit must not recreate the deleted bucket"); } #[cfg(unix)] #[tokio::test] async fn rename_all_rejects_a_replaced_base_with_an_existing_parent() { use std::os::unix::fs::symlink; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let outside = temp_dir.path().join("outside"); std::fs::create_dir(&base).expect("create destination base"); std::fs::create_dir_all(outside.join("object")).expect("create outside destination parent"); let src = temp_dir.path().join("staged-object"); std::fs::write(&src, b"payload").expect("write staged object"); let dst = base.join("object").join("xl.meta"); std::fs::remove_dir(&base).expect("remove destination base before replacement"); symlink(&outside, &base).expect("replace destination base with a symlink"); rename_all(&src, &dst, &base) .await .expect_err("rename must reject an existing destination parent below a replaced base"); assert!(src.exists(), "rejected rename must preserve the staged source"); assert!( !outside.join("object/xl.meta").exists(), "rename must not publish through the replacement symlink" ); } #[cfg(windows)] #[test] fn windows_parent_guard_blocks_parent_replacement() { let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); std::fs::create_dir(&base).expect("create destination base"); let parent = base.join("object").join("nested"); let guard = mkdir_all_below_existing_base_std(&parent, &base).expect("create and lock destination parents"); std::fs::read_dir(&parent).expect("the locked parent must remain readable"); std::fs::rename(&base, temp_dir.path().join("replacement-base")) .expect_err("the locked base must not be replaceable before commit"); std::fs::rename(base.join("object"), base.join("replacement-object")) .expect_err("a locked intermediate directory must not be replaceable before commit"); std::fs::rename(&parent, base.join("replacement-parent")) .expect_err("the locked destination parent must not be replaceable before commit"); assert!(parent.is_dir(), "failed replacement must leave the guarded parent in place"); drop(guard); std::fs::rename(base.join("object"), base.join("replacement-object")) .expect("replacement should succeed after the commit guard is released"); } #[cfg(windows)] #[test] fn windows_publication_root_keeps_normal_root_writes_available() { let temp_dir = tempdir().expect("create temp dir"); let publication_root = PublicationRoot::new(temp_dir.path()).expect("open publication root"); let bucket = temp_dir.path().join("bucket-created-after-root-open"); std::fs::create_dir(&bucket).expect("root handle must not block normal bucket creation"); std::fs::write(bucket.join("marker"), b"payload").expect("root handle must not block normal writes"); drop(publication_root); assert_eq!(std::fs::read(bucket.join("marker")).expect("read marker"), b"payload"); } #[cfg(windows)] #[test] fn windows_rename_all_publication_root_excludes_delete_sharing() { let temp_dir = tempdir().expect("create temp dir"); let root = temp_dir.path().join("publication-root"); let replacement = temp_dir.path().join("replacement-root"); std::fs::create_dir(&root).expect("create publication root"); let publication_root = PublicationRoot::new(&root).expect("open publication root"); std::fs::rename(&root, &replacement).expect_err("the live publication root must not be replaceable"); assert!(root.is_dir(), "failed replacement must retain the configured root"); drop(publication_root); std::fs::rename(&root, &replacement).expect("replacement should succeed after the root handle is released"); } #[cfg(windows)] #[test] fn windows_final_path_falls_back_from_dos_to_guid_and_nt_paths() { use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_PATH_NOT_FOUND}; let path_not_found = || io::Error::from_raw_os_error(i32::try_from(ERROR_PATH_NOT_FOUND).expect("Windows error code should fit i32")); let guid_path = PathBuf::from(r"\\?\Volume{11111111-2222-3333-4444-555555555555}\data"); let resolved = windows_final_path_with_fallbacks( Err(path_not_found()), || Ok(guid_path.clone()), || panic!("a successful GUID lookup must not query the NT path"), ) .expect("a volume without a DOS name should use its GUID path"); assert_eq!(resolved, guid_path); let nt_path = PathBuf::from(r"\\?\GLOBALROOT\Device\HarddiskVolume42\data"); let resolved = windows_final_path_with_fallbacks(Err(path_not_found()), || Err(path_not_found()), || Ok(nt_path.clone())) .expect("a volume without Mount Manager names should use its NT path"); assert_eq!(resolved, nt_path); let access_denied = i32::try_from(ERROR_ACCESS_DENIED).expect("Windows error code should fit i32"); let err = windows_final_path_with_fallbacks( Err(io::Error::from_raw_os_error(access_denied)), || panic!("non-path errors must not be hidden by a GUID retry"), || panic!("non-path errors must not be hidden by an NT retry"), ) .expect_err("a non-path error should be preserved"); assert_eq!(err.raw_os_error(), Some(access_denied)); } #[cfg(windows)] #[test] fn windows_rename_all_queries_a_real_volume_guid_path() { use windows_sys::Win32::Storage::FileSystem::{FILE_NAME_NORMALIZED, VOLUME_NAME_GUID, VOLUME_NAME_NT}; let temp_dir = tempdir().expect("create temp dir"); let root = temp_dir.path().join("publication-root"); std::fs::create_dir(&root).expect("create publication root"); let publication_root = PublicationRoot::new(&root).expect("open publication root"); let guid_path = windows_final_path_with_flags(publication_root.directory.handle.as_ref(), FILE_NAME_NORMALIZED | VOLUME_NAME_GUID) .expect("query the root through its real volume GUID path"); assert!(guid_path.is_absolute(), "the volume GUID result must be absolute"); assert!(std::fs::metadata(guid_path).expect("stat the volume GUID path").is_dir()); let nt_path = windows_final_path_with_flags(publication_root.directory.handle.as_ref(), FILE_NAME_NORMALIZED | VOLUME_NAME_NT) .and_then(windows_nt_path_to_global_root) .expect("query the root through its real NT path"); assert!(nt_path.is_absolute(), "the GLOBALROOT result must be absolute"); assert!(std::fs::metadata(nt_path).expect("stat the GLOBALROOT NT path").is_dir()); } #[cfg(windows)] #[tokio::test] async fn windows_publication_root_follows_a_configured_junction_once() { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::{ Foundation::GENERIC_WRITE, Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE}, }; let temp_dir = tempdir().expect("create temp dir"); let target = temp_dir.path().join("target"); let mount = temp_dir.path().join("configured-root"); std::fs::create_dir(&target).expect("create configured target"); std::fs::create_dir(&mount).expect("create configured mount point"); let mount_writer = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&mount) .map(winapi_util::Handle::from_file) .expect("open configured mount point"); try_set_windows_mount_point(&mount_writer, &target).expect("configure root junction"); drop(mount_writer); let publication_root = PublicationRoot::new(&mount).expect("configured root junction should be followed once"); let resolved_root = publication_root.path().to_path_buf(); assert_eq!( resolved_root, rustfs_utils::canonicalize(&target).expect("canonicalize configured target") ); let configured_base = mount.join("configured-bucket"); let configured_src = mount.join("configured-staging"); let configured_dst = configured_base.join("object"); std::fs::create_dir(&configured_base).expect("create bucket through configured root"); std::fs::write(&configured_src, b"configured").expect("write staged object through configured root"); super::rename_all(&configured_src, &configured_dst, &configured_base, &publication_root) .await .expect("publish a configured path relative to the pinned root"); assert_eq!( std::fs::read(target.join("configured-bucket/object")).expect("read configured-path publication"), b"configured" ); let resolved_base = resolved_root.join("resolved-bucket"); let resolved_src = resolved_root.join("resolved-staging"); let resolved_dst = resolved_base.join("object"); std::fs::create_dir(&resolved_base).expect("create bucket through resolved root"); std::fs::write(&resolved_src, b"resolved").expect("write staged object through resolved root"); super::rename_all(&resolved_src, &resolved_dst, &resolved_base, &publication_root) .await .expect("publish a resolved path relative to the pinned root"); assert_eq!( std::fs::read(target.join("resolved-bucket/object")).expect("read resolved-path publication"), b"resolved" ); drop(publication_root); let mount_writer = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&mount) .map(winapi_util::Handle::from_file) .expect("reopen configured mount point for cleanup"); try_delete_windows_mount_point(&mount_writer).expect("remove configured root junction"); } #[cfg(windows)] #[test] fn windows_rename_source_reparse_policy_only_allows_data_dedup() { use windows_sys::Win32::{ Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT, System::SystemServices::{IO_REPARSE_TAG_DEDUP, IO_REPARSE_TAG_MOUNT_POINT, IO_REPARSE_TAG_SYMLINK}, }; assert!(windows_rename_source_is_allowed(0, 0)); assert!(windows_rename_source_is_allowed(FILE_ATTRIBUTE_REPARSE_POINT, IO_REPARSE_TAG_DEDUP)); assert!(!windows_rename_source_is_allowed( FILE_ATTRIBUTE_REPARSE_POINT, IO_REPARSE_TAG_MOUNT_POINT )); assert!(!windows_rename_source_is_allowed(FILE_ATTRIBUTE_REPARSE_POINT, IO_REPARSE_TAG_SYMLINK)); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_publishes_a_direct_child_with_a_short_name() { let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); std::fs::create_dir(&base).expect("create destination base"); let src = temp_dir.path().join("staged-object"); let dst = base.join("x"); std::fs::write(&src, b"payload").expect("write staged object"); rename_all(&src, &dst, &base).await.expect("direct-child rename must succeed"); assert!(!src.exists()); assert_eq!(std::fs::read(&dst).expect("read published object"), b"payload"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_commit_guard_reuses_source_and_destination_trees() { let temp_dir = tempdir().expect("create temp dir"); let source_parent = temp_dir.path().join("staging/object"); let destination_base = temp_dir.path().join("bucket"); let destination_parent = destination_base.join("object"); std::fs::create_dir_all(&source_parent).expect("create staging parent"); std::fs::create_dir(&destination_base).expect("create destination base"); let publication_root = PublicationRoot::new(temp_dir.path()).expect("open publication root"); let commit_guard = prepare_rename_commit_guard(&source_parent, &destination_parent, &destination_base, &publication_root) .expect("prepare shared commit guard"); let mutation_lease = acquire_namespace_mutation_lease(&destination_parent).await; let first_src = source_parent.join("part.1"); let second_src = source_parent.join("xl.meta"); let first_dst = destination_parent.join("part.1"); let second_dst = destination_parent.join("xl.meta"); std::fs::write(&first_src, b"part").expect("write staged part"); std::fs::write(&second_src, b"meta").expect("write staged metadata"); windows_rename_test_hooks::observe_guard_generations(&first_dst); windows_rename_test_hooks::observe_guard_generations(&second_dst); super::rename_all_with_commit_guard( &first_src, &first_dst, &destination_base, &publication_root, &commit_guard, mutation_lease.clone(), ) .await .expect("publish first entry with shared guards"); super::rename_all_with_commit_guard( &second_src, &second_dst, &destination_base, &publication_root, &commit_guard, mutation_lease, ) .await .expect("publish second entry with shared guards"); let first_generation = windows_rename_test_hooks::take_guard_generations(&first_dst); let second_generation = windows_rename_test_hooks::take_guard_generations(&second_dst); assert_eq!(first_generation.len(), 1); assert_eq!(second_generation, first_generation); std::fs::rename(&source_parent, temp_dir.path().join("replacement-staging")) .expect_err("the retained source parent must not be replaceable"); std::fs::rename(&destination_parent, destination_base.join("replacement-object")) .expect_err("the retained destination parent must not be replaceable"); drop(commit_guard); std::fs::rename(&source_parent, temp_dir.path().join("replacement-staging")) .expect("source replacement should succeed after guard release"); std::fs::rename(&destination_parent, destination_base.join("replacement-object")) .expect("destination replacement should succeed after guard release"); } #[cfg(windows)] #[test] fn windows_rename_commit_guard_publishes_data_directory_with_prepared_metadata() { let temp_dir = tempdir().expect("create temp dir"); let configured_root = temp_dir.path(); let publication_root = PublicationRoot::new(configured_root).expect("open publication root"); let root = publication_root.path(); let source_parent = root.join(".rustfs.sys/tmp/staged-object"); let destination_base = root.join("bucket"); let destination_parent = destination_base.join("object"); let source_data = source_parent.join("data-dir"); let destination_data = destination_parent.join("data-dir"); std::fs::create_dir_all(&source_data).expect("create staged data directory"); std::fs::write(source_data.join("part.1"), b"payload").expect("write staged part"); std::fs::create_dir(&destination_base).expect("create destination bucket"); let commit_guard = prepare_rename_commit_guard(&source_parent, &destination_parent, &destination_base, &publication_root) .expect("prepare shared commit guard"); let source_metadata = source_parent.join("xl.meta"); let destination_metadata = destination_parent.join("xl.meta"); let mut prepared_metadata = create_prepared_rename_source_with_commit_guard(&source_metadata, &destination_metadata, &commit_guard) .expect("prepare staged metadata"); prepared_metadata .write_all(b"metadata", false) .expect("write staged metadata"); rename_with_commit_guard_std(&source_data, &destination_data, &commit_guard) .expect("publish staged data directory while metadata source remains open"); rename_prepared_source_with_commit_guard_std(&prepared_metadata, &source_metadata, &destination_metadata, &commit_guard) .expect("publish prepared metadata"); assert_eq!(std::fs::read(destination_data.join("part.1")).expect("read published part"), b"payload"); assert_eq!(std::fs::read(destination_metadata).expect("read published metadata"), b"metadata"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_commit_guard_shares_a_same_parent_handle() { let temp_dir = tempdir().expect("create temp dir"); let destination_base = temp_dir.path().join("bucket"); let parent = destination_base.join("object"); let src = parent.join("staged-xl.meta"); let dst = parent.join("xl.meta"); std::fs::create_dir_all(&parent).expect("create shared parent"); std::fs::write(&src, b"metadata").expect("write staged metadata"); let publication_root = PublicationRoot::new(temp_dir.path()).expect("open publication root"); let commit_guard = prepare_rename_commit_guard(&parent, &parent, &destination_base, &publication_root) .expect("prepare same-parent commit guard"); let mutation_lease = acquire_namespace_mutation_lease(&parent).await; assert_eq!( commit_guard.source_parent_guard.generation, commit_guard.destination_parent_guard.generation, "same-parent publication must reuse one guarded directory identity" ); super::rename_all_with_commit_guard(&src, &dst, &destination_base, &publication_root, &commit_guard, mutation_lease) .await .expect("same-parent commit rename must not conflict with its own guard"); assert!(!src.exists()); assert_eq!(std::fs::read(dst).expect("read committed metadata"), b"metadata"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_supports_same_parent_publication() { let temp_dir = tempdir().expect("create temp dir"); let parent = temp_dir.path().join("metadata"); let src = parent.join("temporary-format.json"); let dst = parent.join("format.json"); std::fs::create_dir(&parent).expect("create metadata directory"); std::fs::write(&src, b"format").expect("write temporary format"); rename_all(&src, &dst, &parent) .await .expect("same-parent reliable rename must not conflict with its source guard"); assert!(!src.exists()); assert_eq!(std::fs::read(dst).expect("read published format"), b"format"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_supports_child_to_parent_rollback_publication() { let temp_dir = tempdir().expect("create temp dir"); let object_dir = temp_dir.path().join("bucket/object"); let rollback_dir = object_dir.join("rollback-id"); let src = rollback_dir.join("xl.meta.backup"); let dst = object_dir.join("xl.meta"); std::fs::create_dir_all(&rollback_dir).expect("create rollback directory"); std::fs::write(&src, b"old-metadata").expect("write rollback metadata"); std::fs::write(&dst, b"uncommitted-metadata").expect("write metadata to replace"); let publication_root = PublicationRoot::new(temp_dir.path()).expect("open publication root above the object tree"); super::rename_all(&src, &dst, &object_dir, &publication_root) .await .expect("a rollback source below its destination parent must not conflict with its own guards"); assert!(!src.exists()); assert_eq!(std::fs::read(dst).expect("read restored metadata"), b"old-metadata"); } #[cfg(windows)] #[test] fn windows_rename_all_rejects_unavailable_modern_file_ids() { assert!(!super::windows_file_id_is_available(&[0; 16])); assert!(!super::windows_file_id_is_available(&[u8::MAX; 16])); assert!(!super::windows_file_id_is_available(&[0; 8])); assert!(!super::windows_file_id_is_available(&[u8::MAX; 8])); let mut available = [0; 16]; available[0] = 1; assert!(super::windows_file_id_is_available(&available)); } #[cfg(windows)] #[test] fn windows_rename_all_retries_without_unsupported_readonly_flag() { use windows_sys::{ Wdk::Storage::FileSystem::{ FILE_RENAME_IGNORE_READONLY_ATTRIBUTE, FILE_RENAME_POSIX_SEMANTICS, FILE_RENAME_REPLACE_IF_EXISTS, }, Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_DISK_FULL, ERROR_INVALID_PARAMETER}, }; let invalid_parameter = i32::try_from(ERROR_INVALID_PARAMETER).expect("Windows error code should fit i32"); let access_denied = i32::try_from(ERROR_ACCESS_DENIED).expect("Windows error code should fit i32"); let disk_full = i32::try_from(ERROR_DISK_FULL).expect("Windows error code should fit i32"); let mut attempts = Vec::new(); windows_extended_rename_with_compatibility_fallback(io::Error::from_raw_os_error(access_denied), |flags| { attempts.push(flags); if attempts.len() == 1 { Err(io::Error::from_raw_os_error(invalid_parameter)) } else { Ok(()) } }) .expect("unsupported optional flags should use the compatible fallback"); assert_eq!( attempts, vec![ FILE_RENAME_REPLACE_IF_EXISTS | FILE_RENAME_POSIX_SEMANTICS | FILE_RENAME_IGNORE_READONLY_ATTRIBUTE, FILE_RENAME_REPLACE_IF_EXISTS | FILE_RENAME_POSIX_SEMANTICS, ] ); let mut attempts = 0; let err = windows_extended_rename_with_compatibility_fallback(io::Error::from_raw_os_error(invalid_parameter), |_| { attempts += 1; Err(io::Error::from_raw_os_error(access_denied)) }) .expect_err("ordinary rename failures must not be retried with weaker flags"); assert_eq!(attempts, 1); assert_eq!(err.raw_os_error(), Some(access_denied)); let mut attempts = 0; let err = windows_extended_rename_with_compatibility_fallback(io::Error::from_raw_os_error(access_denied), |_| { attempts += 1; Err(io::Error::from_raw_os_error(invalid_parameter)) }) .expect_err("an unsupported extended rename must preserve the legacy error"); assert_eq!(attempts, 2); assert_eq!(err.raw_os_error(), Some(access_denied)); let mut attempts = 0; let err = windows_extended_rename_with_compatibility_fallback(io::Error::from_raw_os_error(access_denied), |_| { attempts += 1; if attempts == 1 { Err(io::Error::from_raw_os_error(invalid_parameter)) } else { Err(io::Error::from_raw_os_error(disk_full)) } }) .expect_err("a supported extended rename failure must replace the stale legacy error"); assert_eq!(attempts, 2); assert_eq!(err.raw_os_error(), Some(disk_full)); } #[cfg(windows)] #[test] fn windows_rename_all_legacy_file_identity_distinguishes_sources() { let temp_dir = tempdir().expect("create temp dir"); let first_path = temp_dir.path().join("first"); let second_path = temp_dir.path().join("second"); std::fs::write(&first_path, b"first").expect("write first source"); std::fs::write(&second_path, b"second").expect("write second source"); let first = winapi_util::Handle::from_file(std::fs::File::open(first_path).expect("open first source")); let second = winapi_util::Handle::from_file(std::fs::File::open(second_path).expect("open second source")); let first_identity = windows_legacy_file_identity(&first).expect("query first legacy identity"); assert_eq!( windows_legacy_file_identity(&first).expect("repeat first legacy identity"), first_identity ); assert_ne!( windows_legacy_file_identity(&second).expect("query second legacy identity"), first_identity ); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_replaces_a_read_only_destination() { let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); std::fs::create_dir(&base).expect("create destination base"); let src = temp_dir.path().join("staged-object"); let dst = base.join("xl.meta"); std::fs::write(&src, b"new").expect("write staged object"); std::fs::write(&dst, b"old").expect("write old destination"); let mut permissions = std::fs::metadata(&dst).expect("inspect old destination").permissions(); permissions.set_readonly(true); std::fs::set_permissions(&dst, permissions).expect("make old destination read-only"); let result = rename_all(&src, &dst, &base).await; if result.is_err() && dst.exists() { let mut permissions = std::fs::metadata(&dst).expect("inspect failed destination").permissions(); permissions.set_readonly(false); std::fs::set_permissions(&dst, permissions).expect("restore failed destination permissions"); } result.expect("read-only destination replacement must match std::fs::rename"); assert_eq!(std::fs::read(&dst).expect("read replacement"), b"new"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_replaces_an_open_destination() { use std::io::Read; use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE}; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); std::fs::create_dir(&base).expect("create destination base"); let src = temp_dir.path().join("staged-object"); let dst = base.join("xl.meta"); std::fs::write(&src, b"new").expect("write staged object"); std::fs::write(&dst, b"old").expect("write old destination"); let mut open_destination = std::fs::OpenOptions::new() .read(true) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) .open(&dst) .expect("open destination with delete sharing"); rename_all(&src, &dst, &base) .await .expect("an open destination that shares delete must remain replaceable"); let mut old_contents = Vec::new(); open_destination .read_to_end(&mut old_contents) .expect("read replaced file through its retained handle"); assert_eq!(old_contents, b"old"); assert_eq!(std::fs::read(&dst).expect("read replacement by path"), b"new"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_fails_closed_during_parent_reparse_mutation() { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::{ Foundation::GENERIC_WRITE, Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE}, }; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let parent = base.join("object"); let outside = temp_dir.path().join("outside"); std::fs::create_dir_all(&parent).expect("create destination parent"); std::fs::create_dir(&outside).expect("create outside target"); let src = temp_dir.path().join("staged-object"); let dst = parent.join("xl.meta"); std::fs::write(&src, b"payload").expect("write staged object"); let parent_for_hook = parent.clone(); let outside_for_hook = outside.clone(); let reparse_writer = Arc::new(std::sync::Mutex::new(None)); let reparse_writer_for_hook = Arc::clone(&reparse_writer); windows_rename_test_hooks::install_before_publication(&dst, move || { let writable_parent = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&parent_for_hook) .map(winapi_util::Handle::from_file) .expect("open the retained parent for a concurrent reparse mutation"); try_set_windows_mount_point(&writable_parent, &outside_for_hook) .expect("redirect the destination parent after it is pinned"); *reparse_writer_for_hook.lock().expect("reparse writer mutex poisoned") = Some(writable_parent); }); rename_all(&src, &dst, &base) .await .expect_err("publication must fail closed when the retained parent becomes a reparse point"); assert!(src.exists(), "failed publication must retain its staged source"); assert!(!outside.join("xl.meta").exists(), "reparse mutation must not redirect publication"); let writable_parent = reparse_writer .lock() .expect("reparse writer mutex poisoned") .take() .expect("publication hook must retain the parent handle"); try_delete_windows_mount_point(&writable_parent).expect("remove destination parent mount point"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_rejects_a_preexisting_reparse_base() { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::{ Foundation::GENERIC_WRITE, Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE}, }; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let outside = temp_dir.path().join("outside"); let src = temp_dir.path().join("staged-object"); let dst = base.join("object").join("xl.meta"); std::fs::create_dir(&base).expect("create destination base"); std::fs::create_dir(&outside).expect("create outside target"); std::fs::write(&src, b"payload").expect("write staged object"); let writable_base = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&base) .map(winapi_util::Handle::from_file) .expect("open destination base for reparse mutation"); try_set_windows_mount_point(&writable_base, &outside).expect("redirect the destination base"); drop(writable_base); let err = rename_all(&src, &dst, &base) .await .expect_err("a reparse destination base must be rejected"); assert!(matches!(err, DiskError::FileAccessDenied)); assert!(src.exists(), "rejected publication must preserve the staged source"); assert!(!outside.join("object").exists(), "reparse base must not redirect parent creation"); let writable_base = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&base) .map(winapi_util::Handle::from_file) .expect("reopen destination base to remove its mount point"); try_delete_windows_mount_point(&writable_base).expect("remove destination base mount point"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_rejects_a_preexisting_reparse_intermediate() { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::{ Foundation::GENERIC_WRITE, Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE}, }; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let linked = base.join("linked"); let outside = temp_dir.path().join("outside"); let src = temp_dir.path().join("staged-object"); let dst = linked.join("object").join("xl.meta"); std::fs::create_dir(&base).expect("create destination base"); std::fs::create_dir(&linked).expect("create intermediate directory"); std::fs::create_dir(&outside).expect("create outside target"); std::fs::write(&src, b"payload").expect("write staged object"); let writable_intermediate = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&linked) .map(winapi_util::Handle::from_file) .expect("open intermediate directory for reparse mutation"); try_set_windows_mount_point(&writable_intermediate, &outside).expect("redirect the intermediate directory"); drop(writable_intermediate); let err = rename_all(&src, &dst, &base) .await .expect_err("a reparse destination intermediate must be rejected"); assert!(matches!(err, DiskError::FileAccessDenied)); assert!(src.exists(), "rejected publication must preserve the staged source"); assert!(!outside.join("object").exists(), "reparse intermediate must not redirect parent creation"); let writable_intermediate = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&linked) .map(winapi_util::Handle::from_file) .expect("reopen intermediate directory to remove its mount point"); try_delete_windows_mount_point(&writable_intermediate).expect("remove intermediate mount point"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_rejects_a_reparse_ancestor_of_nested_base() { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::{ Foundation::GENERIC_WRITE, Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE}, }; let temp_dir = tempdir().expect("create temp dir"); let storage = temp_dir.path().join("storage"); let linked = storage.join("linked"); let outside = temp_dir.path().join("outside"); let outside_base = outside.join("bucket"); let base = linked.join("bucket"); let src = temp_dir.path().join("staged-object"); let dst = base.join("object").join("xl.meta"); std::fs::create_dir(&storage).expect("create storage root"); std::fs::create_dir(&linked).expect("create base ancestor"); std::fs::create_dir(&outside).expect("create outside target"); std::fs::create_dir(&outside_base).expect("create terminal base through redirect target"); std::fs::write(&src, b"payload").expect("write staged object"); let writable_ancestor = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&linked) .map(winapi_util::Handle::from_file) .expect("open base ancestor for reparse mutation"); try_set_windows_mount_point(&writable_ancestor, &outside).expect("redirect the base ancestor"); drop(writable_ancestor); let err = rename_all(&src, &dst, &base) .await .expect_err("a reparse ancestor before a nested base must be rejected"); assert!(matches!(err, DiskError::FileAccessDenied)); assert!(src.exists(), "rejected publication must preserve the staged source"); assert!( !outside_base.join("object").exists(), "a reparse ancestor must not redirect publication outside the guarded tree" ); let writable_ancestor = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&linked) .map(winapi_util::Handle::from_file) .expect("reopen base ancestor to remove its mount point"); try_delete_windows_mount_point(&writable_ancestor).expect("remove base ancestor mount point"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_rejects_a_reparse_ancestor_of_source_parent() { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::{ Foundation::GENERIC_WRITE, Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE}, }; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let staging = temp_dir.path().join("staging"); let linked = staging.join("linked"); let outside = temp_dir.path().join("outside"); let outside_parent = outside.join("parent"); let src = linked.join("parent").join("staged-object"); let dst = base.join("published-object"); std::fs::create_dir(&base).expect("create destination base"); std::fs::create_dir(&staging).expect("create staging root"); std::fs::create_dir(&linked).expect("create source ancestor"); std::fs::create_dir(&outside).expect("create outside target"); std::fs::create_dir(&outside_parent).expect("create redirected source parent"); std::fs::write(outside_parent.join("staged-object"), b"outside").expect("write redirected source object"); let writable_ancestor = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&linked) .map(winapi_util::Handle::from_file) .expect("open source ancestor for reparse mutation"); try_set_windows_mount_point(&writable_ancestor, &outside).expect("redirect the source ancestor"); drop(writable_ancestor); let err = rename_all(&src, &dst, &base) .await .expect_err("a reparse ancestor before the source parent must be rejected"); assert!(matches!(err, DiskError::FileAccessDenied)); assert!(!dst.exists(), "rejected publication must not create a destination"); assert_eq!( std::fs::read(outside_parent.join("staged-object")).expect("read unchanged redirected source"), b"outside" ); let writable_ancestor = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&linked) .map(winapi_util::Handle::from_file) .expect("reopen source ancestor to remove its mount point"); try_delete_windows_mount_point(&writable_ancestor).expect("remove source ancestor mount point"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_retry_retains_destination_identity() { let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let parent = base.join("object"); let src = temp_dir.path().join("staged-directory"); let dst = parent.join("published-directory"); std::fs::create_dir_all(&parent).expect("create destination parent"); std::fs::create_dir(&src).expect("create staged directory"); let dst_for_first_attempt = dst.clone(); windows_rename_test_hooks::install_before_publication(&dst, move || { std::fs::create_dir(&dst_for_first_attempt).expect("create conflicting destination directory"); std::fs::write(dst_for_first_attempt.join("child"), b"occupied").expect("populate conflicting destination"); }); let parent_for_retry = parent.clone(); let replacement = base.join("replacement-object"); let replacement_for_retry = replacement.clone(); let replacement_source = temp_dir.path().join("replacement-source"); let src_for_retry = src.clone(); let replacement_source_for_retry = replacement_source.clone(); let dst_for_retry = dst.clone(); windows_rename_test_hooks::install_before_rename_retry(&dst, move || { std::fs::rename(&parent_for_retry, &replacement_for_retry) .expect_err("the destination guard must remain held between rename attempts"); std::fs::rename(&src_for_retry, &replacement_source_for_retry) .expect_err("the source handle must remain held between rename attempts"); std::fs::remove_file(dst_for_retry.join("child")).expect("remove retry conflict child"); std::fs::remove_dir(&dst_for_retry).expect("remove retry conflict directory"); }); windows_rename_test_hooks::observe_guard_generations(&dst); rename_all(&src, &dst, &base) .await .expect("the second rename attempt must publish through the original guard"); let generations = windows_rename_test_hooks::take_guard_generations(&dst); assert_eq!(generations.len(), 2, "the retry test must observe both publication attempts"); assert_eq!(generations[0], generations[1], "both attempts must retain the same destination guard"); assert!(dst.is_dir(), "the staged directory must be published"); assert!(!replacement.exists(), "the guarded destination parent must not be replaced"); assert!(!replacement_source.exists(), "the guarded source entry must not be replaced"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_retry_recovers_from_a_transient_source_open_conflict() { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE}; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let src = temp_dir.path().join("staged-object"); let dst = base.join("published-object"); std::fs::create_dir(&base).expect("create destination base"); std::fs::write(&src, b"payload").expect("write staged object"); let writer = std::fs::OpenOptions::new() .write(true) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) .open(&src) .expect("retain a transient source writer"); windows_rename_test_hooks::install_before_rename_retry(&dst, move || drop(writer)); rename_all(&src, &dst, &base) .await .expect("the preparation retry must succeed after the writer closes"); assert!(!src.exists()); assert_eq!(std::fs::read(&dst).expect("read retried publication"), b"payload"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_retry_rejects_a_replaced_source_entry() { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE}; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let src = temp_dir.path().join("staged-object"); let original = temp_dir.path().join("original-staged-object"); let dst = base.join("published-object"); std::fs::create_dir(&base).expect("create destination base"); std::fs::write(&src, b"original").expect("write staged object"); let writer = std::fs::OpenOptions::new() .write(true) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) .open(&src) .expect("retain a transient source writer"); let src_for_retry = src.clone(); let original_for_retry = original.clone(); windows_rename_test_hooks::install_before_rename_retry(&dst, move || { drop(writer); std::fs::rename(&src_for_retry, &original_for_retry).expect("move the original staged object aside"); std::fs::write(&src_for_retry, b"replacement").expect("install a replacement staged object"); }); let err = rename_all(&src, &dst, &base) .await .expect_err("a retry must not publish a replacement source entry"); assert!(matches!(err, DiskError::FileCorrupt)); assert!(!dst.exists(), "the replacement source must not be published"); assert_eq!(std::fs::read(&src).expect("read replacement source"), b"replacement"); assert_eq!(std::fs::read(&original).expect("read original source"), b"original"); } #[cfg(windows)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn windows_cancelled_rename_serializes_retry_until_preparation_finishes() { use std::os::windows::fs::OpenOptionsExt; use std::sync::mpsc; use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE}; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let src = temp_dir.path().join("staged-object"); let retry_src = temp_dir.path().join("retry-staged-object"); let dst = base.join("object"); std::fs::create_dir(&base).expect("create destination base"); std::fs::write(&src, b"payload").expect("write staged object"); std::fs::write(&retry_src, b"retry-payload").expect("write retry staged object"); let writer = std::fs::OpenOptions::new() .write(true) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) .open(&src) .expect("retain a transient source writer"); let (release_tx, release_rx) = mpsc::channel(); let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); windows_rename_test_hooks::install_before_rename_retry(&dst, move || { entered_tx.send(()).expect("signal preparation hook entry"); release_rx.recv().expect("wait until the operation has been cancelled"); drop(writer); }); let destination = dst.clone(); let retry_destination = dst.clone(); let retry_base = base.clone(); let rename = tokio::spawn(async move { rename_all(&src, &dst, &base).await }); tokio::time::timeout(std::time::Duration::from_secs(30), entered_rx) .await .expect("timed out waiting for preparation to start before cancellation") .expect("preparation hook sender dropped before cancellation"); rename.abort(); let cancellation = tokio::time::timeout(std::time::Duration::from_secs(1), rename) .await .expect("the async waiter should observe cancellation without waiting for the blocking syscall") .expect_err("the aborted rename task should be cancelled"); assert!(cancellation.is_cancelled(), "the rename waiter should report cancellation"); let mut retry = tokio::spawn(async move { rename_all(&retry_src, &retry_destination, &retry_base).await }); assert!( tokio::time::timeout(std::time::Duration::from_millis(100), &mut retry) .await .is_err(), "a retry must wait while cancelled preparation still owns the destination namespace" ); release_tx.send(()).expect("release preparation after cancellation"); tokio::time::timeout(std::time::Duration::from_secs(10), retry) .await .expect("retry should finish after cancelled preparation releases the namespace") .expect("retry task should not panic") .expect("retry publication should succeed"); assert_eq!( std::fs::read(destination).expect("read retried publication"), b"retry-payload", "the serialized retry must be the final destination value" ); } #[cfg(windows)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn windows_cancelled_rename_serializes_retry_until_publication_finishes() { use std::sync::mpsc; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let src = temp_dir.path().join("staged-object"); let retry_src = temp_dir.path().join("retry-staged-object"); let dst = base.join("object"); std::fs::create_dir(&base).expect("create destination base"); std::fs::write(&src, b"payload").expect("write staged object"); std::fs::write(&retry_src, b"retry-payload").expect("write retry staged object"); let (release_tx, release_rx) = mpsc::channel(); let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); windows_rename_test_hooks::install_before_publication(&dst, move || { entered_tx.send(()).expect("signal publication hook entry"); release_rx.recv().expect("wait until the operation has been cancelled"); }); let destination = dst.clone(); let retry_destination = dst.clone(); let retry_base = base.clone(); let rename = tokio::spawn(async move { rename_all(&src, &dst, &base).await }); tokio::time::timeout(std::time::Duration::from_secs(30), entered_rx) .await .expect("timed out waiting for publication to start before cancellation") .expect("publication hook sender dropped before cancellation"); rename.abort(); let cancellation = tokio::time::timeout(std::time::Duration::from_secs(1), rename) .await .expect("the async waiter should observe cancellation without waiting for publication") .expect_err("the aborted rename task should be cancelled"); assert!(cancellation.is_cancelled(), "the rename waiter should report cancellation"); let mut retry = tokio::spawn(async move { rename_all(&retry_src, &retry_destination, &retry_base).await }); assert!( tokio::time::timeout(std::time::Duration::from_millis(100), &mut retry) .await .is_err(), "a retry must wait while cancelled publication still owns the destination namespace" ); release_tx.send(()).expect("release publication after cancellation"); tokio::time::timeout(std::time::Duration::from_secs(10), retry) .await .expect("retry should finish after cancelled publication releases the namespace") .expect("retry task should not panic") .expect("retry publication should succeed"); assert_eq!( std::fs::read(destination).expect("read retried publication"), b"retry-payload", "the serialized retry must be the final destination value" ); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_rejects_a_source_reparse_entry() { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::{ Foundation::GENERIC_WRITE, Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE}, }; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let src = temp_dir.path().join("staged-directory"); let outside = temp_dir.path().join("outside"); let dst = base.join("published-directory"); std::fs::create_dir(&base).expect("create destination base"); std::fs::create_dir(&src).expect("create source reparse entry"); std::fs::create_dir(&outside).expect("create source target"); std::fs::write(outside.join("marker"), b"outside").expect("write source target marker"); let source_reparse = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&src) .map(winapi_util::Handle::from_file) .expect("open source reparse entry"); try_set_windows_mount_point(&source_reparse, &outside).expect("redirect the staged source"); drop(source_reparse); let publication_root = test_publication_root(&[&src, &dst, &base]); let source_parent_guard = lock_windows_directory_tree(src.parent().expect("source reparse entry must have a parent"), None, &publication_root) .expect("pin the source parent tree"); let identity_error = match open_windows_rename_source_identity(&src, &source_parent_guard) { Ok(_) => panic!("a non-dedup source reparse entry must be rejected by its tag"), Err(err) => err, }; assert_eq!(identity_error.to_string(), WINDOWS_RENAME_SOURCE_REPARSE_ERROR); let rename_error = match open_windows_rename_source(&src, &source_parent_guard) { Ok(_) => panic!("the rename handle must apply the same final-entry tag policy"), Err(err) => err, }; assert_eq!(rename_error.to_string(), WINDOWS_RENAME_SOURCE_REPARSE_ERROR); let err = rename_all(&src, &dst, &base) .await .expect_err("a staged reparse point must not be published into the object tree"); assert!(matches!(err, DiskError::FileAccessDenied)); assert!(!dst.exists(), "rejected reparse publication must not create a destination"); let retained_reparse = std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&src) .map(winapi_util::Handle::from_file) .expect("open retained source reparse entry"); try_delete_windows_mount_point(&retained_reparse).expect("remove retained source reparse point"); drop(retained_reparse); assert!(src.is_dir(), "failed publication must preserve the staged source entry"); assert_eq!(std::fs::read(outside.join("marker")).expect("read unchanged target marker"), b"outside"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_excludes_source_parent_reparse_writers() { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::{ Foundation::GENERIC_WRITE, Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE}, }; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let source_parent = temp_dir.path().join("staging"); let src = source_parent.join("staged-object"); let dst = base.join("published-object"); std::fs::create_dir(&base).expect("create destination base"); std::fs::create_dir(&source_parent).expect("create source parent"); std::fs::write(&src, b"original").expect("write original staged object"); let source_parent_for_hook = source_parent.clone(); windows_rename_test_hooks::install_before_publication(&dst, move || { std::fs::OpenOptions::new() .access_mode(GENERIC_WRITE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(&source_parent_for_hook) .expect_err("the source-parent guard must exclude a reparse writer"); }); rename_all(&src, &dst, &base) .await .expect("source publication must use the anchored source parent"); assert!(!src.exists(), "the original staged entry must be moved"); assert_eq!(std::fs::read(&dst).expect("read published original object"), b"original"); } #[cfg(windows)] #[tokio::test] async fn windows_rename_all_allows_source_readers_but_excludes_writers_and_deleters() { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::{DELETE, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE}; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); std::fs::create_dir(&base).expect("create destination base"); let share_all = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; let readable_src = temp_dir.path().join("readable-stage"); let readable_dst = base.join("readable-object"); std::fs::write(&readable_src, b"reader").expect("write readable source"); let _reader = std::fs::OpenOptions::new() .read(true) .share_mode(share_all) .open(&readable_src) .expect("retain a source reader"); rename_all(&readable_src, &readable_dst, &base) .await .expect("a retained reader must not block publication"); assert_eq!(std::fs::read(&readable_dst).expect("read reader-compatible publication"), b"reader"); let writable_src = temp_dir.path().join("writable-stage"); let writable_dst = base.join("writable-object"); std::fs::write(&writable_src, b"writer").expect("write writable source"); let writer = std::fs::OpenOptions::new() .write(true) .share_mode(share_all) .open(&writable_src) .expect("retain a source writer"); rename_all(&writable_src, &writable_dst, &base) .await .expect_err("a retained writer must block publication"); assert!(writable_src.exists(), "failed writer-conflicting publication must preserve its source"); assert!( !writable_dst.exists(), "failed writer-conflicting publication must not create a destination" ); drop(writer); let deletable_src = temp_dir.path().join("deletable-stage"); let deletable_dst = base.join("deletable-object"); std::fs::write(&deletable_src, b"deleter").expect("write deletable source"); let deleter = std::fs::OpenOptions::new() .access_mode(DELETE) .share_mode(share_all) .open(&deletable_src) .expect("retain a source delete handle"); rename_all(&deletable_src, &deletable_dst, &base) .await .expect_err("a retained delete handle must block duplicate publication"); assert!(deletable_src.exists(), "failed delete-conflicting publication must preserve its source"); assert!( !deletable_dst.exists(), "failed delete-conflicting publication must not create a destination" ); drop(deleter); } #[cfg(windows)] #[test] fn windows_rename_all_preserves_directory_not_empty_error() { use windows_sys::Win32::Foundation::ERROR_DIR_NOT_EMPTY; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let src = temp_dir.path().join("source-directory"); let dst = base.join("destination-directory"); std::fs::create_dir(&base).expect("create destination base"); std::fs::create_dir(&src).expect("create source directory"); std::fs::create_dir(&dst).expect("create destination directory"); std::fs::write(dst.join("child"), b"occupied").expect("populate destination directory"); let guard = mkdir_all_below_existing_base_std(&base, &base).expect("guard destination base"); let publication_root = test_publication_root(&[&src, &dst, &base]); let source_parent_guard = lock_windows_directory_tree(src.parent().expect("source path must have a parent"), None, &publication_root) .expect("anchor source parent"); let source = open_windows_rename_source(&src, &source_parent_guard).expect("anchor source entry"); let err = rename_into_existing_parent(&dst, Some(&guard), &source) .expect_err("replacing a non-empty destination directory must fail"); assert_eq!(err.raw_os_error(), i32::try_from(ERROR_DIR_NOT_EMPTY).ok()); assert!(src.is_dir(), "failed directory replacement must preserve the source"); assert!(dst.join("child").is_file(), "failed directory replacement must preserve the destination"); } #[cfg(windows)] #[tokio::test] async fn windows_guarded_parent_allows_same_and_descendant_publication() { let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let parent = base.join("object"); std::fs::create_dir_all(&parent).expect("create destination parent"); let _guard = mkdir_all_below_existing_base_std(&parent, &base).expect("guard destination parent"); let first_src = temp_dir.path().join("first-stage"); let second_src = temp_dir.path().join("second-stage"); std::fs::write(&first_src, b"first").expect("write first source"); std::fs::write(&second_src, b"second").expect("write second source"); rename_all(&first_src, parent.join("first"), &base) .await .expect("same-parent rename must succeed while a guard is held"); rename_all(&second_src, parent.join("nested").join("second"), &base) .await .expect("descendant-parent rename must succeed while an ancestor guard is held"); assert_eq!(std::fs::read(parent.join("first")).expect("read first destination"), b"first"); assert_eq!( std::fs::read(parent.join("nested").join("second")).expect("read second destination"), b"second" ); } #[cfg(unix)] #[tokio::test] async fn rename_parent_creation_rejects_symlinked_base() { use std::os::unix::fs::symlink; let temp_dir = tempdir().expect("create temp dir"); let outside = temp_dir.path().join("outside"); std::fs::create_dir(&outside).expect("create outside directory"); let base = temp_dir.path().join("bucket"); symlink(&outside, &base).expect("create symlinked base"); mkdir_all_below_existing_base_std(&base.join("object"), &base).expect_err("symlinked base must be rejected"); assert!(!outside.join("object").exists(), "parent creation must remain confined to the base"); } #[cfg(unix)] #[tokio::test] async fn rename_parent_creation_rejects_symlink_below_base() { use std::os::unix::fs::symlink; let temp_dir = tempdir().expect("create temp dir"); let base = temp_dir.path().join("bucket"); let outside = temp_dir.path().join("outside"); std::fs::create_dir(&base).expect("create destination base"); std::fs::create_dir(&outside).expect("create outside directory"); symlink(&outside, base.join("linked")).expect("create symlink below base"); mkdir_all_below_existing_base_std(&base.join("linked/object"), &base).expect_err("symlink below base must be rejected"); assert!( !outside.join("object").exists(), "parent creation must not follow a symlink outside the base" ); } #[tokio::test] async fn fsync_dir_succeeds_on_directory() { let temp_dir = tempdir().expect("create temp dir"); fsync_dir(temp_dir.path()).await.expect("fsync dir must succeed"); } #[tokio::test] async fn file_sync_admission_is_reused_across_commit_barriers() { let temp_dir = tempdir().expect("create temp dir"); let limiter = Arc::new(Semaphore::new(1)); let lease = acquire_namespace_mutation_lease(temp_dir.path()).await; let admission = acquire_file_sync_admission(limiter.clone()) .await .expect("first commit should acquire admission"); run_blocking_namespace_file_sync_operation(lease.clone(), &admission, || Ok(())) .await .expect("first barrier should complete under the admission"); let mut waiting = Box::pin(acquire_file_sync_admission(limiter)); assert!( futures::poll!(&mut waiting).is_pending(), "another commit must remain queued between durability barriers" ); run_blocking_namespace_file_sync_operation(lease, &admission, || Ok(())) .await .expect("later barrier should reuse admission without requeuing"); drop(admission); tokio::time::timeout(Duration::from_secs(30), waiting) .await .expect("queued commit should acquire admission after release") .expect("queued commit should acquire admission"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cancelled_file_sync_waiter_keeps_disk_admission_until_blocking_work_finishes() { use std::sync::mpsc; let temp_dir = tempdir().expect("create temp dir"); let limiter = Arc::new(Semaphore::new(1)); let global_permits = Arc::new(Semaphore::new(1)); let lease = acquire_namespace_mutation_lease(temp_dir.path()).await; let admission = acquire_file_sync_admission(limiter.clone()) .await .expect("file sync admission should be acquired"); let (entered_tx, entered_rx) = mpsc::channel(); let (release_tx, release_rx) = mpsc::channel(); let waiter_global_permits = global_permits.clone(); let waiter = tokio::spawn(async move { run_blocking_namespace_file_sync_operation_with_global(lease, &admission, waiter_global_permits.as_ref(), move || { entered_tx.send(()).expect("signal blocking work"); release_rx.recv().expect("wait for blocking work release"); Ok(()) }) .await }); tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(30))) .await .expect("blocking work waiter should run") .expect("blocking work should start"); waiter.abort(); assert!(waiter.await.expect_err("waiter should be cancelled").is_cancelled()); let returned_global_permit = global_permits .try_acquire() .expect("cancelled waiter must return global capacity for healthy disks"); assert!( limiter.clone().try_acquire_owned().is_err(), "cancelled waiter must not return disk capacity while blocking work is active" ); release_tx.send(()).expect("release blocking work"); let _returned_permit = tokio::time::timeout(Duration::from_secs(30), limiter.acquire_owned()) .await .expect("disk capacity should return after blocking work finishes") .expect("disk limiter should remain open"); drop(returned_global_permit); } #[tokio::test] #[serial_test::serial(file_sync_probe)] async fn sync_dir_files_syncs_regular_files_and_dir() { let temp_dir = tempdir().expect("create temp dir"); std::fs::write(temp_dir.path().join("part.1"), b"shard-one").expect("write part.1"); std::fs::write(temp_dir.path().join("part.2"), b"shard-two").expect("write part.2"); std::fs::create_dir(temp_dir.path().join("subdir")).expect("create subdir"); let _probe = file_sync_probe::set(temp_dir.path()); sync_dir_files(temp_dir.path()).await.expect("sync dir files must succeed"); assert_eq!(std::fs::read(temp_dir.path().join("part.1")).expect("read part.1"), b"shard-one"); assert!( fsync_dir_recorder::was_fsynced(temp_dir.path()), "successful sequential sync must fsync the directory" ); } #[cfg(windows)] #[tokio::test] #[serial_test::serial(file_sync_probe)] async fn windows_sync_dir_files_opens_shards_for_flushing() { let temp_dir = tempdir().expect("create temp dir"); std::fs::write(temp_dir.path().join("part.1"), b"shard").expect("write shard"); let _probe = file_sync_probe::set(temp_dir.path()); sync_dir_files(temp_dir.path()) .await .expect("Windows shard handles must carry write access for FlushFileBuffers"); } #[tokio::test] #[serial_test::serial(file_sync_probe)] async fn sync_dir_files_parallelizes_large_directories() { let temp_dir = tempdir().expect("create temp dir"); for index in 0..PARALLEL_FILE_SYNC_THRESHOLD { std::fs::write(temp_dir.path().join(format!("part.{index}")), b"shard").expect("write part"); } let _probe = file_sync_probe::set_blocking(temp_dir.path()); let path = temp_dir.path().to_path_buf(); let task = tokio::spawn(async move { sync_dir_files_with_limiter(path, file_sync_limiter()).await }); file_sync_probe::wait_for_active(MAX_PARALLEL_FILE_SYNCS).await; assert!(file_sync_probe::peak() > 1, "large directories must sync more than one file concurrently"); assert!( file_sync_probe::peak() <= MAX_PARALLEL_FILE_SYNCS.min(TEST_GLOBAL_FILE_SYNCS), "file sync concurrency must remain bounded" ); file_sync_probe::release(); task.await .expect("join parallel file sync") .expect("parallel file sync must succeed"); assert!( fsync_dir_recorder::was_fsynced(temp_dir.path()), "successful parallel sync must fsync the directory" ); } #[tokio::test] #[serial_test::serial(file_sync_probe)] async fn sync_dir_files_keeps_small_directories_sequential() { let temp_dir = tempdir().expect("create temp dir"); for index in 0..(PARALLEL_FILE_SYNC_THRESHOLD - 1) { std::fs::write(temp_dir.path().join(format!("part.{index}")), b"shard").expect("write part"); } let _probe = file_sync_probe::set_blocking(temp_dir.path()); let path = temp_dir.path().to_path_buf(); let task = tokio::spawn(async move { sync_dir_files_with_limiter(path, file_sync_limiter()).await }); file_sync_probe::wait_for_active(1).await; assert_eq!(file_sync_probe::peak(), 1, "small directories must avoid parallel task overhead"); file_sync_probe::release(); task.await .expect("join sequential file sync") .expect("sequential file sync must succeed"); } #[tokio::test] #[serial_test::serial(file_sync_probe)] async fn sync_dir_files_bounds_concurrency_across_directories() { let temp_dir = tempdir().expect("create temp dir"); let directory_count = TEST_GLOBAL_FILE_SYNCS / MAX_PARALLEL_FILE_SYNCS + 1; let mut directories = Vec::with_capacity(directory_count); for directory_index in 0..directory_count { let directory = temp_dir.path().join(format!("disk.{directory_index}")); std::fs::create_dir(&directory).expect("create disk directory"); for file_index in 0..PARALLEL_FILE_SYNC_THRESHOLD { std::fs::write(directory.join(format!("part.{file_index}")), b"shard").expect("write part"); } directories.push(directory); } let _probe = file_sync_probe::set_blocking(temp_dir.path()); let task = tokio::spawn(async move { futures::future::join_all( directories .iter() .map(|directory| sync_dir_files_with_limiter(directory, file_sync_limiter())), ) .await }); file_sync_probe::wait_for_active(TEST_GLOBAL_FILE_SYNCS).await; assert!( file_sync_probe::peak() > MAX_PARALLEL_FILE_SYNCS, "independent directories should share the global sync capacity" ); assert!( file_sync_probe::peak() <= TEST_GLOBAL_FILE_SYNCS, "aggregate file sync concurrency must remain process-bounded" ); file_sync_probe::release(); let results = task.await.expect("join cross-directory file syncs"); assert!(results.iter().all(std::result::Result::is_ok), "all directory syncs must succeed"); } #[tokio::test] #[serial_test::serial(file_sync_probe)] async fn sync_dir_files_bounds_concurrency_per_disk() { let temp_dir = tempdir().expect("create temp dir"); let mut directories = Vec::with_capacity(2); for directory_index in 0..2 { let directory = temp_dir.path().join(format!("disk.{directory_index}")); std::fs::create_dir(&directory).expect("create disk directory"); for file_index in 0..PARALLEL_FILE_SYNC_THRESHOLD { std::fs::write(directory.join(format!("part.{file_index}")), b"shard").expect("write part"); } directories.push(directory); } let _probe = file_sync_probe::set_blocking(temp_dir.path()); let disk_permits = file_sync_limiter(); let task = tokio::spawn(async move { futures::future::join_all( directories .iter() .map(|directory| sync_dir_files_with_limiter(directory, disk_permits.clone())), ) .await }); file_sync_probe::wait_for_active(MAX_PARALLEL_FILE_SYNCS).await; assert!(file_sync_probe::peak() > 1, "one disk should sync multiple files concurrently"); assert!( file_sync_probe::peak() <= MAX_PARALLEL_FILE_SYNCS, "one disk must not exceed its own sync capacity" ); file_sync_probe::release(); let results = task.await.expect("join per-disk file syncs"); assert!(results.iter().all(std::result::Result::is_ok), "all directory syncs must succeed"); } #[tokio::test] #[serial_test::serial(file_sync_probe)] async fn sync_dir_files_acquires_disk_capacity_before_global_capacity() { let global_reservation = FILE_SYNC_PERMITS .acquire_many(TEST_GLOBAL_FILE_SYNCS as u32) .await .expect("global file sync limiter must remain open"); let disk_permits = Arc::new(Semaphore::new(1)); let mut acquisition = Box::pin(acquire_file_sync_permits(disk_permits.clone())); assert!(futures::poll!(&mut acquisition).is_pending()); assert_eq!( disk_permits.available_permits(), 0, "a waiter blocked on global capacity must already hold its disk permit" ); drop(acquisition); assert_eq!(disk_permits.available_permits(), 1, "cancelling the waiter must return its disk permit"); drop(global_reservation); } #[tokio::test] #[serial_test::serial(file_sync_probe)] async fn sync_dir_files_does_not_fsync_dir_after_sequential_file_failure() { let temp_dir = tempdir().expect("create temp dir"); std::fs::write(temp_dir.path().join("part.1"), b"shard").expect("write part"); let _probe = file_sync_probe::set_failing(temp_dir.path()); let err = sync_dir_files_with_limiter(temp_dir.path(), file_sync_limiter()) .await .expect_err("file sync failure must propagate"); assert_eq!(err.kind(), io::ErrorKind::Other); assert!( !fsync_dir_recorder::was_fsynced(temp_dir.path()), "directory must not be fsynced after a file sync failure" ); } #[tokio::test] #[serial_test::serial(file_sync_probe)] async fn sync_dir_files_parallel_failure_stops_new_work_and_skips_dir_fsync() { let temp_dir = tempdir().expect("create temp dir"); let file_count = TEST_GLOBAL_FILE_SYNCS * 16; for index in 0..file_count { std::fs::write(temp_dir.path().join(format!("part.{index}")), b"shard").expect("write part"); } let _probe = file_sync_probe::set_failing_blocking(temp_dir.path()); let err = sync_dir_files_with_limiter(temp_dir.path(), file_sync_limiter()) .await .expect_err("parallel file sync failure must propagate"); assert_eq!(err.kind(), io::ErrorKind::Other); assert!( file_sync_probe::attempts() <= MAX_PARALLEL_FILE_SYNCS, "parallel sync must stop scheduling files after the first failure" ); assert!( !fsync_dir_recorder::was_fsynced(temp_dir.path()), "directory must not be fsynced after a parallel file sync failure" ); file_sync_probe::release(); file_sync_probe::wait_for_idle().await; } #[tokio::test] #[serial_test::serial(file_sync_probe)] async fn sync_dir_files_cancellation_isolates_global_capacity_from_stuck_disk_work() { let temp_dir = tempdir().expect("create temp dir"); for index in 0..PARALLEL_FILE_SYNC_THRESHOLD { std::fs::write(temp_dir.path().join(format!("part.{index}")), b"shard").expect("write part"); } let _probe = file_sync_probe::set_blocking(temp_dir.path()); let disk_permits = file_sync_limiter(); let initial_disk_permits = disk_permits.available_permits(); let global_reservation = FILE_SYNC_PERMITS .acquire_many((TEST_GLOBAL_FILE_SYNCS - MAX_PARALLEL_FILE_SYNCS) as u32) .await .expect("global file sync limiter must remain open"); let path = temp_dir.path().to_path_buf(); let task = tokio::spawn({ let disk_permits = disk_permits.clone(); async move { sync_dir_files_with_limiter(path, disk_permits).await } }); file_sync_probe::wait_for_active(MAX_PARALLEL_FILE_SYNCS).await; task.abort(); let join_err = task.await.expect_err("file sync task must be cancelled"); assert!(join_err.is_cancelled(), "task abort must cancel the outer file sync future"); assert_eq!( disk_permits.available_permits(), 0, "detached blocking syncs must retain their per-disk permits" ); let returned_global_permits = FILE_SYNC_PERMITS .try_acquire_many(MAX_PARALLEL_FILE_SYNCS as u32) .expect("cancelled work must return global capacity for healthy disks"); file_sync_probe::release(); file_sync_probe::wait_for_idle().await; let returned_disk_permits = tokio::time::timeout( std::time::Duration::from_secs(30), disk_permits.clone().acquire_many_owned(initial_disk_permits as u32), ) .await .expect("blocking syncs must return their per-disk permits") .expect("disk file sync limiter must remain open"); drop(returned_disk_permits); drop(returned_global_permits); drop(global_reservation); } #[tokio::test] #[serial_test::serial(file_sync_probe)] async fn sync_dir_files_missing_dir_returns_not_found() { let temp_dir = tempdir().expect("create temp dir"); let missing = temp_dir.path().join("missing"); let _probe = file_sync_probe::set(temp_dir.path()); let err = sync_dir_files(&missing).await.expect_err("missing dir must fail"); assert_eq!(err.kind(), io::ErrorKind::NotFound); } }