diff --git a/.github/workflows/windows-filesystem.yml b/.github/workflows/windows-filesystem.yml index 2d3aa3b89..c47e7bc54 100644 --- a/.github/workflows/windows-filesystem.yml +++ b/.github/workflows/windows-filesystem.yml @@ -69,6 +69,10 @@ jobs: install-build-packaging-tools: 'false' install-test-tools: 'false' + - name: Check production Windows dependencies + shell: pwsh + run: cargo check -p rustfs-ecstore --lib + - name: Test guarded rename publication shell: pwsh run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture diff --git a/crates/ecstore/Cargo.toml b/crates/ecstore/Cargo.toml index 23a91ade9..dcc83cc1c 100644 --- a/crates/ecstore/Cargo.toml +++ b/crates/ecstore/Cargo.toml @@ -240,7 +240,19 @@ rustfs-uring = "0.2.1" [target.'cfg(windows)'.dependencies] winapi-util.workspace = true -windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } +windows-sys = { workspace = true, features = [ + "Wdk_Foundation", + "Wdk_Storage_FileSystem", + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_SystemServices", + "Win32_System_WindowsProgramming", +] } + +[target.'cfg(windows)'.dev-dependencies] +windows-sys = { workspace = true, features = ["Win32_System_Ioctl"] } [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] } diff --git a/crates/ecstore/src/disk/local.rs b/crates/ecstore/src/disk/local.rs index 2f661eb57..58632adde 100644 --- a/crates/ecstore/src/disk/local.rs +++ b/crates/ecstore/src/disk/local.rs @@ -239,10 +239,15 @@ async fn write_metadata_rollback_backup(object_dir: &Path, rollback_dir: Uuid, d Ok(()) } -async fn restore_metadata_backup(object_dir: &Path, xl_path: &Path, rollback_dir: Uuid) -> Result<()> { +async fn restore_metadata_backup( + object_dir: &Path, + xl_path: &Path, + rollback_dir: Uuid, + publication_root: &os::PublicationRoot, +) -> Result<()> { let rollback_path = object_dir.join(rollback_dir.to_string()); let backup_path = rollback_path.join(STORAGE_FORMAT_FILE_BACKUP); - rename_all(&backup_path, xl_path, object_dir).await?; + rename_all(&backup_path, xl_path, object_dir, publication_root).await?; // A synthetic inline rollback dir held only the backup the rename above // just consumed; reclaim it so the object dir can empty out. A real data // dir still holds its parts, so the non-recursive remove is a benign @@ -251,7 +256,132 @@ async fn restore_metadata_backup(object_dir: &Path, xl_path: &Path, rollback_dir Ok(()) } -async fn restore_delete_rollback(object_dir: &Path, xl_path: &Path, rollback_dir: Uuid) -> Result<()> { +async fn lock_rename_commit_directories( + source_parent: &Path, + destination_parent: &Path, + base_dir: &Path, + publication_root: &os::PublicationRoot, + mutation_lease: Arc, +) -> Result { + #[cfg(windows)] + let result = { + let source_parent = source_parent.to_path_buf(); + let destination_parent = destination_parent.to_path_buf(); + let base_dir = base_dir.to_path_buf(); + let publication_root = publication_root.clone(); + os::run_blocking_namespace_operation(mutation_lease, move || { + let result = os::prepare_rename_commit_guard(&source_parent, &destination_parent, &base_dir, &publication_root); + #[cfg(test)] + if result.is_ok() { + run_destination_commit_directory_preparation(&destination_parent); + } + result + }) + .await + }; + #[cfg(not(windows))] + let result = { + let _ = mutation_lease; + os::prepare_rename_commit_guard(source_parent, destination_parent, base_dir, publication_root) + }; + + let result = result.map_err(|err| match std::fs::symlink_metadata(base_dir) { + Err(base_err) if base_err.kind() == ErrorKind::NotFound => base_err, + _ => err, + }); + + result.map_err(to_file_error).map_err(DiskError::from) +} + +async fn read_rename_destination_metadata( + file_path: &Path, + rename_commit_guard: &os::RenameCommitGuard, + mutation_lease: Arc, +) -> Result> { + #[cfg(windows)] + let result = { + let file_path = file_path.to_path_buf(); + let rename_commit_guard = rename_commit_guard.clone(); + os::run_blocking_namespace_operation(mutation_lease, move || { + os::read_destination_file_with_commit_guard(&file_path, &rename_commit_guard) + }) + .await + }; + #[cfg(not(windows))] + let _ = (rename_commit_guard, mutation_lease); + #[cfg(not(windows))] + let result = match super::fs::read_file(file_path).await { + Ok(data) => Ok(Some(data)), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + }; + + result + .map(|data| data.map(Bytes::from)) + .map_err(to_file_error) + .map_err(DiskError::from) +} + +async fn restore_renamed_data_source( + src_volume_dir: &Path, + src_data_path: &Path, + dst_data_path: &Path, + publication_root: &os::PublicationRoot, + mutation_lease: Arc, +) -> Result<()> { + if fs::symlink_metadata(src_data_path).await.is_ok() { + return Ok(()); + } + let result = + match os::rename_all_with_lease(dst_data_path, src_data_path, src_volume_dir, publication_root, mutation_lease).await { + Ok(()) => Ok(()), + Err(DiskError::FileNotFound) => { + let source_exists = fs::symlink_metadata(src_data_path).await.is_ok(); + let destination_missing = matches!( + fs::symlink_metadata(dst_data_path).await, + Err(err) if err.kind() == ErrorKind::NotFound + ); + if source_exists && destination_missing { + Ok(()) + } else { + Err(DiskError::FileNotFound) + } + } + Err(err) => Err(err), + }; + if let Err(err) = &result { + warn!( + event = EVENT_DISK_LOCAL_RENAME_REJECTED, + component = LOG_COMPONENT_ECSTORE, + subsystem = LOG_SUBSYSTEM_DISK_LOCAL, + reason = "restore_staged_data_source_failed", + src_path = ?src_data_path, + dst_path = ?dst_data_path, + error = ?err, + "Failed to restore staged data after a metadata commit was rejected" + ); + } + result +} + +async fn restore_published_data_source( + data_paths: Option<&(PathBuf, PathBuf)>, + src_volume_dir: &Path, + publication_root: &os::PublicationRoot, + mutation_lease: Arc, +) -> Result<()> { + let Some((src_data_path, dst_data_path)) = data_paths else { + return Ok(()); + }; + restore_renamed_data_source(src_volume_dir, src_data_path, dst_data_path, publication_root, mutation_lease).await +} + +async fn restore_delete_rollback( + object_dir: &Path, + xl_path: &Path, + rollback_dir: Uuid, + publication_root: &os::PublicationRoot, +) -> Result<()> { remove_version_delete_markers(object_dir, rollback_dir).await?; let rollback_path = object_dir.join(rollback_dir.to_string()); let mut staged_paths = Vec::new(); @@ -273,11 +403,11 @@ async fn restore_delete_rollback(object_dir: &Path, xl_path: &Path, rollback_dir let had_staged_paths = !staged_paths.is_empty(); for (src, dst) in staged_paths { - rename_all(&src, &dst, object_dir).await?; + rename_all(&src, &dst, object_dir, publication_root).await?; } let backup_path = rollback_path.join(STORAGE_FORMAT_FILE_BACKUP); - match rename_all(&backup_path, xl_path, object_dir).await { + match rename_all(&backup_path, xl_path, object_dir, publication_root).await { Ok(()) => { let _ = fs::remove_dir(&rollback_path).await; Ok(()) @@ -335,32 +465,38 @@ async fn remove_version_delete_markers(object_dir: &Path, rollback_dir: Uuid) -> Ok(()) } +struct DeleteRollbackFailure { + stage: &'static str, + error: DiskError, +} + async fn restore_delete_rollback_after_error( object_dir: &Path, xl_path: &Path, rollback_dir: Option, volume: &str, path: &str, - stage: &'static str, - err: DiskError, + failure: DeleteRollbackFailure, + publication_root: &os::PublicationRoot, ) -> DiskError { + let DeleteRollbackFailure { stage, error } = failure; let Some(rollback_dir) = rollback_dir else { - return err; + return error; }; - if let Err(restore_err) = restore_delete_rollback(object_dir, xl_path, rollback_dir).await { + if let Err(restore_err) = restore_delete_rollback(object_dir, xl_path, rollback_dir, publication_root).await { warn!( volume, path, rollback_dir = %rollback_dir, stage, - cause = ?err, + cause = ?error, error = ?restore_err, "failed to restore delete rollback after local delete error" ); } - err + error } /// Whether a failed `remove_dir` while cleaning up an object path is benign. @@ -1846,14 +1982,30 @@ fn mmap_page_size() -> Result { #[cfg(test)] static RENAME_DATA_FAIL_BEFORE_OLD_METADATA_BACKUP: std::sync::Mutex> = std::sync::Mutex::new(None); #[cfg(test)] -static RENAME_DATA_FAIL_AFTER_METADATA_COMMIT: std::sync::Mutex> = std::sync::Mutex::new(None); +static RENAME_DATA_FAIL_AFTER_METADATA_COMMIT: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); #[cfg(test)] static RENAME_DATA_FAIL_COMMIT_RENAME: std::sync::Mutex> = std::sync::Mutex::new(None); #[cfg(test)] +static RENAME_DATA_REMOVE_STAGED_META_BEFORE_COMMIT: std::sync::Mutex> = std::sync::Mutex::new(None); +#[cfg(test)] static LOCAL_INLINE_ROLLBACK_HARDLINK_FAILURE: std::sync::Mutex> = std::sync::Mutex::new(None); #[cfg(test)] static RENAME_DATA_REMOVE_DST_BASE_BEFORE_COMMIT: std::sync::Mutex> = std::sync::Mutex::new(None); #[cfg(test)] +type InlinePreparationHook = Box; +#[cfg(test)] +static INLINE_PREPARATION_BEFORE_BACKUP: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); +#[cfg(test)] +static RENAME_DATA_AFTER_FIRST_PUBLICATION: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); +#[cfg(test)] +static OWNED_FILE_WRITE_BEFORE_OPEN: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); +#[cfg(all(test, windows))] +static DESTINATION_COMMIT_DIRECTORY_PREPARATION: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); +#[cfg(test)] static DELETE_VERSION_FAIL_AFTER_DATA_STAGED: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); #[cfg(test)] static DELETE_VERSION_FAIL_AFTER_COMMIT: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); @@ -1867,9 +2019,10 @@ fn set_rename_data_fail_before_old_metadata_backup(dst_path: &str) { #[cfg(test)] fn set_rename_data_fail_after_metadata_commit(dst_path: &str) { - *RENAME_DATA_FAIL_AFTER_METADATA_COMMIT + RENAME_DATA_FAIL_AFTER_METADATA_COMMIT .lock() - .expect("test failpoint lock should not be poisoned") = Some(dst_path.to_string()); + .expect("test failpoint lock should not be poisoned") + .push(dst_path.to_string()); } #[cfg(test)] @@ -1879,6 +2032,13 @@ fn set_rename_data_fail_commit_rename(dst_path: &str) { .expect("test failpoint lock should not be poisoned") = Some(dst_path.to_string()); } +#[cfg(test)] +fn set_rename_data_remove_staged_meta_before_commit(dst_path: &str) { + *RENAME_DATA_REMOVE_STAGED_META_BEFORE_COMMIT + .lock() + .expect("test failpoint lock should not be poisoned") = Some(dst_path.to_string()); +} + #[cfg(test)] fn set_local_inline_rollback_hardlink_failure(dst_path: &Path) { *LOCAL_INLINE_ROLLBACK_HARDLINK_FAILURE @@ -1893,6 +2053,38 @@ fn set_rename_data_remove_dst_base_before_commit(dst_path: &str, dst_base: &Path .expect("test failpoint lock should not be poisoned") = Some((dst_path.to_string(), dst_base.to_path_buf())); } +#[cfg(test)] +fn set_inline_preparation_before_backup(dst_path: &str, hook: impl FnOnce() + Send + 'static) { + INLINE_PREPARATION_BEFORE_BACKUP + .lock() + .expect("test preparation hook lock should not be poisoned") + .insert(dst_path.to_string(), Box::new(hook)); +} + +#[cfg(test)] +fn set_rename_data_after_first_publication(dst_path: &str, hook: impl FnOnce() + Send + 'static) { + RENAME_DATA_AFTER_FIRST_PUBLICATION + .lock() + .expect("test publication hook lock should not be poisoned") + .insert(dst_path.to_string(), Box::new(hook)); +} + +#[cfg(test)] +fn set_owned_file_write_before_open(path: &Path, hook: impl FnOnce() + Send + 'static) { + OWNED_FILE_WRITE_BEFORE_OPEN + .lock() + .expect("test file write hook lock should not be poisoned") + .insert(path.to_path_buf(), Box::new(hook)); +} + +#[cfg(all(test, windows))] +fn set_destination_commit_directory_preparation(path: &Path, hook: impl FnOnce() + Send + 'static) { + DESTINATION_COMMIT_DIRECTORY_PREPARATION + .lock() + .expect("test destination preparation hook lock should not be poisoned") + .insert(path.to_path_buf(), Box::new(hook)); +} + #[cfg(test)] fn set_delete_version_fail_after_data_staged(path: &str) { DELETE_VERSION_FAIL_AFTER_DATA_STAGED @@ -1924,11 +2116,11 @@ fn should_fail_before_old_metadata_backup(dst_path: &str) -> bool { #[cfg(test)] fn should_fail_after_metadata_commit(dst_path: &str) -> bool { - let mut target = RENAME_DATA_FAIL_AFTER_METADATA_COMMIT + let mut targets = RENAME_DATA_FAIL_AFTER_METADATA_COMMIT .lock() .expect("test failpoint lock should not be poisoned"); - if target.as_deref() == Some(dst_path) { - target.take(); + if let Some(index) = targets.iter().position(|target| target == dst_path) { + targets.remove(index); true } else { false @@ -1948,6 +2140,18 @@ fn should_fail_commit_rename(dst_path: &str) -> bool { } } +#[cfg(test)] +fn should_remove_staged_meta_before_commit(dst_path: &str) -> bool { + let mut target = RENAME_DATA_REMOVE_STAGED_META_BEFORE_COMMIT + .lock() + .expect("test failpoint lock should not be poisoned"); + if target.as_deref() != Some(dst_path) { + return false; + } + target.take(); + true +} + #[cfg(test)] fn should_fail_local_inline_rollback_hardlink(dst_path: &Path) -> bool { let mut target = LOCAL_INLINE_ROLLBACK_HARDLINK_FAILURE @@ -1962,16 +2166,77 @@ fn should_fail_local_inline_rollback_hardlink(dst_path: &Path) -> bool { } #[cfg(test)] -fn remove_dst_base_before_commit(dst_path: &str) -> std::io::Result<()> { - let mut target = RENAME_DATA_REMOVE_DST_BASE_BEFORE_COMMIT - .lock() - .expect("test failpoint lock should not be poisoned"); - let Some((_, base)) = target.as_ref().filter(|(target_path, _)| target_path == dst_path) else { - return Ok(()); +async fn remove_dst_base_before_commit( + dst_path: &str, + guard: os::RenameCommitGuard, + source_parent: &Path, + destination_parent: &Path, + destination_base: &Path, + publication_root: &os::PublicationRoot, + mutation_lease: Arc, +) -> Result { + let base = { + let mut target = RENAME_DATA_REMOVE_DST_BASE_BEFORE_COMMIT + .lock() + .expect("test failpoint lock should not be poisoned"); + if target.as_ref().is_some_and(|(target_path, _)| target_path == dst_path) { + target.take().map(|(_, base)| base) + } else { + None + } }; - std::fs::remove_dir_all(base)?; - target.take(); - Ok(()) + let Some(base) = base else { + return Ok(guard); + }; + + #[cfg(windows)] + drop(guard); + std::fs::remove_dir_all(base).map_err(to_file_error)?; + lock_rename_commit_directories(source_parent, destination_parent, destination_base, publication_root, mutation_lease).await +} + +#[cfg(test)] +fn run_inline_preparation_before_backup(dst_path: &str) { + let hook = INLINE_PREPARATION_BEFORE_BACKUP + .lock() + .expect("test preparation hook lock should not be poisoned") + .remove(dst_path); + if let Some(hook) = hook { + hook(); + } +} + +#[cfg(test)] +fn run_rename_data_after_first_publication(dst_path: &str) { + let hook = RENAME_DATA_AFTER_FIRST_PUBLICATION + .lock() + .expect("test publication hook lock should not be poisoned") + .remove(dst_path); + if let Some(hook) = hook { + hook(); + } +} + +#[cfg(test)] +fn run_owned_file_write_before_open(path: &Path) { + let hook = OWNED_FILE_WRITE_BEFORE_OPEN + .lock() + .expect("test file write hook lock should not be poisoned") + .remove(path); + if let Some(hook) = hook { + hook(); + } +} + +#[cfg(all(test, windows))] +fn run_destination_commit_directory_preparation(path: &Path) { + let hook = DESTINATION_COMMIT_DIRECTORY_PREPARATION + .lock() + .expect("test destination preparation hook lock should not be poisoned") + .remove(path); + if let Some(hook) = hook { + hook(); + } } #[cfg(test)] @@ -2018,16 +2283,35 @@ fn should_fail_commit_rename(_dst_path: &str) -> bool { false } +#[cfg(not(test))] +fn should_remove_staged_meta_before_commit(_dst_path: &str) -> bool { + false +} + #[cfg(not(test))] fn should_fail_local_inline_rollback_hardlink(_dst_path: &Path) -> bool { false } #[cfg(not(test))] -fn remove_dst_base_before_commit(_dst_path: &str) -> std::io::Result<()> { - Ok(()) +async fn remove_dst_base_before_commit( + _dst_path: &str, + guard: os::RenameCommitGuard, + _source_parent: &Path, + _destination_parent: &Path, + _destination_base: &Path, + _publication_root: &os::PublicationRoot, + _mutation_lease: Arc, +) -> Result { + Ok(guard) } +#[cfg(not(test))] +fn run_inline_preparation_before_backup(_dst_path: &str) {} + +#[cfg(not(test))] +fn run_rename_data_after_first_publication(_dst_path: &str) {} + #[cfg(not(test))] fn should_fail_after_delete_data_staged(_path: &str) -> bool { false @@ -4186,6 +4470,7 @@ fn build_local_io_backend(root: PathBuf) -> Arc { pub struct LocalDisk { pub root: PathBuf, + publication_root: os::PublicationRoot, pub format_path: PathBuf, pub format_info: RwLock, pub endpoint: Endpoint, @@ -4313,6 +4598,26 @@ impl LocalDisk { let root = resolve_local_disk_root(&endpoint_path).inspect_err(|err| { log_startup_disk_error("resolve_local_disk_root", Path::new(&endpoint_path), err); })?; + #[cfg(windows)] + let publication_root_path = { + // `resolve_local_disk_root` validates fallback mount roots. The + // publication root must still retain the configured alias so paths + // created before final-path normalization remain relative to it. + drop(root); + Path::new(&endpoint_path) + }; + #[cfg(not(windows))] + let publication_root_path = root.as_path(); + let publication_root = os::PublicationRoot::new(publication_root_path) + .map_err(DiskError::from) + .inspect_err(|err| { + log_startup_disk_error("open_publication_root", publication_root_path, err); + })?; + // On Windows the configured endpoint may be a junction, subst drive, or + // mapped path. Use the final path from the pinned root handle for every + // subsequent path-based operation so retargeting the configured alias + // cannot split ordinary IO from handle-relative publication. + let root = publication_root.path().to_path_buf(); ensure_data_usage_layout(&root) .await @@ -4325,8 +4630,13 @@ impl LocalDisk { let startup_cleanup_notify = Arc::new(Notify::new()); if cleanup - && let Err(err) = - Self::cleanup_tmp_on_startup(&root, startup_cleanup_ready.clone(), startup_cleanup_notify.clone()).await + && let Err(err) = Self::cleanup_tmp_on_startup( + &root, + &publication_root, + startup_cleanup_ready.clone(), + startup_cleanup_notify.clone(), + ) + .await { startup_cleanup_ready.store(1, Ordering::Release); startup_cleanup_notify.notify_waiters(); @@ -4442,6 +4752,7 @@ impl LocalDisk { // TODD: DiskInfo let mut disk = Self { root: root.clone(), + publication_root, endpoint: ep.clone(), format_path, format_info: RwLock::new(format_info), @@ -4492,7 +4803,8 @@ impl LocalDisk { disk.exit_signal = Some(exit_tx); let root = disk.root.clone(); - tokio::spawn(Self::cleanup_deleted_objects_loop(root, exit_rx)); + let publication_root = disk.publication_root.clone(); + tokio::spawn(Self::cleanup_deleted_objects_loop(root, publication_root, exit_rx)); debug!( event = EVENT_DISK_LOCAL_STARTUP_CLEANUP, component = LOG_COMPONENT_ECSTORE, @@ -4505,7 +4817,11 @@ impl LocalDisk { Ok(disk) } - async fn cleanup_deleted_objects_loop(root: PathBuf, mut exit_rx: tokio::sync::broadcast::Receiver<()>) { + async fn cleanup_deleted_objects_loop( + root: PathBuf, + publication_root: os::PublicationRoot, + mut exit_rx: tokio::sync::broadcast::Receiver<()>, + ) { let start_at = Instant::now() + DELETED_OBJECTS_CLEANUP_INTERVAL; let mut interval = interval_at(start_at, DELETED_OBJECTS_CLEANUP_INTERVAL); loop { @@ -4522,7 +4838,7 @@ impl LocalDisk { "Disk local background cleanup failed" ); } - if let Err(err) = Self::cleanup_stale_tmp_objects(root.clone()).await { + if let Err(err) = Self::cleanup_stale_tmp_objects(root.clone(), &publication_root).await { error!( event = EVENT_DISK_LOCAL_BACKGROUND_CLEANUP, component = LOG_COMPONENT_ECSTORE, @@ -4560,13 +4876,14 @@ impl LocalDisk { async fn cleanup_tmp_on_startup( root: &Path, + publication_root: &os::PublicationRoot, startup_cleanup_ready: Arc, startup_cleanup_notify: Arc, ) -> Result<()> { let tmp_path = Self::meta_path(root, RUSTFS_META_TMP_BUCKET); let tmp_old_path = Self::meta_path(root, RUSTFS_META_TMP_OLD_BUCKET).join(Uuid::new_v4().to_string()); - rename_all_ignore_missing_source(&tmp_path, &tmp_old_path, root) + rename_all_ignore_missing_source(&tmp_path, &tmp_old_path, root, publication_root) .await .inspect_err(|err| { log_startup_disk_error("cleanup_tmp_rename_all", &tmp_path, err); @@ -4617,11 +4934,15 @@ impl LocalDisk { } } - async fn cleanup_stale_tmp_objects(root: PathBuf) -> Result<()> { - Self::cleanup_stale_tmp_objects_with_expiry(root, STALE_TMP_OBJECT_EXPIRY).await + async fn cleanup_stale_tmp_objects(root: PathBuf, publication_root: &os::PublicationRoot) -> Result<()> { + Self::cleanup_stale_tmp_objects_with_expiry(root, publication_root, STALE_TMP_OBJECT_EXPIRY).await } - async fn cleanup_stale_tmp_objects_with_expiry(root: PathBuf, expiry: Duration) -> Result<()> { + async fn cleanup_stale_tmp_objects_with_expiry( + root: PathBuf, + publication_root: &os::PublicationRoot, + expiry: Duration, + ) -> Result<()> { let tmp_path = Self::meta_path(&root, RUSTFS_META_TMP_BUCKET); let mut entries = match fs::read_dir(&tmp_path).await { Ok(entries) => entries, @@ -4658,7 +4979,7 @@ impl LocalDisk { } let target_path = Self::meta_path(&root, RUSTFS_META_TMP_DELETED_BUCKET).join(Uuid::new_v4().to_string()); - rename_all(entry.path(), target_path, Self::meta_path(&root, RUSTFS_META_BUCKET)).await?; + rename_all(entry.path(), target_path, Self::meta_path(&root, RUSTFS_META_BUCKET), publication_root).await?; } Ok(()) @@ -4898,13 +5219,22 @@ impl LocalDisk { // } let err = if recursive { - rename_all_ignore_missing_source(delete_path, trash_path, self.get_bucket_path(RUSTFS_META_TMP_DELETED_BUCKET)?) - .await - .err() + rename_all_ignore_missing_source( + delete_path, + trash_path, + self.get_bucket_path(RUSTFS_META_TMP_DELETED_BUCKET)?, + &self.publication_root, + ) + .await + .err() } else { match rename(&delete_path, &trash_path).await { Ok(()) => None, - Err(err) if err.kind() == ErrorKind::NotFound => None, + Err(err) + if err.kind() == ErrorKind::NotFound && os::rename_source_is_missing(delete_path, &self.publication_root) => + { + None + } Err(err) => Some(to_file_error(err).into()), } }; @@ -4915,6 +5245,7 @@ impl LocalDisk { encode_dir_object(delete_path.to_string_lossy().as_ref()), trash_path2, self.get_bucket_path(RUSTFS_META_TMP_DELETED_BUCKET)?, + &self.publication_root, ) .await; } @@ -4932,18 +5263,8 @@ impl LocalDisk { return Ok(()); } - // A missing source is benign (the object is already gone). Both the - // recursive path (rename_all_ignore_missing_source) and the - // non-recursive NotFound arm above already fold that case into `None`, - // but keep the guard explicit so a genuine rename failure is never - // reported as success. Every other error is a real failure: propagate - // it (already mapped by to_file_error, e.g. I/O -> FaultyDisk, - // permission -> FileAccessDenied) so callers can surface a faulty disk - // and trigger heal, matching MinIO's deleteFile. - if err == Error::FileNotFound { - return Ok(()); - } - + // Missing sources are folded into `None` above. Any remaining error is + // a real failure, including a missing destination base. return Err(err); } @@ -5254,10 +5575,10 @@ impl LocalDisk { && opts.undo_write { if opts.undo_delete { - return restore_delete_rollback(object_dir, &xlpath, rollback_dir).await; + return restore_delete_rollback(object_dir, &xlpath, rollback_dir, &self.publication_root).await; } - return restore_metadata_backup(object_dir, &xlpath, rollback_dir).await; + return restore_metadata_backup(object_dir, &xlpath, rollback_dir, &self.publication_root).await; } let (data, _) = self.read_all_data_with_dmtime(volume, volume_dir.as_path(), &xlpath).await?; @@ -5302,8 +5623,11 @@ impl LocalDisk { rollback_dir, volume, path, - "delete_versions_metadata_update", - err, + DeleteRollbackFailure { + stage: "delete_versions_metadata_update", + error: err, + }, + &self.publication_root, ) .await); } @@ -5334,8 +5658,11 @@ impl LocalDisk { rollback_dir, volume, path, - "delete_versions_data_path", - err, + DeleteRollbackFailure { + stage: "delete_versions_data_path", + error: err, + }, + &self.publication_root, ) .await); } @@ -5362,8 +5689,11 @@ impl LocalDisk { Some(rollback_dir), volume, path, - "delete_versions_rollback_dir", - err, + DeleteRollbackFailure { + stage: "delete_versions_rollback_dir", + error: err, + }, + &self.publication_root, ) .await); } @@ -5385,7 +5715,13 @@ impl LocalDisk { reserved_version_delete |= reserved; let rollback_data_path = rollback_path.join(dir.to_string()); if !reserved - && let Err(err) = rename_all_ignore_missing_source(&dir_path, &rollback_data_path, &rollback_path).await + && let Err(err) = rename_all_ignore_missing_source( + &dir_path, + &rollback_data_path, + &rollback_path, + &self.publication_root, + ) + .await { return Err(restore_delete_rollback_after_error( object_dir, @@ -5393,8 +5729,11 @@ impl LocalDisk { Some(rollback_dir), volume, path, - "delete_versions_stage_data", - err, + DeleteRollbackFailure { + stage: "delete_versions_stage_data", + error: err, + }, + &self.publication_root, ) .await); } @@ -5417,8 +5756,11 @@ impl LocalDisk { Some(rollback_dir), volume, path, - "delete_versions_test_after_stage", - DiskError::Unexpected, + DeleteRollbackFailure { + stage: "delete_versions_test_after_stage", + error: DiskError::Unexpected, + }, + &self.publication_root, ) .await); } @@ -5456,8 +5798,11 @@ impl LocalDisk { rollback_dir, volume, path, - "delete_versions_commit_delete", - err, + DeleteRollbackFailure { + stage: "delete_versions_commit_delete", + error: err, + }, + &self.publication_root, ) .await); } @@ -5496,8 +5841,11 @@ impl LocalDisk { rollback_dir, volume, path, - "delete_versions_metadata_encode", - err, + DeleteRollbackFailure { + stage: "delete_versions_metadata_encode", + error: err, + }, + &self.publication_root, ) .await); } @@ -5518,8 +5866,11 @@ impl LocalDisk { rollback_dir, volume, path, - "delete_versions_commit_write", - err, + DeleteRollbackFailure { + stage: "delete_versions_commit_write", + error: err, + }, + &self.publication_root, ) .await); } @@ -5569,7 +5920,7 @@ impl LocalDisk { return Err(DiskError::Unexpected); } - rename_all(tmp_file_path, &file_path, volume_dir).await?; + rename_all(tmp_file_path, &file_path, volume_dir, &self.publication_root).await?; if sync && durability.syncs_commit_metadata() @@ -5656,27 +6007,27 @@ impl LocalDisk { } tokio::task::spawn_blocking(move || { - let mut f = std::fs::OpenOptions::new() + #[cfg(test)] + run_owned_file_write_before_open(&path); + + let mut file = std::fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .open(&path) .map_err(to_file_error)?; - - std::io::Write::write_all(&mut f, buf.as_ref()).map_err(to_file_error)?; + std::io::Write::write_all(&mut file, buf.as_ref()).map_err(to_file_error)?; if sync != SyncMode::None { - f.sync_data().map_err(to_file_error)?; - // See the Ref branch above: FileOnly callers rename the - // file away, so the tmp directory entry never needs to - // become durable. + file.sync_data().map_err(to_file_error)?; + // FileOnly callers rename the file away, so the tmp + // directory entry never needs to become durable. if sync == SyncMode::FileAndDir && let Some(parent) = path.parent() { os::fsync_dir_std(parent).map_err(to_file_error)?; } } - - Ok::<(), std::io::Error>(()) + Ok::<_, std::io::Error>(()) }) .await .map_err(DiskError::from)??; @@ -6650,7 +7001,16 @@ impl LocalDisk { err: DiskError, ) -> DiskError { let xl_path = object_dir.join(STORAGE_FORMAT_FILE); - restore_delete_rollback_after_error(object_dir, &xl_path, Some(rollback_dir), volume, object, stage, err).await + restore_delete_rollback_after_error( + object_dir, + &xl_path, + Some(rollback_dir), + volume, + object, + DeleteRollbackFailure { stage, error: err }, + &self.publication_root, + ) + .await } /// Execute every deferred data-dir deletion pending on `volume` right now, @@ -7433,7 +7793,7 @@ impl DiskAPI for LocalDisk { .map_err(to_file_error)?; } - rename_all(&src_file_path, &dst_file_path, &dst_volume_dir).await?; + rename_all(&src_file_path, &dst_file_path, &dst_volume_dir, &self.publication_root).await?; if durability.syncs_commit_metadata() && let Some(parent) = dst_file_path.parent() @@ -7456,7 +7816,7 @@ impl DiskAPI for LocalDisk { if let Some(transaction_publish_meta) = transaction_publish_meta { let dst_meta_path = self.get_object_path(dst_volume, &format!("{dst_path}.meta"))?; - rename_all(&transaction_publish_meta, &dst_meta_path, &dst_volume_dir).await?; + rename_all(&transaction_publish_meta, &dst_meta_path, &dst_volume_dir, &self.publication_root).await?; if durability.syncs_commit_metadata() && let Some(parent) = dst_meta_path.parent() { @@ -7531,7 +7891,7 @@ impl DiskAPI for LocalDisk { } } - rename_all(&src_file_path, &dst_file_path, &dst_volume_dir).await?; + rename_all(&src_file_path, &dst_file_path, &dst_volume_dir, &self.publication_root).await?; // Both ends changed identity: the source path no longer exists and the // destination now resolves to a different inode (backlog#1145). @@ -7773,8 +8133,10 @@ impl DiskAPI for LocalDisk { crate::hp_guard!("LocalDisk::rename_data"); // A non-force DeleteBucket must not remove a directory while a local // object commit is publishing into it. The peer's empty scan remains - // optimistic; this guard establishes the local commit/delete order. - let _volume_mutation_guard = os::disk_volume_mutation_lock(&self.root, dst_volume).read_owned().await; + // optimistic; this lease establishes the local commit/delete order and + // remains owned by any blocking syscall that outlives async cancellation. + let destination_object_path = self.get_object_path(dst_volume, dst_path)?; + let mutation_lease = os::acquire_rename_data_mutation_lease(&self.root, dst_volume, &destination_object_path).await; if fi.is_legacy_indexed_delete_marker() { fi.erasure.index = 0; } @@ -7869,19 +8231,30 @@ impl DiskAPI for LocalDisk { // pinned to strict. let durability = effective_durability(dst_volume); + let src_file_parent = src_file_path + .parent() + .ok_or_else(|| DiskError::other("missing staged metadata parent"))?; + let dst_file_parent = dst_file_path + .parent() + .ok_or_else(|| DiskError::other("missing object metadata parent"))?; + if !no_inline { + fs::create_dir_all(src_file_parent).await.map_err(to_file_error)?; + } + // Acquire the common trees before reading destination metadata. On + // Windows this pins the object directory identity across metadata + // preparation, data publication, rollback backup, and final commit. + let rename_commit_guard = lock_rename_commit_directories( + src_file_parent, + dst_file_parent, + &dst_volume_dir, + &self.publication_root, + mutation_lease.clone(), + ) + .await?; + let has_dst_buf = read_rename_destination_metadata(&dst_file_path, &rename_commit_guard, mutation_lease.clone()).await?; + if no_inline { // Non-inline: read xl.meta, parse, write, rename data dir, rename xl.meta - let has_dst_buf = match super::fs::read_file(&dst_file_path).await { - Ok(res) => Some(res), - Err(e) => { - let e: DiskError = to_file_error(e).into(); - if e != DiskError::FileNotFound { - return Err(e); - } - None - } - }; - let mut xlmeta = FileMeta::new(); // An existing dst xl.meta that fails to parse leaves `xlmeta` empty // and gets overwritten by the commit below (pre-existing behavior); @@ -7928,7 +8301,6 @@ impl DiskAPI for LocalDisk { let version_signature = rename_data_versions_signature(&xlmeta); let new_dst_buf = xlmeta.marshal_msg()?; - let src_file_parent = src_file_path.parent().unwrap_or(src_volume_dir.as_path()); // This tmp xl.meta is renamed onto dst_file_path at the commit // point below, so only its contents must be durable before the // rename (SyncMode::FileOnly); the dst parent directory is fsynced @@ -7954,10 +8326,28 @@ impl DiskAPI for LocalDisk { // fdatasync here is a cheap no-op. A missing source dir is left for the // rename below to report through the existing rollback path. Payload // durability is kept by both strict and relaxed. - // Bound to a local so the borrow lives across the join! below. - let tmp_meta_rel_path = format!("{}/{}", src_path, STORAGE_FORMAT_FILE); - let tmp_meta_write = - self.write_all_private(src_volume, &tmp_meta_rel_path, new_dst_buf.into(), tmp_meta_sync, src_file_parent); + let tmp_meta_write = { + let src_file_path = src_file_path.clone(); + let dst_file_path = dst_file_path.clone(); + let rename_commit_guard = rename_commit_guard.clone(); + let mutation_lease = mutation_lease.clone(); + async move { + os::run_blocking_namespace_operation(mutation_lease, move || { + #[cfg(test)] + run_owned_file_write_before_open(&src_file_path); + let mut prepared_metadata_source = os::create_prepared_rename_source_with_commit_guard( + &src_file_path, + &dst_file_path, + &rename_commit_guard, + )?; + prepared_metadata_source.write_all(&new_dst_buf, tmp_meta_sync != SyncMode::None)?; + Ok(prepared_metadata_source) + }) + .await + .map_err(to_file_error) + .map_err(DiskError::from) + } + }; let shard_sync = async { if durability.syncs_data_shards() && let Some((src_data_path, _)) = has_data_dir_path.as_ref() @@ -7972,9 +8362,23 @@ impl DiskAPI for LocalDisk { // Surface a tmp-meta failure first (its prior serial position), then a // shard-sync failure; either aborts before any rename, exactly as the // sequential version did. - tmp_meta_res?; + let prepared_metadata_source = tmp_meta_res?; shard_sync_res?; - remove_dst_base_before_commit(dst_path).map_err(to_file_error)?; + let rename_commit_guard = remove_dst_base_before_commit( + dst_path, + rename_commit_guard, + src_file_parent, + dst_file_parent, + &dst_volume_dir, + &self.publication_root, + mutation_lease.clone(), + ) + .await?; + if should_remove_staged_meta_before_commit(dst_path) { + drop(prepared_metadata_source); + std::fs::remove_file(&src_file_path).map_err(to_file_error)?; + return Err(DiskError::FileNotFound); + } // Heal reuses the version's data_dir, so for in-place corruption // the destination dir still exists — and rename(2) cannot replace @@ -7995,11 +8399,17 @@ impl DiskAPI for LocalDisk { "Healing commit could not purge the stale destination data dir" ); } - if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref() - && let Err(err) = rename_all(src_data_path, dst_data_path, &skip_parent).await + && let Err(err) = os::rename_all_with_commit_guard( + src_data_path, + dst_data_path, + &skip_parent, + &self.publication_root, + &rename_commit_guard, + mutation_lease.clone(), + ) + .await { - let _ = self.delete_file(&dst_volume_dir, dst_data_path, false, false).await; info!( event = EVENT_DISK_LOCAL_RENAME_REJECTED, component = LOG_COMPONENT_ECSTORE, @@ -8010,8 +8420,18 @@ impl DiskAPI for LocalDisk { error = ?err, "Disk local rename flow failed" ); + restore_published_data_source( + has_data_dir_path.as_ref(), + &src_volume_dir, + &self.publication_root, + mutation_lease.clone(), + ) + .await?; return Err(err); } + if has_data_dir_path.is_some() { + run_rename_data_after_first_publication(dst_path); + } // Crash-consistency injection: hard power loss after the data dir // is in place but before xl.meta commits. No cleanup — the harness @@ -8022,9 +8442,6 @@ impl DiskAPI for LocalDisk { } if should_fail_before_old_metadata_backup(dst_path) { - if let Some((_, dst_data_path)) = has_data_dir_path.as_ref() { - let _ = self.delete_file(&dst_volume_dir, dst_data_path, false, false).await; - } info!( event = EVENT_DISK_LOCAL_RENAME_REJECTED, component = LOG_COMPONENT_ECSTORE, @@ -8032,6 +8449,13 @@ impl DiskAPI for LocalDisk { reason = "test_fail_before_old_metadata_backup", "Disk local rename flow failed before metadata commit" ); + restore_published_data_source( + has_data_dir_path.as_ref(), + &src_volume_dir, + &self.publication_root, + mutation_lease.clone(), + ) + .await?; return Err(DiskError::Unexpected); } @@ -8045,30 +8469,82 @@ impl DiskAPI for LocalDisk { } else { SyncMode::None }; - if let Some(old_data_dir) = rollback_data_dir - && let Some(dst_buf) = has_dst_buf.as_ref() - && let Err(err) = self - .write_all_private( - dst_volume, - &format!("{}/{}/{}", dst_path, old_data_dir, STORAGE_FORMAT_FILE_BACKUP), - dst_buf.clone().into(), - backup_sync, - &skip_parent, + if let (Some(old_data_dir), Some(dst_buf)) = (rollback_data_dir, has_dst_buf.as_ref()) { + let backup_parent = dst_file_parent.join(old_data_dir.to_string()); + #[cfg(not(windows))] + if let Err(err) = os::make_dir_all(&backup_parent, &skip_parent).await { + restore_published_data_source( + has_data_dir_path.as_ref(), + &src_volume_dir, + &self.publication_root, + mutation_lease.clone(), ) - .await - { - if let Some((_, dst_data_path)) = has_data_dir_path.as_ref() { - let _ = self.delete_file(&dst_volume_dir, dst_data_path, false, false).await; + .await?; + return Err(err); + } + let backup_path_guard = match rename_commit_guard.create_destination_directory_for_path_access(&backup_parent) { + Ok(guard) => guard, + Err(err) => { + restore_published_data_source( + has_data_dir_path.as_ref(), + &src_volume_dir, + &self.publication_root, + mutation_lease.clone(), + ) + .await?; + return Err(DiskError::from(to_file_error(err))); + } + }; + let backup_path = backup_parent.join(STORAGE_FORMAT_FILE_BACKUP); + if let Err(err) = check_path_length(backup_path.to_string_lossy().as_ref()) { + #[cfg(windows)] + drop(backup_path_guard); + restore_published_data_source( + has_data_dir_path.as_ref(), + &src_volume_dir, + &self.publication_root, + mutation_lease.clone(), + ) + .await?; + return Err(err); + } + let backup_bytes = dst_buf.clone(); + // Keep the volume, commit-tree, and exact destination-path + // guards in this task until the backup write and durability + // sync finish. A detached spawn_blocking writer could survive + // cancellation and later truncate a newer transaction's + // deterministic rollback backup. + let write_result = os::run_blocking_namespace_operation(mutation_lease.clone(), move || { + #[cfg(test)] + run_owned_file_write_before_open(&backup_path); + backup_path_guard.write_file_for_path_access( + &backup_path, + backup_bytes.as_ref(), + backup_sync != SyncMode::None, + backup_sync == SyncMode::FileAndDir, + ) + }) + .await + .map_err(to_file_error) + .map_err(DiskError::from); + if let Err(err) = write_result { + info!( + event = EVENT_DISK_LOCAL_RENAME_REJECTED, + component = LOG_COMPONENT_ECSTORE, + subsystem = LOG_SUBSYSTEM_DISK_LOCAL, + reason = "write_old_metadata_backup_failed", + error = ?err, + "Disk local rename flow failed" + ); + restore_published_data_source( + has_data_dir_path.as_ref(), + &src_volume_dir, + &self.publication_root, + mutation_lease.clone(), + ) + .await?; + return Err(err); } - info!( - event = EVENT_DISK_LOCAL_RENAME_REJECTED, - component = LOG_COMPONENT_ECSTORE, - subsystem = LOG_SUBSYSTEM_DISK_LOCAL, - reason = "write_old_metadata_backup_failed", - error = ?err, - "Disk local rename flow failed" - ); - return Err(err); } // Crash-consistency injection: hard power loss after the rollback @@ -8079,10 +8555,17 @@ impl DiskAPI for LocalDisk { return Err(DiskError::Unexpected); } - if let Err(err) = rename_all(&src_file_path, &dst_file_path, &skip_parent).await { - if let Some((_, dst_data_path)) = has_data_dir_path.as_ref() { - let _ = self.delete_file(&dst_volume_dir, dst_data_path, false, false).await; - } + if let Err(err) = os::rename_all_with_prepared_source( + prepared_metadata_source, + &src_file_path, + &dst_file_path, + &skip_parent, + &self.publication_root, + &rename_commit_guard, + mutation_lease.clone(), + ) + .await + { info!( event = EVENT_DISK_LOCAL_RENAME_REJECTED, component = LOG_COMPONENT_ECSTORE, @@ -8093,6 +8576,13 @@ impl DiskAPI for LocalDisk { error = ?err, "Disk local rename flow failed" ); + restore_published_data_source( + has_data_dir_path.as_ref(), + &src_volume_dir, + &self.publication_root, + mutation_lease.clone(), + ) + .await?; return Err(err); } @@ -8164,6 +8654,12 @@ impl DiskAPI for LocalDisk { } } + // Publication and every rollback-capable durability step are now + // complete. Do not retain the Windows object identity guard while + // cleaning staging paths or invalidating cached descriptors. + #[cfg(windows)] + drop(rename_commit_guard); + if let Some(src_file_path_parent) = src_file_path.parent() { if src_volume != super::RUSTFS_META_MULTIPART_BUCKET { let _ = remove_std(src_file_path_parent); @@ -8197,26 +8693,26 @@ impl DiskAPI for LocalDisk { old_current_size, }) } else { - // Inline: merge read + parse + write + rename into single spawn_blocking + // Inline metadata preparation is blocking. The transaction lease is + // moved into that work so a timeout can release the async waiter without + // allowing a retry to reuse the deterministic staging path too early. let src = src_file_path.clone(); let dst = dst_file_path.clone(); - // Captured by the closure to fsync the new object's ancestor dir chain. - let bucket_dir = dst_volume_dir.clone(); let cleanup_path = if src_volume == super::RUSTFS_META_MULTIPART_BUCKET { src_file_path.parent().map(|p| p.to_path_buf()) } else { None }; - let dst_path_for_failpoint = dst_path.to_string(); - let inline_commit = tokio::task::spawn_blocking(move || { - // Read existing xl.meta - let has_dst_buf = match std::fs::read(&dst) { - Ok(buf) => Some(Bytes::from(buf)), - Err(e) if e.kind() == ErrorKind::NotFound => None, - Err(e) => return Err(to_file_error(e)), - }; - + #[cfg(windows)] + let source_parent = src_file_parent.to_path_buf(); + let rename_commit_guard_for_preparation = rename_commit_guard.clone(); + let inline_preparation = os::run_blocking_namespace_operation(mutation_lease.clone(), move || { + let mut prepared_metadata_source = + os::create_prepared_rename_source_with_commit_guard(&src, &dst, &rename_commit_guard_for_preparation)?; + #[cfg(windows)] + let source_metadata_guard = + rename_commit_guard_for_preparation.lock_source_directory_for_path_access(&source_parent)?; let mut xlmeta = FileMeta::new(); // Same as the non-inline branch: an unparsable existing dst // xl.meta must surface as unknown, not `Absent` @@ -8249,109 +8745,151 @@ impl DiskAPI for LocalDisk { } }); let sync = durability.syncs_commit_metadata(); - let mut local_rollback_path = None; + let mut staged_rollback_path = None; if let Some(d) = old_data_dir.as_ref() { let _ = xlmeta.data.remove_two(version_id, *d); } xlmeta.add_version(fi)?; let version_signature = rename_data_versions_signature(&xlmeta); let new_buf = xlmeta.marshal_msg()?; - remove_dst_base_before_commit(&dst_path_for_failpoint).map_err(to_file_error)?; - - // Write new xl.meta + rename. Inline objects carry their data - // inside xl.meta, so this whole sequence is a metadata commit: + // Write the staged xl.meta. Inline objects carry their data inside + // xl.meta, so this is the durable preparation for the metadata commit: // relaxed tiers do no per-object fsync here at all (aligned // with MinIO's default), trading a documented power-loss // window for latency. - if let Some(parent) = src.parent() { - std::fs::create_dir_all(parent)?; - } - let mut f = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&src)?; - std::io::Write::write_all(&mut f, &new_buf)?; - if sync { - f.sync_data()?; - } - if let Some(old_dir) = rollback_data_dir.as_ref() - && let Some(ref buf) = has_dst_buf - && let Some(dst_parent) = dst.parent() + prepared_metadata_source.write_all(&new_buf, sync)?; + run_inline_preparation_before_backup(&dst_path_for_failpoint); + if let Some(ref old_metadata) = has_dst_buf + && (rollback_data_dir.is_some() || sync || cfg!(test)) { - let old_path = dst_parent.join(old_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP); - let old_parent = old_path.parent().map(|p| p.to_path_buf()); - let _old_parent_guard = old_parent - .as_deref() - .map(|parent| os::mkdir_all_below_existing_base_std(parent, &bucket_dir)) - .transpose() - .map_err(to_file_error)?; - // This rollback backup is the sole restore source for a later - // undo_write when the set-level write quorum fails. Persist it as - // durably as the new xl.meta written above (and as the non-inline - // branch does): a bare std::fs::write leaves both the bytes and the - // new directory entry in the page cache, so a crash before a - // rollback could restore a lost or truncated backup. - let mut backup = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&old_path) - .map_err(to_file_error)?; - std::io::Write::write_all(&mut backup, buf).map_err(to_file_error)?; + #[cfg(windows)] + let backup_path = { + let backup_path = src + .parent() + .ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "missing staging metadata parent"))? + .join(STORAGE_FORMAT_FILE_BACKUP); + source_metadata_guard.write_file_for_path_access(&backup_path, old_metadata, sync, false)?; + backup_path + }; + #[cfg(not(windows))] + let backup_path = create_local_inline_rollback_backup(&dst, &src, old_metadata)?; + #[cfg(not(windows))] if sync { - backup.sync_data().map_err(to_file_error)?; - if let Some(ref old_parent) = old_parent { - os::fsync_dir_std(old_parent).map_err(to_file_error)?; - } + std::fs::File::open(&backup_path)?.sync_data()?; } - } else if let Some(ref old_metadata) = has_dst_buf - && (sync || cfg!(test)) - { - local_rollback_path = Some(create_local_inline_rollback_backup(&dst, &src, old_metadata)?); + staged_rollback_path = Some(backup_path); } - #[cfg(windows)] - let _commit_parent_guard = if let Some(parent) = dst.parent() { - Some(os::mkdir_all_below_existing_base_std(parent, &bucket_dir).map_err(to_file_error)?) - } else { - None - }; - let commit_result = if should_fail_commit_rename(&dst_path_for_failpoint) { - Err(std::io::Error::other("test fail during metadata commit rename")) - } else { - match std::fs::rename(&src, &dst) { - Ok(()) => Ok(()), - Err(err) if err.kind() == ErrorKind::NotFound && !src.exists() => Ok(()), - Err(err) if err.kind() == ErrorKind::NotFound => { - if let Some(parent) = dst.parent() { - let _parent_guard = - os::mkdir_all_below_existing_base_std(parent, &bucket_dir).map_err(to_file_error)?; - } - std::fs::rename(&src, &dst).map_err(to_file_error)?; - Ok(()) - } - Err(err) => Err(to_file_error(err)), - } - }; - if let Err(err) = commit_result { - if let Some(backup_path) = local_rollback_path.as_deref() { - let _ = remove_file_if_exists(backup_path); + Ok::<_, std::io::Error>(( + rollback_data_dir, + old_data_dir, + version_signature, + old_current_size, + staged_rollback_path, + has_dst_buf.is_none(), + prepared_metadata_source, + )) + }) + .await + .map_err(to_file_error) + .map_err(DiskError::from); + + let ( + rollback_data_dir, + cleanup_data_dir, + version_signature, + old_current_size, + mut local_rollback_path, + destination_was_absent, + prepared_metadata_source, + ) = match inline_preparation { + Ok(prepared) => prepared, + Err(err) => { + for part_path in &invalidate_part_paths { + self.io_backend.invalidate_cached_fd(dst_volume, part_path).await; } return Err(err); } + }; - if should_fail_after_metadata_commit(&dst_path_for_failpoint) { - rollback_inline_metadata_commit_std(&dst, rollback_data_dir, local_rollback_path.as_deref())?; + let rename_commit_guard = remove_dst_base_before_commit( + dst_path, + rename_commit_guard, + src_file_parent, + dst_file_parent, + &dst_volume_dir, + &self.publication_root, + mutation_lease.clone(), + ) + .await?; + + if should_remove_staged_meta_before_commit(dst_path) { + drop(prepared_metadata_source); + let remove_result = std::fs::remove_file(&src_file_path); + if let Some(backup_path) = local_rollback_path.as_deref() { + let _ = remove_file_if_exists(backup_path); + } + remove_result.map_err(to_file_error)?; + return Err(DiskError::FileNotFound); + } + + if let (Some(rollback_data_dir), Some(staged_backup)) = (rollback_data_dir, local_rollback_path.as_deref()) { + let Some(dst_parent) = dst_file_path.parent() else { + return Err(DiskError::other("missing object metadata parent")); + }; + let backup_path = dst_parent + .join(rollback_data_dir.to_string()) + .join(STORAGE_FORMAT_FILE_BACKUP); + if let Err(err) = rename_all(staged_backup, &backup_path, &dst_volume_dir, &self.publication_root).await { + let _ = remove_file_if_exists(staged_backup); + return Err(err); + } + run_rename_data_after_first_publication(dst_path); + if durability.syncs_commit_metadata() + && let Some(backup_parent) = backup_path.parent() + && let Err(err) = os::fsync_dir(backup_parent).await + { + return Err(DiskError::from(to_file_error(err))); + } + local_rollback_path = None; + } + + let commit_result = if should_fail_commit_rename(dst_path) { + Err(DiskError::other("test fail during metadata commit rename")) + } else { + os::rename_all_with_prepared_source( + prepared_metadata_source, + &src_file_path, + &dst_file_path, + &dst_volume_dir, + &self.publication_root, + &rename_commit_guard, + mutation_lease.clone(), + ) + .await + }; + if let Err(err) = commit_result { + if let Some(backup_path) = local_rollback_path.as_deref() { + let _ = remove_file_if_exists(backup_path); + } + for part_path in &invalidate_part_paths { + self.io_backend.invalidate_cached_fd(dst_volume, part_path).await; + } + return Err(err); + } + + let post_commit = async { + if should_fail_after_metadata_commit(dst_path) { + rollback_inline_metadata_commit_std(&dst_file_path, rollback_data_dir, local_rollback_path.as_deref())?; return Err(std::io::Error::other("test fail after metadata commit")); } // Persist the commit rename's directory entry across power loss. - if sync - && let Some(dst_parent) = dst.parent() - && let Err(err) = os::fsync_dir_std(dst_parent) + if durability.syncs_commit_metadata() + && let Some(dst_parent) = dst_file_path.parent() + && let Err(err) = os::fsync_dir(dst_parent).await { - rollback_inline_metadata_commit_std(&dst, rollback_data_dir, local_rollback_path.as_deref())?; + rollback_inline_metadata_commit_std(&dst_file_path, rollback_data_dir, local_rollback_path.as_deref())?; return Err(err); } @@ -8362,53 +8900,51 @@ impl DiskAPI for LocalDisk { // not its own entry, so for a new inline object fsync the ancestor // chain up to and including the bucket. Overwrites already have a // durable object dir; the starts_with guard bounds the walk. - if sync && has_dst_buf.is_none() { - let mut ancestor = dst.parent().and_then(|object_dir| object_dir.parent()); + if durability.syncs_commit_metadata() && destination_was_absent { + let mut ancestor = dst_file_path.parent().and_then(|object_dir| object_dir.parent()); while let Some(ancestor_dir) = ancestor { - if !ancestor_dir.starts_with(&bucket_dir) { + if !ancestor_dir.starts_with(&dst_volume_dir) { break; } - if let Err(err) = os::fsync_dir_std(ancestor_dir) { - rollback_inline_metadata_commit_std(&dst, rollback_data_dir, local_rollback_path.as_deref())?; + if let Err(err) = os::fsync_dir(ancestor_dir).await { + rollback_inline_metadata_commit_std( + &dst_file_path, + rollback_data_dir, + local_rollback_path.as_deref(), + )?; return Err(err); } - if ancestor_dir == bucket_dir.as_path() { + if ancestor_dir == dst_volume_dir.as_path() { break; } ancestor = ancestor_dir.parent(); } } - if let Some(backup_path) = local_rollback_path.as_deref() { - let _ = remove_file_if_exists(backup_path); - } + Ok::<(), std::io::Error>(()) + } + .await; - Ok::<(Option, Option, Option>, Option), std::io::Error>(( - rollback_data_dir, - old_data_dir, - version_signature, - old_current_size, - )) - }) - .await - .map_err(DiskError::from)?; - - // A post-commit rollback inside the closure (a commit-metadata fsync - // failure under strict durability) restores the old data dir; drop any - // fds cached during the committed window before propagating the error - // (rustfs/backlog#1177). The sync closure cannot call the async - // invalidate itself, so it is done here. Inline objects carry their - // data in xl.meta rather than separate part inodes, so this is mostly - // defensive, but it keeps the inline and streaming branches consistent. - let (old_data_dir, cleanup_data_dir, version_signature, old_current_size) = match inline_commit { - Ok(committed) => committed, - Err(err) => { - for part_path in &invalidate_part_paths { - self.io_backend.invalidate_cached_fd(dst_volume, part_path).await; - } - return Err(DiskError::from(err)); + // A post-commit rollback (for example, a commit-metadata fsync + // failure under strict durability) restores the old metadata; drop any + // descriptors cached during the committed window before propagating the + // error (rustfs/backlog#1177). Inline objects carry data in xl.meta, so + // this is mostly defensive and keeps both commit branches consistent. + if let Err(err) = post_commit { + for part_path in &invalidate_part_paths { + self.io_backend.invalidate_cached_fd(dst_volume, part_path).await; } - }; + return Err(DiskError::from(err)); + } + + // The commit no longer has a rollback path. Release the Windows + // object identity guard before best-effort staging cleanup. + #[cfg(windows)] + drop(rename_commit_guard); + + if let Some(backup_path) = local_rollback_path.as_deref() { + let _ = remove_file_if_exists(backup_path); + } // Cleanup if let Some(ref cleanup) = cleanup_path { @@ -8434,7 +8970,7 @@ impl DiskAPI for LocalDisk { Ok(RenameDataResp { old_data_dir: cleanup_data_dir, - rollback_data_dir: old_data_dir, + rollback_data_dir, cleanup_data_dir, sign: version_signature, old_current_size, @@ -8898,9 +9434,9 @@ impl DiskAPI for LocalDisk { && opts.undo_write { if opts.undo_delete { - restore_delete_rollback(file_path.as_path(), &xl_path, old_data_dir).await?; + restore_delete_rollback(file_path.as_path(), &xl_path, old_data_dir, &self.publication_root).await?; } else { - restore_metadata_backup(file_path.as_path(), &xl_path, old_data_dir).await?; + restore_metadata_backup(file_path.as_path(), &xl_path, old_data_dir, &self.publication_root).await?; } if !opts.undo_delete @@ -8937,7 +9473,8 @@ impl DiskAPI for LocalDisk { } if let Err(err) = self.write_metadata("", volume, path, fi).await { if let Some(rollback_dir) = rollback_dir - && let Err(restore_err) = restore_delete_rollback(file_path.as_path(), &xl_path, rollback_dir).await + && let Err(restore_err) = + restore_delete_rollback(file_path.as_path(), &xl_path, rollback_dir, &self.publication_root).await { warn!( volume, @@ -8977,8 +9514,11 @@ impl DiskAPI for LocalDisk { rollback_dir, volume, path, - "delete_version_metadata_update", - err, + DeleteRollbackFailure { + stage: "delete_version_metadata_update", + error: err, + }, + &self.publication_root, ) .await); } @@ -8991,8 +9531,11 @@ impl DiskAPI for LocalDisk { rollback_dir, volume, path, - "delete_version_data_path", - err, + DeleteRollbackFailure { + stage: "delete_version_data_path", + error: err, + }, + &self.publication_root, ) .await); } @@ -9007,8 +9550,11 @@ impl DiskAPI for LocalDisk { Some(rollback_dir), volume, path, - "delete_version_rollback_dir", - err, + DeleteRollbackFailure { + stage: "delete_version_rollback_dir", + error: err, + }, + &self.publication_root, ) .await); } @@ -9021,15 +9567,20 @@ impl DiskAPI for LocalDisk { Some(rollback_dir), volume, path, - "delete_version_reserve_data", - err, + DeleteRollbackFailure { + stage: "delete_version_reserve_data", + error: err, + }, + &self.publication_root, ) .await); } }; let rollback_data_path = rollback_path.join(uuid.to_string()); if !reserved_version_delete - && let Err(err) = rename_all_ignore_missing_source(&old_path, &rollback_data_path, &rollback_path).await + && let Err(err) = + rename_all_ignore_missing_source(&old_path, &rollback_data_path, &rollback_path, &self.publication_root) + .await { return Err(restore_delete_rollback_after_error( file_path.as_path(), @@ -9037,8 +9588,11 @@ impl DiskAPI for LocalDisk { Some(rollback_dir), volume, path, - "delete_version_stage_data", - err, + DeleteRollbackFailure { + stage: "delete_version_stage_data", + error: err, + }, + &self.publication_root, ) .await); } @@ -9061,8 +9615,11 @@ impl DiskAPI for LocalDisk { Some(rollback_dir), volume, path, - "delete_version_test_after_stage", - DiskError::Unexpected, + DeleteRollbackFailure { + stage: "delete_version_test_after_stage", + error: DiskError::Unexpected, + }, + &self.publication_root, ) .await); } @@ -9104,8 +9661,11 @@ impl DiskAPI for LocalDisk { rollback_dir, volume, path, - "delete_version_metadata_encode", - err, + DeleteRollbackFailure { + stage: "delete_version_metadata_encode", + error: err, + }, + &self.publication_root, ) .await); } @@ -9128,8 +9688,11 @@ impl DiskAPI for LocalDisk { rollback_dir, volume, path, - "delete_version_commit", - err, + DeleteRollbackFailure { + stage: "delete_version_commit", + error: err, + }, + &self.publication_root, ) .await); } @@ -9775,7 +10338,7 @@ mod test { // #948: a genuinely missing source is benign and must still return Ok. #[tokio::test] - async fn move_to_trash_missing_source_is_ok() { + async fn windows_and_unix_move_to_trash_missing_source_is_ok() { let (disk, dir) = new_disk().await; let missing = dir.path().join("bucket").join("does-not-exist"); @@ -9787,11 +10350,52 @@ mod test { .expect("missing source must be treated as benign (non-recursive)"); } + #[tokio::test] + async fn windows_and_unix_move_to_trash_missing_destination_base_preserves_the_source() { + let (disk, dir) = new_disk().await; + let source = dir.path().join("bucket/object"); + let source_file = dir.path().join("bucket/object-file"); + fs::create_dir_all(&source).await.expect("source directory should be created"); + fs::write(source.join("part.1"), b"payload") + .await + .expect("source payload should be written"); + fs::write(&source_file, b"file-payload") + .await + .expect("source file should be written"); + let trash = disk + .get_bucket_path(RUSTFS_META_TMP_DELETED_BUCKET) + .expect("trash path should resolve"); + if let Err(err) = fs::remove_dir_all(&trash).await + && err.kind() != ErrorKind::NotFound + { + panic!("trash directory should be removable: {err}"); + } + + let err = disk + .move_to_trash(&source, true, false) + .await + .expect_err("a missing trash base must not be reported as a successful delete"); + + assert_eq!(err, DiskError::FileNotFound); + let err = disk + .move_to_trash(&source_file, false, false) + .await + .expect_err("a missing trash base must not be reported as a successful non-recursive delete"); + assert_eq!(err, DiskError::FileNotFound); + assert_eq!( + fs::read(source.join("part.1")) + .await + .expect("source payload must remain readable"), + b"payload" + ); + assert_eq!(fs::read(&source_file).await.expect("source file must remain readable"), b"file-payload"); + } + // #948: a real rename failure (here ENOTDIR, because a path component is a // regular file) must propagate instead of being swallowed as Ok(()). Before // the fix every non-DiskFull error fell through to `return Ok(())`. #[tokio::test] - async fn move_to_trash_propagates_real_rename_error() { + async fn windows_and_unix_move_to_trash_propagates_real_rename_error() { let (disk, dir) = new_disk().await; let bucket_dir = dir.path().join("bucket"); fs::create_dir_all(&bucket_dir).await.expect("bucket dir should be created"); @@ -9811,7 +10415,7 @@ mod test { // #948: the happy path is unchanged — an existing object is moved out of its // original location and the call succeeds. #[tokio::test] - async fn move_to_trash_moves_existing_object() { + async fn windows_and_unix_move_to_trash_moves_existing_object() { let (disk, dir) = new_disk().await; let object_dir = dir.path().join("bucket").join("obj-dir"); fs::create_dir_all(&object_dir).await.expect("object dir should be created"); @@ -9829,7 +10433,7 @@ mod test { // succeed. Before the fix the unconditional pre-rename remove returned // FileNotFound and aborted the whole rename. #[tokio::test] - async fn rename_file_directory_to_missing_destination_succeeds() { + async fn windows_and_unix_rename_file_directory_to_missing_destination_succeeds() { let (disk, dir) = new_disk().await; ensure_test_volume(&disk, "vol").await; @@ -9851,7 +10455,7 @@ mod test { // #960: the same NotFound-tolerance fix applied to rename_part. #[tokio::test] - async fn rename_part_directory_to_missing_destination_succeeds() { + async fn windows_and_unix_rename_part_directory_to_missing_destination_succeeds() { let (disk, dir) = new_disk().await; ensure_test_volume(&disk, "vol").await; @@ -10230,6 +10834,7 @@ mod test { let dir = tempdir().expect("temp dir should be created"); let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created"); + let publication_root = os::PublicationRoot::new(dir.path()).expect("publication root should open"); disk.startup_cleanup_ready.store(0, Ordering::Release); let ready = Arc::clone(&disk.startup_cleanup_ready); @@ -10241,7 +10846,7 @@ mod test { disk.wait_for_startup_cleanup().await; assert_eq!(disk.startup_cleanup_ready.load(Ordering::Acquire), 1); - LocalDisk::cleanup_stale_tmp_objects_with_expiry(dir.path().join("missing-root"), Duration::ZERO) + LocalDisk::cleanup_stale_tmp_objects_with_expiry(dir.path().join("missing-root"), &publication_root, Duration::ZERO) .await .expect("missing tmp path should be a cleanup no-op"); LocalDisk::cleanup_deleted_objects(dir.path().join("missing-root")) @@ -10257,7 +10862,7 @@ mod test { fs::create_dir_all(&trash_root).await.expect("trash dir should be created"); backdate_mtime(&stale_dir, Duration::from_secs(10)); - LocalDisk::cleanup_stale_tmp_objects_with_expiry(dir.path().to_path_buf(), Duration::ZERO) + LocalDisk::cleanup_stale_tmp_objects_with_expiry(dir.path().to_path_buf(), &publication_root, Duration::ZERO) .await .expect("stale tmp directory should move to trash"); assert!(!stale_dir.exists(), "stale tmp directory should be moved away"); @@ -11404,9 +12009,735 @@ mod test { ); } + #[cfg(windows)] + #[tokio::test] + async fn windows_rename_data_non_inline_retains_destination_identity_across_publications() { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created"); + let bucket = "windows-non-inline-identity-bucket"; + let object = "prefix/non-inline-object"; + let tmp_object = "windows-non-inline-identity-stage"; + let version_id = Uuid::parse_str("99999999-1111-2222-3333-aaaaaaaaaaaa").expect("version id should parse"); + let old_data_dir = Uuid::parse_str("99999999-7777-8888-9999-cccccccccccc").expect("old data dir should parse"); + let data_dir = Uuid::parse_str("99999999-4444-5555-6666-bbbbbbbbbbbb").expect("data dir should parse"); + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + + let object_dir = disk + .get_object_path(bucket, object) + .expect("destination object path should resolve"); + let old_data_path = object_dir.join(old_data_dir.to_string()); + fs::create_dir_all(&old_data_path) + .await + .expect("old data directory should be created"); + fs::write(old_data_path.join("part.1"), b"old-payload") + .await + .expect("old part should be written"); + let old_meta = test_meta(test_file_info(object, version_id, Some(old_data_dir), None)); + fs::write(object_dir.join(STORAGE_FORMAT_FILE), &old_meta) + .await + .expect("old metadata should be written"); + + let staged_part = disk + .get_object_path(RUSTFS_META_TMP_BUCKET, &format!("{tmp_object}/{data_dir}/part.1")) + .expect("staged part path should resolve"); + fs::create_dir_all(staged_part.parent().expect("staged part should have a parent")) + .await + .expect("staged data directory should be created"); + fs::write(&staged_part, b"payload") + .await + .expect("staged part should be written"); + + let replacement_dir = disk + .get_object_path(bucket, "prefix/replacement-object") + .expect("replacement object path should resolve"); + let staging_parent = disk + .get_object_path(RUSTFS_META_TMP_BUCKET, tmp_object) + .expect("staging parent should resolve"); + let replacement_staging_parent = disk + .get_object_path(RUSTFS_META_TMP_BUCKET, "replacement-non-inline-stage") + .expect("replacement staging parent should resolve"); + let staged_metadata = staging_parent.join(STORAGE_FORMAT_FILE); + let replacement_staged_metadata = staging_parent.join("replacement-xl.meta"); + let object_dir_for_hook = object_dir.clone(); + let replacement_dir_for_hook = replacement_dir.clone(); + let staging_parent_for_hook = staging_parent.clone(); + let replacement_staging_parent_for_hook = replacement_staging_parent.clone(); + let staged_metadata_for_hook = staged_metadata.clone(); + let replacement_staged_metadata_for_hook = replacement_staged_metadata.clone(); + set_rename_data_after_first_publication(object, move || { + std::fs::rename(&object_dir_for_hook, &replacement_dir_for_hook) + .expect_err("the destination object identity must remain pinned until xl.meta commits"); + std::fs::rename(&staging_parent_for_hook, &replacement_staging_parent_for_hook) + .expect_err("the staging identity must remain pinned across data and xl.meta publication"); + std::fs::rename(&staged_metadata_for_hook, &replacement_staged_metadata_for_hook) + .expect_err("the prepared xl.meta entry must not be replaceable after data publication"); + }); + + let fi = test_file_info(object, version_id, Some(data_dir), None); + disk.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, fi, bucket, object) + .await + .expect("non-inline rename_data should commit"); + + assert!(!replacement_dir.exists(), "the destination object directory must not be replaced"); + assert!(staging_parent.exists(), "the guarded staging parent must retain its identity"); + assert!( + !replacement_staging_parent.exists(), + "the staging parent must not be replaced between data and metadata publication" + ); + assert!( + !replacement_staged_metadata.exists(), + "the prepared metadata source must remain the committed entry" + ); + assert_eq!( + fs::read(object_dir.join(data_dir.to_string()).join("part.1")) + .await + .expect("published part should be readable"), + b"payload" + ); + assert!( + object_dir.join(STORAGE_FORMAT_FILE).exists(), + "metadata must publish into the pinned object directory" + ); + assert_eq!( + fs::read(old_data_path.join(STORAGE_FORMAT_FILE_BACKUP)) + .await + .expect("rollback metadata should be written while the destination guard is held"), + old_meta + ); + } + + #[cfg(windows)] + #[tokio::test] + async fn windows_rename_data_replaces_hard_linked_legacy_destination_metadata() { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created"); + let bucket = "windows-hard-linked-destination-bucket"; + let object = "prefix/inline-object"; + let tmp_object = "windows-hard-linked-destination-stage"; + let version_id = Uuid::parse_str("aaaaaaaa-7777-8888-9999-bbbbbbbbbbbb").expect("version id should parse"); + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + + let object_dir = disk + .get_object_path(bucket, object) + .expect("destination object path should resolve"); + fs::create_dir_all(&object_dir) + .await + .expect("destination object directory should be created"); + let destination = object_dir.join(STORAGE_FORMAT_FILE); + let old_meta = test_meta(test_file_info(object, version_id, None, Some(Bytes::from_static(b"old-inline-payload")))); + fs::write(&destination, &old_meta) + .await + .expect("destination metadata should be written"); + let legacy_backup = disk + .get_object_path(RUSTFS_META_TMP_BUCKET, &format!("legacy-inline-rollback/{STORAGE_FORMAT_FILE_BACKUP}")) + .expect("legacy rollback backup path should resolve"); + fs::create_dir_all(legacy_backup.parent().expect("legacy rollback backup should have a parent")) + .await + .expect("legacy rollback directory should be created"); + std::fs::hard_link(&destination, &legacy_backup).expect("legacy rollback backup hard link should be created"); + + let new_fi = test_file_info(object, version_id, None, Some(Bytes::from_static(b"new-inline-payload"))); + disk.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, new_fi, bucket, object) + .await + .expect("a legacy hard-linked destination must be safely replaced"); + + assert_eq!( + fs::read(&legacy_backup) + .await + .expect("legacy rollback backup should remain readable"), + old_meta, + "replacing the destination must not mutate its legacy rollback backup" + ); + let raw = fs::read(&destination).await.expect("published metadata should be readable"); + assert_ne!(raw, old_meta, "the new metadata must replace the legacy hard-linked destination"); + FileMeta::load(&raw) + .expect("published metadata should parse") + .find_version(Some(version_id)) + .expect("published metadata must contain the committed version"); + } + + #[cfg(windows)] + #[tokio::test] + async fn windows_rename_data_supersedes_a_hard_linked_rollback_backup() { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created"); + let bucket = "windows-hard-linked-backup-bucket"; + let object = "prefix/non-inline-object"; + let tmp_object = "windows-hard-linked-backup-stage"; + let version_id = Uuid::parse_str("cccccccc-7777-8888-9999-dddddddddddd").expect("version id should parse"); + let old_data_dir = Uuid::parse_str("eeeeeeee-1111-2222-3333-aaaaaaaaaaaa").expect("old data dir should parse"); + let new_data_dir = Uuid::parse_str("ffffffff-4444-5555-6666-bbbbbbbbbbbb").expect("new data dir should parse"); + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + + let object_dir = disk + .get_object_path(bucket, object) + .expect("destination object path should resolve"); + let old_data_path = object_dir.join(old_data_dir.to_string()); + fs::create_dir_all(&old_data_path) + .await + .expect("old data directory should be created"); + fs::write(old_data_path.join("part.1"), b"old-payload") + .await + .expect("old part should be written"); + let old_meta = test_meta(test_file_info(object, version_id, Some(old_data_dir), None)); + fs::write(object_dir.join(STORAGE_FORMAT_FILE), &old_meta) + .await + .expect("old metadata should be written"); + + let victim = dir.path().join("hard-link-victim-backup"); + let victim_bytes = b"must-not-be-truncated"; + fs::write(&victim, victim_bytes) + .await + .expect("backup victim should be written"); + let backup_path = old_data_path.join(STORAGE_FORMAT_FILE_BACKUP); + std::fs::hard_link(&victim, &backup_path).expect("rollback backup hard link should be created"); + + let staged_part = disk + .get_object_path(RUSTFS_META_TMP_BUCKET, &format!("{tmp_object}/{new_data_dir}/part.1")) + .expect("staged part path should resolve"); + fs::create_dir_all(staged_part.parent().expect("staged part should have a parent")) + .await + .expect("staged data directory should be created"); + fs::write(&staged_part, b"new-payload") + .await + .expect("staged part should be written"); + + let new_fi = test_file_info(object, version_id, Some(new_data_dir), None); + disk.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, new_fi, bucket, object) + .await + .expect("the rollback backup entry should be safely superseded"); + + assert_eq!( + fs::read(&victim).await.expect("backup victim should remain readable"), + victim_bytes, + "publishing the rollback backup must not truncate another hard link" + ); + assert_eq!( + fs::read(&backup_path).await.expect("rollback backup should be readable"), + old_meta, + "the superseded backup entry must contain the exact previous metadata" + ); + } + + #[cfg(windows)] + #[tokio::test] + async fn windows_rename_data_inline_publishes_via_guarded_rename() { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created"); + let bucket = "windows-inline-bucket"; + let object = "prefix/inline-object"; + let tmp_object = "windows-inline-stage"; + let version_id = Uuid::parse_str("aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb").expect("version id should parse"); + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + let source = disk + .get_object_path(RUSTFS_META_TMP_BUCKET, &format!("{tmp_object}/{STORAGE_FORMAT_FILE}")) + .expect("staged metadata path should resolve"); + let destination = disk + .get_object_path(bucket, &format!("{object}/{STORAGE_FORMAT_FILE}")) + .expect("destination metadata path should resolve"); + let replacement_source = source.with_file_name("replacement-xl.meta"); + let fi = test_file_info(object, version_id, None, Some(Bytes::from_static(b"inline-payload"))); + let source_pinned_before_write = Arc::new(AtomicBool::new(false)); + let source_pinned_before_write_in_hook = Arc::clone(&source_pinned_before_write); + let source_for_hook = source.clone(); + let replacement_source_for_hook = replacement_source.clone(); + os::windows_rename_test_hooks::install_before_source_write(&source, move || { + source_pinned_before_write_in_hook.store(true, Ordering::Release); + std::fs::rename(&source_for_hook, &replacement_source_for_hook) + .expect_err("the staged metadata entry must be pinned before its first write"); + std::fs::OpenOptions::new() + .write(true) + .open(&source_for_hook) + .expect_err("the staged metadata entry must reject a second writer before its first write"); + }); + let guarded_commit_seen = Arc::new(AtomicBool::new(false)); + let guarded_commit_seen_in_hook = Arc::clone(&guarded_commit_seen); + os::windows_rename_test_hooks::install_before_publication(&destination, move || { + guarded_commit_seen_in_hook.store(true, Ordering::Release); + }); + + disk.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, fi, bucket, object) + .await + .expect("inline metadata must publish after its writer is closed"); + + assert!( + source_pinned_before_write.load(Ordering::Acquire), + "the production inline writer must pin the staged metadata entry before writing" + ); + assert!( + guarded_commit_seen.load(Ordering::Acquire), + "the production inline commit must use guarded handle-relative publication" + ); + assert!(!source.exists(), "successful publication must remove the staged metadata path"); + assert!( + !replacement_source.exists(), + "the pinned staged metadata entry must not be replaceable before its first write" + ); + let raw = std::fs::read(&destination).expect("read published metadata"); + let metadata = FileMeta::load(&raw).expect("published metadata must parse"); + metadata + .find_version(Some(version_id)) + .expect("published metadata must contain the committed version"); + } + + #[cfg(windows)] + #[tokio::test] + async fn windows_rename_data_inline_retains_destination_identity_across_backup_and_commit() { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created"); + let bucket = "windows-inline-identity-bucket"; + let object = "prefix/inline-object"; + let tmp_object = "windows-inline-identity-stage"; + let version_id = Uuid::parse_str("aaaaaaaa-4444-5555-6666-bbbbbbbbbbbb").expect("version id should parse"); + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + + let object_dir = disk + .get_object_path(bucket, object) + .expect("destination object path should resolve"); + fs::create_dir_all(&object_dir) + .await + .expect("destination object directory should be created"); + let old_fi = test_file_info(object, version_id, None, Some(Bytes::from_static(b"old-inline-payload"))); + let old_meta = test_meta(old_fi); + fs::write(object_dir.join(STORAGE_FORMAT_FILE), &old_meta) + .await + .expect("old metadata should be written"); + + let replacement_dir = disk + .get_object_path(bucket, "prefix/replacement-inline-object") + .expect("replacement object path should resolve"); + let staging_parent = disk + .get_object_path(RUSTFS_META_TMP_BUCKET, tmp_object) + .expect("staging parent should resolve"); + let staged_metadata = staging_parent.join(STORAGE_FORMAT_FILE); + let replacement_staged_metadata = staging_parent.join("replacement-xl.meta"); + let object_dir_for_hook = object_dir.clone(); + let replacement_dir_for_hook = replacement_dir.clone(); + let staged_metadata_for_hook = staged_metadata.clone(); + let replacement_staged_metadata_for_hook = replacement_staged_metadata.clone(); + set_rename_data_after_first_publication(object, move || { + std::fs::rename(&object_dir_for_hook, &replacement_dir_for_hook) + .expect_err("the destination object identity must remain pinned after publishing its rollback backup"); + std::fs::rename(&staged_metadata_for_hook, &replacement_staged_metadata_for_hook) + .expect_err("the prepared inline xl.meta must not be replaceable after backup publication"); + }); + + let new_fi = test_file_info(object, version_id, None, Some(Bytes::from_static(b"new-inline-payload"))); + disk.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, new_fi, bucket, object) + .await + .expect("inline rename_data should commit"); + + assert!(!replacement_dir.exists(), "the destination object directory must not be replaced"); + assert!( + !replacement_staged_metadata.exists(), + "the prepared inline metadata source must remain the committed entry" + ); + let raw = fs::read(object_dir.join(STORAGE_FORMAT_FILE)) + .await + .expect("published metadata should be readable"); + assert_ne!(raw, old_meta, "the new metadata must replace the old inline version"); + let metadata = FileMeta::load(&raw).expect("published metadata should parse"); + metadata + .find_version(Some(version_id)) + .expect("published metadata must contain the committed version"); + } + + #[cfg(windows)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn windows_cancelled_destination_preparation_releases_waiter_but_retains_volume_guard() { + use std::sync::{Arc, mpsc}; + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created")); + let bucket = "windows-cancelled-preparation-bucket"; + let object = "prefix/inline-object"; + let tmp_object = "windows-cancelled-preparation-stage"; + let version_id = Uuid::parse_str("cccccccc-1111-2222-3333-dddddddddddd").expect("version id should parse"); + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + let object_dir = disk + .get_object_path(bucket, object) + .expect("destination object path should resolve"); + let destination = object_dir.join(STORAGE_FORMAT_FILE); + let fi = test_file_info(object, version_id, None, Some(Bytes::from_static(b"inline-payload"))); + let _mode = durability_mode_override::set(DurabilityMode::Relaxed); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + set_destination_commit_directory_preparation(&object_dir, move || { + entered_tx.send(()).expect("signal destination preparation entry"); + release_rx.recv().expect("wait until cancellation has been observed"); + }); + + let operation_disk = Arc::clone(&disk); + let rename = tokio::spawn(async move { + operation_disk + .rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, fi, bucket, object) + .await + }); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(10))) + .await + .expect("destination preparation waiter should run") + .expect("rename_data must reach destination preparation"); + + rename.abort(); + let cancellation = tokio::time::timeout(Duration::from_secs(1), rename) + .await + .expect("the async waiter should observe cancellation without waiting for destination preparation") + .expect_err("the aborted rename task should be cancelled"); + assert!(cancellation.is_cancelled(), "the rename waiter should report cancellation"); + let volume_lock = os::disk_volume_mutation_lock(&disk.root, bucket); + let volume_guard_released_early = Arc::clone(&volume_lock).try_write_owned().is_ok(); + release_tx + .send(()) + .expect("release destination preparation after cancellation"); + + assert!( + !volume_guard_released_early, + "cancellation must not release the volume guard while destination preparation can still mutate the namespace" + ); + let _exclusive = tokio::time::timeout(Duration::from_secs(5), volume_lock.write_owned()) + .await + .expect("volume guard must be released after cancelled destination preparation finishes"); + assert!( + !destination.exists(), + "cancellation during preparation must prevent the outer transaction from publishing metadata" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn windows_and_unix_cancelled_staged_metadata_write_serializes_same_object_retry() { + use std::sync::{Arc, mpsc}; + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created")); + let bucket = "cancelled-staged-metadata-bucket"; + let object = "prefix/non-inline-object"; + let tmp_object = "cancelled-staged-metadata-stage"; + let version_id = Uuid::parse_str("dddddddd-1111-2222-3333-eeeeeeeeeeee").expect("version id should parse"); + let data_dir = Uuid::parse_str("ffffffff-1111-2222-3333-aaaaaaaaaaaa").expect("data dir should parse"); + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + + let staged_part = disk + .get_object_path(RUSTFS_META_TMP_BUCKET, &format!("{tmp_object}/{data_dir}/part.1")) + .expect("staged data path should resolve"); + fs::create_dir_all(staged_part.parent().expect("staged part should have a parent")) + .await + .expect("staged data directory should be created"); + fs::write(&staged_part, b"new-payload") + .await + .expect("staged part should be written"); + let staged_metadata = disk + .get_object_path(RUSTFS_META_TMP_BUCKET, &format!("{tmp_object}/{STORAGE_FORMAT_FILE}")) + .expect("staged metadata path should resolve"); + + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + set_owned_file_write_before_open(&staged_metadata, move || { + entered_tx.send(()).expect("signal staged metadata write entry"); + release_rx.recv().expect("wait until cancellation has been observed"); + }); + + let mut cancelled_fi = test_file_info(object, version_id, Some(data_dir), None); + cancelled_fi + .metadata + .insert("test-generation".to_string(), "cancelled".to_string()); + let cancelled_disk = Arc::clone(&disk); + let cancelled = tokio::spawn(async move { + cancelled_disk + .rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, cancelled_fi, bucket, object) + .await + }); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(10))) + .await + .expect("staged metadata waiter should run") + .expect("rename_data must reach the staged metadata write"); + cancelled.abort(); + let cancellation = tokio::time::timeout(Duration::from_secs(1), cancelled) + .await + .expect("the async waiter should observe cancellation without waiting for the staged writer") + .expect_err("the aborted rename task should be cancelled"); + assert!(cancellation.is_cancelled(), "the rename waiter should report cancellation"); + + let mut retry_fi = test_file_info(object, version_id, Some(data_dir), None); + retry_fi.metadata.insert("test-generation".to_string(), "retry".to_string()); + let retry_disk = Arc::clone(&disk); + let mut retry = tokio::spawn(async move { + retry_disk + .rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, retry_fi, bucket, object) + .await + }); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut retry).await.is_err(), + "the retry must wait while the cancelled staged writer still owns the object namespace" + ); + release_tx.send(()).expect("release cancelled staged metadata writer"); + tokio::time::timeout(Duration::from_secs(10), retry) + .await + .expect("retry should finish after the cancelled writer releases the namespace") + .expect("retry task should not panic") + .expect("same-object retry should commit successfully"); + + let destination_metadata = disk + .get_object_path(bucket, &format!("{object}/{STORAGE_FORMAT_FILE}")) + .expect("destination metadata path should resolve"); + let raw = fs::read(destination_metadata) + .await + .expect("retried metadata should be readable"); + let metadata = FileMeta::load(&raw).expect("retried metadata should parse"); + let (_, version) = metadata + .find_version(Some(version_id)) + .expect("retried metadata should contain the requested version"); + assert_eq!( + version + .object + .expect("non-inline version should contain object metadata") + .meta_user + .get("test-generation") + .map(String::as_str), + Some("retry"), + "the cancelled writer must not overwrite metadata committed by the retry" + ); + } + + #[cfg(windows)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn windows_cancelled_inline_publication_releases_waiter_but_retains_volume_guard() { + use std::sync::{Arc, mpsc}; + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created")); + let bucket = "windows-cancelled-inline-bucket"; + let object = "prefix/inline-object"; + let tmp_object = "windows-cancelled-inline-stage"; + let version_id = Uuid::parse_str("bbbbbbbb-1111-2222-3333-cccccccccccc").expect("version id should parse"); + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + let destination = disk + .get_object_path(bucket, &format!("{object}/{STORAGE_FORMAT_FILE}")) + .expect("destination metadata path should resolve"); + let fi = test_file_info(object, version_id, None, Some(Bytes::from_static(b"inline-payload"))); + let _mode = durability_mode_override::set(DurabilityMode::Relaxed); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + os::windows_rename_test_hooks::install_before_publication(&destination, move || { + entered_tx.send(()).expect("signal publication hook entry"); + release_rx.recv().expect("wait until cancellation has been observed"); + }); + + let operation_disk = Arc::clone(&disk); + let rename = tokio::spawn(async move { + operation_disk + .rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, fi, bucket, object) + .await + }); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(10))) + .await + .expect("publication entry waiter should run") + .expect("publication must reach its guarded commit"); + + rename.abort(); + let cancellation = tokio::time::timeout(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 volume_lock = os::disk_volume_mutation_lock(&disk.root, bucket); + let volume_guard_released_early = Arc::clone(&volume_lock).try_write_owned().is_ok(); + release_tx.send(()).expect("release publication after cancellation"); + + assert!( + !volume_guard_released_early, + "cancellation must not release the volume guard while an inline publication can still commit" + ); + tokio::time::timeout(Duration::from_secs(5), async { + while !destination.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("publication should complete before the cancelled task releases its guard"); + let _exclusive = tokio::time::timeout(Duration::from_secs(5), volume_lock.write_owned()) + .await + .expect("volume guard must be released after publication finishes"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn windows_and_unix_cancelled_non_inline_backup_write_retains_its_volume_guard() { + use std::sync::{Arc, mpsc}; + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created")); + let bucket = "cancelled-backup-write-bucket"; + let object = "prefix/non-inline-object"; + let tmp_object = "cancelled-backup-write-stage"; + let version_id = Uuid::parse_str("aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb").expect("version id should parse"); + let old_data_dir = Uuid::parse_str("cccccccc-1111-2222-3333-dddddddddddd").expect("data dir should parse"); + let new_data_dir = Uuid::parse_str("eeeeeeee-1111-2222-3333-ffffffffffff").expect("data dir should parse"); + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + + let object_dir = disk + .get_object_path(bucket, object) + .expect("destination object path should resolve"); + let old_meta = test_meta(test_file_info(object, version_id, Some(old_data_dir), None)); + fs::create_dir_all(object_dir.join(old_data_dir.to_string())) + .await + .expect("old data directory should be created"); + fs::write(object_dir.join(STORAGE_FORMAT_FILE), old_meta.clone()) + .await + .expect("old metadata should be written"); + + let staged_part = disk + .get_object_path(RUSTFS_META_TMP_BUCKET, &format!("{tmp_object}/{new_data_dir}/part.1")) + .expect("staged data path should resolve"); + fs::create_dir_all(staged_part.parent().expect("staged part should have a parent")) + .await + .expect("staged data directory should be created"); + fs::write(&staged_part, b"new-payload") + .await + .expect("staged part should be written"); + + let backup_path = object_dir.join(old_data_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + set_owned_file_write_before_open(&backup_path, move || { + entered_tx.send(()).expect("signal backup write entry"); + release_rx.recv().expect("wait until cancellation has been observed"); + }); + + let operation_disk = Arc::clone(&disk); + let rename = tokio::spawn(async move { + operation_disk + .rename_data( + RUSTFS_META_TMP_BUCKET, + tmp_object, + test_file_info(object, version_id, Some(new_data_dir), None), + bucket, + object, + ) + .await + }); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(10))) + .await + .expect("backup write waiter should run") + .expect("rename_data must reach the rollback backup write"); + + rename.abort(); + let cancellation = tokio::time::timeout(Duration::from_secs(1), rename) + .await + .expect("the async waiter should observe cancellation without waiting for the backup write") + .expect_err("the aborted rename task should be cancelled"); + assert!(cancellation.is_cancelled(), "the rename waiter should report cancellation"); + let volume_lock = os::disk_volume_mutation_lock(&disk.root, bucket); + let volume_guard_released_early = Arc::clone(&volume_lock).try_write_owned().is_ok(); + release_tx.send(()).expect("release rollback backup write after cancellation"); + + assert!( + !volume_guard_released_early, + "cancellation must not release the volume guard while a rollback backup can still be written" + ); + let _exclusive = tokio::time::timeout(Duration::from_secs(5), volume_lock.write_owned()) + .await + .expect("volume guard must be released after the rollback backup write finishes"); + assert_eq!( + fs::read(&backup_path).await.expect("rollback backup should be readable"), + old_meta, + "the guarded write must preserve the exact old metadata" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn windows_and_unix_delete_volume_waits_for_in_flight_rename_data_commit() { + use std::sync::{Arc, mpsc}; + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created")); + let bucket = "delete-volume-commit-order-bucket"; + let object = "prefix/non-inline-object"; + let tmp_object = "delete-volume-commit-order-stage"; + let version_id = Uuid::parse_str("aaaaaaaa-2222-3333-4444-bbbbbbbbbbbb").expect("version id should parse"); + let data_dir = Uuid::parse_str("cccccccc-2222-3333-4444-dddddddddddd").expect("data dir should parse"); + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + + let staged_part = disk + .get_object_path(RUSTFS_META_TMP_BUCKET, &format!("{tmp_object}/{data_dir}/part.1")) + .expect("staged data path should resolve"); + fs::create_dir_all(staged_part.parent().expect("staged part should have a parent")) + .await + .expect("staged data directory should be created"); + fs::write(&staged_part, b"new-payload") + .await + .expect("staged part should be written"); + + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + set_rename_data_after_first_publication(object, move || { + entered_tx.send(()).expect("signal first publication"); + release_rx.recv().expect("wait while delete_volume is blocked"); + }); + + let rename_disk = Arc::clone(&disk); + let rename = tokio::spawn(async move { + rename_disk + .rename_data( + RUSTFS_META_TMP_BUCKET, + tmp_object, + test_file_info(object, version_id, Some(data_dir), None), + bucket, + object, + ) + .await + }); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(10))) + .await + .expect("publication entry waiter should run") + .expect("rename_data must publish its data before metadata commit"); + + let delete_disk = Arc::clone(&disk); + let mut delete = tokio::spawn(async move { delete_disk.delete_volume(bucket, false).await }); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut delete).await.is_err(), + "delete_volume must wait for the in-flight metadata commit" + ); + + release_tx.send(()).expect("release metadata commit"); + rename + .await + .expect("rename_data task should finish") + .expect("rename_data should commit successfully"); + let delete_err = tokio::time::timeout(Duration::from_secs(5), delete) + .await + .expect("delete_volume should finish after the commit") + .expect("delete_volume task should not panic") + .expect_err("the committed object must keep the bucket non-empty"); + assert_eq!(delete_err, DiskError::VolumeNotEmpty); + } + #[tokio::test] #[serial_test::serial(rename_data_deleted_bucket)] - async fn rename_data_non_inline_does_not_recreate_bucket_deleted_before_commit() { + async fn windows_and_unix_rename_data_non_inline_does_not_recreate_bucket_deleted_before_commit() { let dir = tempfile::tempdir().expect("temp dir should be created"); let endpoint = Endpoint::try_from(dir.path().to_string_lossy().as_ref()).expect("endpoint should parse"); let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created"); @@ -11442,7 +12773,7 @@ mod test { #[tokio::test] #[serial_test::serial(rename_data_deleted_bucket)] - async fn rename_data_inline_does_not_recreate_bucket_deleted_before_commit() { + async fn windows_and_unix_rename_data_inline_does_not_recreate_bucket_deleted_before_commit() { let dir = tempfile::tempdir().expect("temp dir should be created"); let endpoint = Endpoint::try_from(dir.path().to_string_lossy().as_ref()).expect("endpoint should parse"); let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created"); @@ -11923,6 +13254,7 @@ mod test { async fn test_rename_data_writes_old_metadata_backup_for_inline_overwrite() { use tempfile::tempdir; + let _mode = durability_mode_override::set(DurabilityMode::Strict); let dir = tempdir().expect("temp dir should be created"); let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created"); @@ -11961,6 +13293,14 @@ mod test { assert_eq!(resp.sign, Some(version_id.as_bytes().to_vec())); let backup_path = dst_object_dir.join(old_data_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP); assert!(backup_path.exists()); + assert!( + os::fsync_dir_recorder::was_fsynced(backup_path.parent().expect("backup must have a parent")), + "strict inline overwrite must persist the rollback backup directory entry" + ); + assert!( + os::fsync_dir_recorder::was_fsynced(&dst_object_dir), + "strict inline overwrite must persist the committed xl.meta directory entry" + ); // The rollback backup must contain the previous metadata bytes verbatim so // that undo_write can restore the prior committed object; guards the inline // backup write against truncation/corruption regressions. @@ -12238,7 +13578,7 @@ mod test { } #[tokio::test] - async fn test_rename_data_inline_post_commit_error_restores_old_metadata() { + async fn windows_and_unix_rename_data_inline_post_commit_error_restores_old_metadata() { use tempfile::tempdir; let dir = tempdir().expect("temp dir should be created"); @@ -12270,17 +13610,86 @@ mod test { set_rename_data_fail_after_metadata_commit(object); let new_fi = test_file_info(object, version_id, None, Some(Bytes::from_static(b"inline-new"))); - let result = disk + let err = disk .rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, new_fi, bucket, object) - .await; + .await + .expect_err("post-commit failure must be returned"); - assert!(result.is_err()); + assert!(matches!(err, DiskError::Io(ref io_err) if io_err.kind() == ErrorKind::Other)); let restored_meta = fs::read(dst_object_dir.join(STORAGE_FORMAT_FILE)) .await .expect("old metadata should still be readable"); assert_eq!(restored_meta, old_meta); } + #[tokio::test] + async fn windows_and_unix_rename_data_inline_post_commit_error_removes_fresh_metadata() { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created"); + let bucket = "fresh-inline-post-commit-bucket"; + let object = "fresh-inline-post-commit-object"; + let tmp_object = "tmp-fresh-inline-post-commit"; + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + + set_rename_data_fail_after_metadata_commit(object); + let fi = test_file_info(object, Uuid::new_v4(), None, Some(Bytes::from_static(b"inline"))); + let err = disk + .rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, fi, bucket, object) + .await + .expect_err("post-commit failure must reject a fresh inline object"); + + assert!(matches!(err, DiskError::Io(ref io_err) if io_err.kind() == ErrorKind::Other)); + assert!( + !dir.path().join(bucket).join(object).join(STORAGE_FORMAT_FILE).exists(), + "failed fresh inline commit must remove published metadata" + ); + } + + #[tokio::test] + async fn windows_and_unix_rename_data_non_inline_post_commit_error_removes_fresh_object() { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created"); + let bucket = "fresh-non-inline-post-commit-bucket"; + let object = "fresh-non-inline-post-commit-object"; + let tmp_object = "tmp-fresh-non-inline-post-commit"; + let data_dir = Uuid::new_v4(); + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + let staged_part = dir + .path() + .join(RUSTFS_META_TMP_BUCKET) + .join(tmp_object) + .join(data_dir.to_string()) + .join("part.1"); + fs::create_dir_all(staged_part.parent().expect("staged part should have a parent")) + .await + .expect("staged data directory should be created"); + fs::write(&staged_part, b"payload") + .await + .expect("staged part should be written"); + + set_rename_data_fail_after_metadata_commit(object); + let fi = test_file_info(object, Uuid::new_v4(), Some(data_dir), None); + let err = disk + .rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, fi, bucket, object) + .await + .expect_err("post-commit failure must reject a fresh non-inline object"); + + assert!(matches!(err, DiskError::Unexpected)); + let destination = dir.path().join(bucket).join(object); + assert!( + !destination.join(STORAGE_FORMAT_FILE).exists(), + "failed fresh non-inline commit must remove published metadata" + ); + assert!( + !destination.join(data_dir.to_string()).exists(), + "failed fresh non-inline commit must remove published data" + ); + } + #[tokio::test] async fn rename_delete_marker_post_commit_error_restores_other_version_metadata() { use tempfile::tempdir; @@ -12351,6 +13760,7 @@ mod test { use tempfile::tempdir; let dir = tempdir().expect("temp dir should be created"); + let publication_root = os::PublicationRoot::new(dir.path()).expect("publication root should open"); let object_dir = dir.path().join("bucket").join("obj"); let xl_path = object_dir.join(STORAGE_FORMAT_FILE); let rollback_dir = Uuid::new_v4(); @@ -12362,7 +13772,7 @@ mod test { .await .expect("backup should be written"); - restore_metadata_backup(&object_dir, &xl_path, rollback_dir) + restore_metadata_backup(&object_dir, &xl_path, rollback_dir, &publication_root) .await .expect("restore should succeed"); assert_eq!( @@ -12383,7 +13793,7 @@ mod test { .await .expect("part should be written"); - restore_metadata_backup(&object_dir, &xl_path, real_dir) + restore_metadata_backup(&object_dir, &xl_path, real_dir, &publication_root) .await .expect("restore should succeed"); assert!(real_path.join("part.1").exists(), "a real data dir must keep its parts"); @@ -12444,6 +13854,164 @@ mod test { } } + #[tokio::test] + async fn windows_and_unix_inline_rename_missing_staged_metadata_fails_without_replacing_destination() { + use tempfile::tempdir; + + let dir = tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created"); + let bucket = "bucket"; + let object = "missing-staged-inline-object"; + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + + let old_meta = test_meta(test_file_info(object, Uuid::new_v4(), None, Some(Bytes::from_static(b"old")))); + let dst_object_dir = dir.path().join(bucket).join(object); + fs::create_dir_all(&dst_object_dir) + .await + .expect("object dir should be created"); + fs::write(dst_object_dir.join(STORAGE_FORMAT_FILE), old_meta.clone()) + .await + .expect("old metadata should be written"); + + set_rename_data_remove_staged_meta_before_commit(object); + let err = disk + .rename_data( + RUSTFS_META_TMP_BUCKET, + "tmp-missing-staged-inline", + test_file_info(object, Uuid::new_v4(), None, Some(Bytes::from_static(b"new"))), + bucket, + object, + ) + .await + .expect_err("a missing staged xl.meta must fail publication"); + + assert_eq!(err, DiskError::FileNotFound); + assert_eq!( + fs::read(dst_object_dir.join(STORAGE_FORMAT_FILE)) + .await + .expect("old metadata should remain readable"), + old_meta + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn windows_and_unix_cancelled_inline_preparation_serializes_newer_commit() { + use std::sync::mpsc; + use tempfile::tempdir; + + let dir = tempdir().expect("temp dir should be created"); + let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse"); + let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created")); + let bucket = "bucket"; + let object = "cancelled-inline-preparation"; + let version_id = Uuid::parse_str("7c5d2fa4-84aa-47aa-8a8d-a8d121ef3579").expect("version id should parse"); + ensure_test_volume(&disk, bucket).await; + ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; + + let object_dir = dir.path().join(bucket).join(object); + fs::create_dir_all(&object_dir).await.expect("object dir should be created"); + let initial_meta = test_meta(test_file_info(object, version_id, None, Some(Bytes::from_static(b"v0")))); + fs::write(object_dir.join(STORAGE_FORMAT_FILE), &initial_meta) + .await + .expect("initial metadata should be written"); + + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + set_inline_preparation_before_backup(object, move || { + entered_tx.send(()).expect("signal blocked preparation"); + release_rx.recv().expect("wait for newer commits"); + }); + let cancelled_disk = Arc::clone(&disk); + let cancelled = tokio::spawn(async move { + cancelled_disk + .rename_data( + RUSTFS_META_TMP_BUCKET, + "cancelled-inline-stage", + test_file_info(object, version_id, None, Some(Bytes::from_static(b"cancelled"))), + bucket, + object, + ) + .await + }); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(10))) + .await + .expect("preparation waiter should run") + .expect("preparation must reach the backup hook"); + cancelled.abort(); + assert!(cancelled.await.expect_err("operation should be cancelled").is_cancelled()); + + let newer_disk = Arc::clone(&disk); + let mut newer = tokio::spawn(async move { + newer_disk + .rename_data( + RUSTFS_META_TMP_BUCKET, + "newer-inline-stage", + test_file_info(object, version_id, None, Some(Bytes::from_static(b"v1"))), + bucket, + object, + ) + .await + }); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut newer).await.is_err(), + "a newer commit must wait while cancelled preparation owns the object namespace" + ); + release_tx.send(()).expect("release cancelled preparation"); + tokio::time::timeout(Duration::from_secs(10), newer) + .await + .expect("newer commit should finish after cancelled preparation releases the namespace") + .expect("newer commit task should not panic") + .expect("newer inline metadata should commit"); + + let current_v1 = fs::read(object_dir.join(STORAGE_FORMAT_FILE)) + .await + .expect("newer metadata should be readable"); + let current_meta = FileMeta::load(¤t_v1).expect("newer metadata should parse"); + let rollback_dir = inline_metadata_rollback_dir(version_id, ¤t_meta); + let shared_backup = object_dir.join(rollback_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP); + + set_rename_data_fail_commit_rename(object); + disk.rename_data( + RUSTFS_META_TMP_BUCKET, + "latest-inline-stage", + test_file_info(object, version_id, None, Some(Bytes::from_static(b"v2"))), + bucket, + object, + ) + .await + .expect_err("the latest commit should stop after publishing its rollback backup"); + assert_eq!(fs::read(&shared_backup).await.expect("latest rollback backup should exist"), current_v1); + + let cancelled_backup = dir + .path() + .join(RUSTFS_META_TMP_BUCKET) + .join("cancelled-inline-stage") + .join(STORAGE_FORMAT_FILE_BACKUP); + assert_eq!( + fs::read(&cancelled_backup) + .await + .expect("cancelled preparation should finish its private backup"), + initial_meta, + "the cancelled preparation must retain the metadata snapshot it observed" + ); + + assert_eq!( + fs::read(&shared_backup) + .await + .expect("shared rollback backup should remain readable"), + current_v1, + "cancelled preparation must not overwrite the newer shared rollback backup" + ); + assert_eq!( + fs::read(object_dir.join(STORAGE_FORMAT_FILE)) + .await + .expect("current metadata should remain readable"), + current_v1 + ); + } + #[tokio::test] async fn rename_purge_pending_payload_stays_object_and_cleans_local_backup() { use tempfile::tempdir; @@ -12805,6 +14373,7 @@ mod test { let dir = tempdir().expect("temp dir should be created"); let src = dir.path().join("src"); let dst = dir.path().join("dst"); + let publication_root = os::PublicationRoot::new(dir.path()).expect("publication root should open"); fs::create_dir_all(&src).await.expect("source dir should be created"); fs::write(src.join("part.1"), b"live-data") .await @@ -12822,7 +14391,7 @@ mod test { .finish(); let _guard = tracing::subscriber::set_default(subscriber); - let err = rename_all_ignore_missing_source(&src, &dst, dir.path()) + let err = rename_all_ignore_missing_source(&src, &dst, dir.path(), &publication_root) .await .expect_err("a non-empty rollback destination must reject rename"); @@ -13268,7 +14837,7 @@ mod test { set_rename_data_fail_before_old_metadata_backup(object); let new_fi = test_file_info(object, version_id, Some(new_data_dir), None); let result = disk - .rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, new_fi, bucket, object) + .rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, new_fi.clone(), bucket, object) .await; assert!(result.is_err()); @@ -13276,7 +14845,42 @@ mod test { .await .expect("old metadata should still be readable"); assert_eq!(current_meta, old_meta); - assert!(!object_dir.join("object").join(STORAGE_FORMAT_FILE).exists()); + assert_eq!( + fs::read(tmp_data_dir.join("part.1")) + .await + .expect("failed commit must restore the staged data directory"), + b"new-data", + "recursive rollback must preserve the staged shard payload" + ); + assert!( + !object_dir.join(new_data_dir.to_string()).exists(), + "failed commit must not strand the new data directory at the destination" + ); + + disk.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, new_fi, bucket, object) + .await + .expect("the same staged request should succeed on its first retry"); + assert_eq!( + fs::read(object_dir.join(new_data_dir.to_string()).join("part.1")) + .await + .expect("retried commit should publish the staged shard"), + b"new-data" + ); + assert!(!tmp_data_dir.exists(), "successful retry must consume the restored staging directory"); + let committed_meta = fs::read(object_dir.join(STORAGE_FORMAT_FILE)) + .await + .expect("retried metadata should be readable"); + let committed_meta = FileMeta::load(&committed_meta).expect("retried metadata should parse"); + let (_, committed_version) = committed_meta + .find_version(Some(version_id)) + .expect("retried metadata should contain the requested version"); + assert_eq!( + committed_version + .object + .expect("retried non-inline version should contain object metadata") + .data_dir, + Some(new_data_dir) + ); } #[tokio::test] @@ -13381,8 +14985,9 @@ mod test { use tempfile::tempdir; let dir = tempdir().expect("operation should succeed"); + let publication_root = os::PublicationRoot::new(dir.path()).expect("publication root should open"); - LocalDisk::cleanup_tmp_on_startup(dir.path(), Arc::new(AtomicU32::new(0)), Arc::new(Notify::new())) + LocalDisk::cleanup_tmp_on_startup(dir.path(), &publication_root, Arc::new(AtomicU32::new(0)), Arc::new(Notify::new())) .await .expect("missing temporary directory should already be clean"); @@ -13394,6 +14999,7 @@ mod test { use tempfile::tempdir; let dir = tempdir().expect("operation should succeed"); + let publication_root = os::PublicationRoot::new(dir.path()).expect("publication root should open"); let tmp = LocalDisk::meta_path(dir.path(), RUSTFS_META_TMP_BUCKET); let leftover = tmp.join("leftover").join("data"); fs::create_dir_all(leftover.parent().expect("operation should succeed")) @@ -13401,7 +15007,7 @@ mod test { .expect("operation should succeed"); fs::write(&leftover, b"temporary").await.expect("operation should succeed"); - LocalDisk::cleanup_tmp_on_startup(dir.path(), Arc::new(AtomicU32::new(0)), Arc::new(Notify::new())) + LocalDisk::cleanup_tmp_on_startup(dir.path(), &publication_root, Arc::new(AtomicU32::new(0)), Arc::new(Notify::new())) .await .expect("operation should succeed"); @@ -13414,6 +15020,7 @@ mod test { use tempfile::tempdir; let dir = tempdir().expect("operation should succeed"); + let publication_root = os::PublicationRoot::new(dir.path()).expect("publication root should open"); let tmp = LocalDisk::meta_path(dir.path(), RUSTFS_META_TMP_BUCKET); let stale = tmp.join("stale").join("data"); let trash = LocalDisk::meta_path(dir.path(), RUSTFS_META_TMP_DELETED_BUCKET); @@ -13426,7 +15033,7 @@ mod test { // Backdate after the write above: creating stale/data refreshes the // scanned tmp/stale directory's mtime. backdate_mtime(&tmp.join("stale"), Duration::from_secs(10)); - LocalDisk::cleanup_stale_tmp_objects_with_expiry(dir.path().to_path_buf(), Duration::ZERO) + LocalDisk::cleanup_stale_tmp_objects_with_expiry(dir.path().to_path_buf(), &publication_root, Duration::ZERO) .await .expect("operation should succeed"); @@ -13442,6 +15049,7 @@ mod test { use tempfile::tempdir; let dir = tempdir().expect("operation should succeed"); + let publication_root = os::PublicationRoot::new(dir.path()).expect("publication root should open"); let tmp = LocalDisk::meta_path(dir.path(), RUSTFS_META_TMP_BUCKET); let fresh_dir = tmp.join("fresh").join("data"); let regular_file = tmp.join("note.txt"); @@ -13454,7 +15062,7 @@ mod test { fs::write(&fresh_dir, b"temporary").await.expect("operation should succeed"); fs::write(®ular_file, b"keep").await.expect("operation should succeed"); - LocalDisk::cleanup_stale_tmp_objects_with_expiry(dir.path().to_path_buf(), Duration::from_secs(60)) + LocalDisk::cleanup_stale_tmp_objects_with_expiry(dir.path().to_path_buf(), &publication_root, Duration::from_secs(60)) .await .expect("operation should succeed"); diff --git a/crates/ecstore/src/disk/os.rs b/crates/ecstore/src/disk/os.rs index 2280ec340..ccce48958 100644 --- a/crates/ecstore/src/disk/os.rs +++ b/crates/ecstore/src/disk/os.rs @@ -25,7 +25,9 @@ use std::{ sync::{Arc, LazyLock, Weak}, }; use tokio::fs; -use tokio::sync::{OwnedSemaphorePermit, RwLock, Semaphore, SemaphorePermit}; +use tokio::sync::{ + Mutex as AsyncMutex, OwnedMutexGuard, OwnedRwLockReadGuard, OwnedSemaphorePermit, RwLock, Semaphore, SemaphorePermit, +}; use tracing::warn; /// Check path length according to OS limits. @@ -90,6 +92,62 @@ pub(crate) mod fsync_dir_recorder { } } +#[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<()> { @@ -104,10 +162,18 @@ pub fn fsync_dir_std(dir: impl AsRef) -> io::Result<()> { Ok(()) } -/// Async wrapper around [`fsync_dir_std`]; runs the blocking fsync off the runtime. +/// Async wrapper around [`fsync_dir_std`]; runs the blocking Unix fsync off the runtime. pub async fn fsync_dir(dir: impl AsRef) -> io::Result<()> { - let dir = dir.as_ref().to_path_buf(); - tokio::task::spawn_blocking(move || fsync_dir_std(dir)).await? + #[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 @@ -125,6 +191,10 @@ static FILE_SYNC_PERMITS: LazyLock = LazyLock::new(|| Semaphore::new( 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 @@ -177,6 +247,47 @@ pub(crate) fn disk_volume_mutation_lock(root: &Path, volume: &str) -> Arc 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>, +} + +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, + }) +} + +/// 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), + }) +} + /// 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. @@ -376,7 +487,11 @@ fn sync_file(path: &Path) -> io::Result<()> { if _probe.as_ref().is_some_and(file_sync_probe::ActiveGuard::should_fail) { return Err(io::Error::other("injected file sync failure")); } - std::fs::File::open(path)?.sync_data() + #[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<()> { @@ -557,65 +672,610 @@ 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) + 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<()> { - match reliable_rename_inner(src_file_path, dst_file_path.as_ref(), base_dir, false).await { + 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 => 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, true).await + 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 parent_guard = match dst_file_path.as_ref().parent() { - Some(parent) => Some(mkdir_all_below_existing_base(parent, base_dir.as_ref()).await?), - None => None, - }; + 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 +} - let mut i = 0; - loop { - if let Err(e) = rename_into_existing_parent(src_file_path.as_ref(), dst_file_path.as_ref(), parent_guard.as_ref()) { - if should_retry_rename(&e, i) { - i += 1; - continue; - } - if warn_on_missing_source || e.kind() != io::ErrorKind::NotFound { - warn_reliable_rename_failure(src_file_path.as_ref(), dst_file_path.as_ref(), base_dir.as_ref(), &e); - } - return Err(e); +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 +} - break; +#[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}")))? +} + +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, @@ -649,7 +1309,164 @@ fn rename_into_existing_parent( renameat(&src_parent, src_name, dst_parent, dst_name).map_err(io::Error::from) } -#[cfg(not(unix))] +#[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, @@ -658,15 +1475,90 @@ fn rename_into_existing_parent( super::fs::rename_std(src_file_path, dst_file_path) } -async fn mkdir_all_below_existing_base(dir_path: &Path, base_dir: &Path) -> io::Result { - let dir_path = dir_path.to_path_buf(); - let base_dir = base_dir.to_path_buf(); +#[cfg(windows)] +#[derive(Clone)] +struct WindowsDirectoryHandle { + handle: Arc, +} - tokio::task::spawn_blocking(move || mkdir_all_below_existing_base_std(&dir_path, &base_dir)).await? +/// 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)] -pub(crate) type ExistingBaseDirectoryGuard = Vec; +#[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; @@ -674,33 +1566,967 @@ pub(crate) type ExistingBaseDirectoryGuard = Vec; #[cfg(all(not(unix), not(windows)))] pub(crate) type ExistingBaseDirectoryGuard = (); -#[cfg(windows)] -fn lock_windows_directory(path: &Path) -> io::Result { - use std::os::windows::fs::OpenOptionsExt; - use windows_sys::Win32::Storage::FileSystem::{ - FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, - FILE_SHARE_READ, FILE_SHARE_WRITE, - }; - - // Relative child publication requires write sharing on every guarded - // ancestor. Omitting delete sharing still prevents any directory in the - // resolved path from being renamed or removed before the commit finishes. - let file = std::fs::OpenOptions::new() - .read(true) - .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) - .open(path)?; - let handle = winapi_util::Handle::from_file(file); - let info = winapi_util::file::information(&handle)?; - if info.file_attributes() & u64::from(FILE_ATTRIBUTE_DIRECTORY) == 0 - || info.file_attributes() & u64::from(FILE_ATTRIBUTE_REPARSE_POINT) != 0 - { - return Err(io::Error::from(io::ErrorKind::NotADirectory)); - } - Ok(handle) +#[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) fn mkdir_all_below_existing_base_std(dir_path: &Path, base_dir: &Path) -> io::Result { +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"))?; @@ -715,6 +2541,7 @@ pub(crate) fn mkdir_all_below_existing_base_std(dir_path: &Path, base_dir: &Path #[cfg(unix)] { + let _ = publication_root; use rustix::fs::{Mode, OFlags, mkdirat, open, openat}; use rustix::io::Errno; @@ -742,27 +2569,74 @@ pub(crate) fn mkdir_all_below_existing_base_std(dir_path: &Path, base_dir: &Path #[cfg(windows)] { - let mut handles = vec![lock_windows_directory(base_dir)?]; - let mut current = base_dir.to_path_buf(); + 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; }; - current.push(component); - match std::fs::create_dir(¤t) { - Ok(()) => {} - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {} - Err(err) => return Err(err), - } - handles.push(lock_windows_directory(¤t)?); + 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); } - Ok(handles) + // 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; + let _ = (relative, publication_root); Err(io::Error::new( io::ErrorKind::Unsupported, "safe recursive directory creation is unavailable on this platform", @@ -780,8 +2654,8 @@ fn warn_reliable_rename_failure(src_file_path: &Path, dst_file_path: &Path, base /// Whether a failed `rename` in [`reliable_rename_inner`] should be retried. /// /// Only the first failure is retried, and `NotFound` is never retried: the -/// retry does not recreate the missing source or parent directory, so a second -/// attempt is guaranteed to fail identically. Skipping it spares speculative +/// 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 @@ -900,6 +2774,46 @@ mod tests { 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"); @@ -992,6 +2906,135 @@ mod tests { (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() { @@ -1046,6 +3089,23 @@ mod tests { 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"); @@ -1090,7 +3150,7 @@ mod tests { #[test] fn rename_retry_never_retries_not_found() { // NotFound is terminal for the retry loop: the retry does not recreate - // the missing source/parent, so a second rename would fail identically. + // 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)); @@ -1245,6 +3305,1168 @@ mod tests { .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() { @@ -1282,9 +4504,7 @@ mod tests { let base = temp_dir.path().join("bucket"); symlink(&outside, &base).expect("create symlinked base"); - mkdir_all_below_existing_base(&base.join("object"), &base) - .await - .expect_err("symlinked base must be rejected"); + 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"); } @@ -1301,9 +4521,7 @@ mod tests { std::fs::create_dir(&outside).expect("create outside directory"); symlink(&outside, base.join("linked")).expect("create symlink below base"); - mkdir_all_below_existing_base(&base.join("linked/object"), &base) - .await - .expect_err("symlink below base must be rejected"); + mkdir_all_below_existing_base_std(&base.join("linked/object"), &base).expect_err("symlink below base must be rejected"); assert!( !outside.join("object").exists(), @@ -1336,6 +4554,19 @@ mod tests { ); } + #[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() {