diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-06-18 00:34:39 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-06-18 00:34:39 +0800 |
| commit | dd9fa4b2104f7a19b4a364cf46b96b974c442cd1 (patch) | |
| tree | af2488af4dadd9872a18c945ea0960cd5860d03d /rola-utils/functions/src | |
| parent | 7ea51858189338cbda1a21dc5724d1a8ce3aedb9 (diff) | |
refactor: split copy_with_temp_rename into sync and async modules
Diffstat (limited to 'rola-utils/functions/src')
| -rw-r--r-- | rola-utils/functions/src/copy_with_temp_rename.rs | 404 | ||||
| -rw-r--r-- | rola-utils/functions/src/copy_with_temp_rename/async.rs | 222 | ||||
| -rw-r--r-- | rola-utils/functions/src/copy_with_temp_rename/sync.rs | 209 | ||||
| -rw-r--r-- | rola-utils/functions/src/lib.rs | 3 |
4 files changed, 636 insertions, 202 deletions
diff --git a/rola-utils/functions/src/copy_with_temp_rename.rs b/rola-utils/functions/src/copy_with_temp_rename.rs index cdbe3db..ede73f6 100644 --- a/rola-utils/functions/src/copy_with_temp_rename.rs +++ b/rola-utils/functions/src/copy_with_temp_rename.rs @@ -1,209 +1,209 @@ -use std::borrow::Cow; -use std::io; -use std::path::{Path, PathBuf}; +mod r#async; +mod sync; -/// Safely copies a file: first copies to a temporary file, then atomically replaces the destination. -pub fn copy_with_temp_rename(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> { - internal_copy_with_temp_rename(src.as_ref(), dst.as_ref()) -} +pub use r#async::*; +pub use sync::*; -fn internal_copy_with_temp_rename(src: &Path, dst: &Path) -> io::Result<()> { - // Check if source file exists to avoid later failures - if !src.exists() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!("source file does not exist: {}", src.display()), - )); - } - - // Canonicalize paths and compare; if same file, return early to avoid unnecessary copy - // Only canonicalize destination if it exists; if it doesn't exist, skip the comparison - let src_canonical = std::fs::canonicalize(src); - if dst.exists() { - let dst_canonical = std::fs::canonicalize(dst); - match (src_canonical, dst_canonical) { - (Ok(src_c), Ok(dst_c)) => { - if src_c == dst_c { - return Ok(()); - } - } - (Err(e), _) | (_, Err(e)) => { - return Err(io::Error::new( - io::ErrorKind::Other, - format!( - "failed to canonicalize paths (src: {}, dst: {}): {}", - src.display(), - dst.display(), - e - ), - )); - } - } - } else { - // dst doesn't exist yet, so it cannot be the same file as src - // If src canonicalization fails, propagate the error - let _ = src_canonical.map_err(|e| { - io::Error::new( - io::ErrorKind::Other, - format!( - "failed to canonicalize source path (src: {}): {}", - src.display(), - e - ), - ) - })?; - } - - // Ensure both source and destination are regular files, preventing accidental operations on directories - { - let src_meta = src.metadata().map_err(|e| { - io::Error::new( - e.kind(), - format!("failed to get source metadata for {}: {}", src.display(), e), - ) - })?; - if !src_meta.is_file() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("source is not a regular file: {}", src.display()), - )); - } - - if dst.exists() { - let dst_meta = dst.metadata().map_err(|e| { - io::Error::new( - e.kind(), - format!( - "failed to get destination metadata for {}: {}", - dst.display(), - e - ), - ) - })?; - if !dst_meta.is_file() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "destination exists and is not a regular file: {}", - dst.display() - ), - )); - } - } - } - - // Ensure the parent directory of the destination exists, creating it if necessary - if let Some(parent) = dst.parent() { - std::fs::create_dir_all(parent)?; - } - - // Create a temporary file in the destination directory as an intermediate write target - let temp_dir = dst.parent().unwrap_or_else(|| Path::new(".")); - let base = src - .file_name() - .map(|f| f.to_string_lossy()) - .unwrap_or_else(|| Cow::Borrowed("temp")); - let temp_path = atomic_create_temp(temp_dir, &base)?; - - // Copy source file contents to the temporary file; clean up on failure - if let Err(e) = std::fs::copy(src, &temp_path) { - let _ = std::fs::remove_file(&temp_path); - return Err(e); - } - - // Atomically replace the destination file; clean up temporary file on failure - if let Err(e) = atomic_rename(&temp_path, dst) { - let _ = std::fs::remove_file(&temp_path); - return Err(e); - } - - // Copy source file permissions to the destination - let _ = std::fs::set_permissions(dst, src.metadata()?.permissions()); - - Ok(()) -} +#[cfg(test)] +mod async_tests { + use tokio::io; + + use super::*; + use crate::rola_test_sandbox; + + #[tokio::test] + async fn copy_to_new_location() { + let sandbox = rola_test_sandbox("async_copy_to_new_location"); + let src = sandbox.join("src.txt"); + let dst = sandbox.join("dst.txt"); + tokio::fs::write(&src, b"hello").await.unwrap(); + + copy_with_temp_rename_async(&src, &dst).await.unwrap(); + let content = tokio::fs::read_to_string(&dst).await.unwrap(); + assert_eq!(content, "hello"); + } + + #[tokio::test] + async fn overwrite_existing_destination() { + let sandbox = rola_test_sandbox("async_overwrite_existing_dst"); + let src = sandbox.join("src.txt"); + let dst = sandbox.join("dst.txt"); + tokio::fs::write(&src, b"new content").await.unwrap(); + tokio::fs::write(&dst, b"old content").await.unwrap(); + + copy_with_temp_rename_async(&src, &dst).await.unwrap(); + let content = tokio::fs::read_to_string(&dst).await.unwrap(); + assert_eq!(content, "new content"); + } + + #[tokio::test] + async fn source_not_found() { + let sandbox = rola_test_sandbox("async_source_not_found"); + let src = sandbox.join("nonexistent.txt"); + let dst = sandbox.join("dst.txt"); + + let err = copy_with_temp_rename_async(&src, &dst).await.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[tokio::test] + async fn copy_same_file() { + let sandbox = rola_test_sandbox("async_copy_same_file"); + let file = sandbox.join("file.txt"); + tokio::fs::write(&file, b"content").await.unwrap(); + + copy_with_temp_rename_async(&file, &file).await.unwrap(); + let content = tokio::fs::read_to_string(&file).await.unwrap(); + assert_eq!(content, "content"); + } + + #[tokio::test] + async fn source_is_directory() { + let sandbox = rola_test_sandbox("async_source_is_directory"); + let src = sandbox.join("a_dir"); + let dst = sandbox.join("dst.txt"); + tokio::fs::create_dir(&src).await.unwrap(); + + let err = copy_with_temp_rename_async(&src, &dst).await.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + #[tokio::test] + async fn destination_is_directory() { + let sandbox = rola_test_sandbox("async_dest_is_directory"); + let src = sandbox.join("src.txt"); + let dst = sandbox.join("a_dir"); + tokio::fs::write(&src, b"content").await.unwrap(); + tokio::fs::create_dir(&dst).await.unwrap(); + + let err = copy_with_temp_rename_async(&src, &dst).await.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + #[tokio::test] + async fn create_parent_directory() { + let sandbox = rola_test_sandbox("async_create_parent_dir"); + let src = sandbox.join("src.txt"); + let dst = sandbox.join("sub/deep/dst.txt"); + tokio::fs::write(&src, b"hello").await.unwrap(); + + copy_with_temp_rename_async(&src, &dst).await.unwrap(); + assert!(tokio::fs::try_exists(&dst).await.unwrap()); + let content = tokio::fs::read_to_string(&dst).await.unwrap(); + assert_eq!(content, "hello"); + } + + #[tokio::test] + async fn content_is_atomic() { + let sandbox = rola_test_sandbox("async_content_is_atomic"); + let src = sandbox.join("src.txt"); + let dst = sandbox.join("dst.txt"); + tokio::fs::write(&src, b"final content").await.unwrap(); + tokio::fs::write(&dst, b"initial").await.unwrap(); + + copy_with_temp_rename_async(&src, &dst).await.unwrap(); -/// Atomically renames a path, replacing the destination if it exists. -fn atomic_rename(src: &Path, dst: &Path) -> io::Result<()> { - #[cfg(unix)] - { - std::fs::rename(src, dst) - } - - #[cfg(not(unix))] - { - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - - // Convert OsStr to a null-terminated UTF-16 vector - fn to_wide_null(os_str: &OsStr) -> Vec<u16> { - os_str.encode_wide().chain(Some(0)).collect() - } - - let src_wide = to_wide_null(src.as_os_str()); - let dst_wide = to_wide_null(dst.as_os_str()); - - // SAFETY: `src_wide` and `dst_wide` are valid, owned `Vec<u16>`s, - // the pointers returned by `as_ptr()` are valid for their lifetime and are null-terminated. - // Declares the Windows API MoveFileExW for atomic renaming. - unsafe extern "system" { - /// MOVEFILE_REPLACE_EXISTING = 1 - fn MoveFileExW( - lpExistingFileName: *const u16, - lpNewFileName: *const u16, - dwFlags: u32, - ) -> i32; - } - - const MOVEFILE_REPLACE_EXISTING: u32 = 1; - - // SAFETY: - // 1. `src_wide` and `dst_wide` are null-terminated UTF-16 strings, valid for their lifetime. - // 2. `MoveFileExW` is a Windows system API declared via extern "system", - // with correct calling convention and ABI. - // 3. Return value is checked; errors are handled via `last_os_error()`. - let ret = unsafe { - MoveFileExW( - src_wide.as_ptr(), - dst_wide.as_ptr(), - MOVEFILE_REPLACE_EXISTING, - ) - }; - - if ret == 0 { - Err(io::Error::last_os_error()) - } else { - Ok(()) - } + let content = tokio::fs::read_to_string(&dst).await.unwrap(); + assert_eq!(content, "final content"); } } -fn atomic_create_temp(dir: &Path, base: &str) -> io::Result<PathBuf> { - use std::fs::OpenOptions; - - // Generate temporary file names; first attempt without index, then incrementing to avoid conflicts - let make_name = |i: usize| { - let name = if i == 0 { - format!(".temp_{}", base) - } else { - format!(".temp_{}_{}", base, i) - }; - dir.join(name) - }; - - const MAX_ATTEMPTS: usize = 10000; - // Loop to create a new file; create_new ensures the file does not already exist - for i in 0..MAX_ATTEMPTS { - let path = make_name(i); - match OpenOptions::new().create_new(true).write(true).open(&path) { - Ok(_) => return Ok(path), - Err(ref e) if e.kind() == io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(e), - } - } - - Err(io::Error::new( - io::ErrorKind::Other, - "exceeded max attempts to create temp file", - )) +#[cfg(test)] +mod sync_tests { + use super::*; + use crate::rola_test_sandbox; + use std::{fs, io}; + + #[test] + fn copy_to_new_location() { + let sandbox = rola_test_sandbox("copy_to_new_location"); + let src = sandbox.join("src.txt"); + let dst = sandbox.join("dst.txt"); + fs::write(&src, b"hello").unwrap(); + + copy_with_temp_rename(&src, &dst).unwrap(); + assert_eq!(fs::read_to_string(&dst).unwrap(), "hello"); + } + + #[test] + fn overwrite_existing_destination() { + let sandbox = rola_test_sandbox("overwrite_existing_dst"); + let src = sandbox.join("src.txt"); + let dst = sandbox.join("dst.txt"); + fs::write(&src, b"new content").unwrap(); + fs::write(&dst, b"old content").unwrap(); + + copy_with_temp_rename(&src, &dst).unwrap(); + assert_eq!(fs::read_to_string(&dst).unwrap(), "new content"); + } + + #[test] + fn source_not_found() { + let sandbox = rola_test_sandbox("source_not_found"); + let src = sandbox.join("nonexistent.txt"); + let dst = sandbox.join("dst.txt"); + + let err = copy_with_temp_rename(&src, &dst).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn copy_same_file() { + let sandbox = rola_test_sandbox("copy_same_file"); + let file = sandbox.join("file.txt"); + fs::write(&file, b"content").unwrap(); + + copy_with_temp_rename(&file, &file).unwrap(); + assert_eq!(fs::read_to_string(&file).unwrap(), "content"); + } + + #[test] + fn source_is_directory() { + let sandbox = rola_test_sandbox("source_is_directory"); + let src = sandbox.join("a_dir"); + let dst = sandbox.join("dst.txt"); + fs::create_dir(&src).unwrap(); + + let err = copy_with_temp_rename(&src, &dst).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn destination_is_directory() { + let sandbox = rola_test_sandbox("dest_is_directory"); + let src = sandbox.join("src.txt"); + let dst = sandbox.join("a_dir"); + fs::write(&src, b"content").unwrap(); + fs::create_dir(&dst).unwrap(); + + let err = copy_with_temp_rename(&src, &dst).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn create_parent_directory() { + let sandbox = rola_test_sandbox("create_parent_dir"); + let src = sandbox.join("src.txt"); + let dst = sandbox.join("sub/deep/dst.txt"); + fs::write(&src, b"hello").unwrap(); + + copy_with_temp_rename(&src, &dst).unwrap(); + assert!(dst.exists()); + assert_eq!(fs::read_to_string(&dst).unwrap(), "hello"); + } + + #[test] + fn content_is_atomic() { + let sandbox = rola_test_sandbox("content_is_atomic"); + let src = sandbox.join("src.txt"); + let dst = sandbox.join("dst.txt"); + fs::write(&src, b"final content").unwrap(); + fs::write(&dst, b"initial").unwrap(); + + copy_with_temp_rename(&src, &dst).unwrap(); + + // After the operation, dst must contain exactly the source content + // (never partial/empty, thanks to temp+rename) + assert_eq!(fs::read_to_string(&dst).unwrap(), "final content"); + } } diff --git a/rola-utils/functions/src/copy_with_temp_rename/async.rs b/rola-utils/functions/src/copy_with_temp_rename/async.rs new file mode 100644 index 0000000..83275a1 --- /dev/null +++ b/rola-utils/functions/src/copy_with_temp_rename/async.rs @@ -0,0 +1,222 @@ +use std::borrow::Cow; +use std::path::{Path, PathBuf}; +use tokio::io; + +/// Safely copies a file: first copies to a temporary file, then atomically replaces the destination. +pub async fn copy_with_temp_rename_async( + src: impl AsRef<Path>, + dst: impl AsRef<Path>, +) -> io::Result<()> { + internal_copy_with_temp_rename(src.as_ref(), dst.as_ref()).await +} + +async fn internal_copy_with_temp_rename(src: &Path, dst: &Path) -> io::Result<()> { + // Check if source file exists to avoid later failures + if !tokio::fs::try_exists(src) + .await + .map_err(|e| io::Error::new(e.kind(), format!("failed to check source existence: {}", e)))? + { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("source file does not exist: {}", src.display()), + )); + } + + // Canonicalize paths and compare; if same file, return early to avoid unnecessary copy + // Only canonicalize destination if it exists; if it doesn't exist, skip the comparison + let src_canonical = tokio::fs::canonicalize(src).await; + let dst_exists = tokio::fs::try_exists(dst).await.unwrap_or(false); + if dst_exists { + let dst_canonical = tokio::fs::canonicalize(dst).await; + match (src_canonical, dst_canonical) { + (Ok(src_c), Ok(dst_c)) => { + if src_c == dst_c { + return Ok(()); + } + } + (Err(e), _) | (_, Err(e)) => { + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "failed to canonicalize paths (src: {}, dst: {}): {}", + src.display(), + dst.display(), + e + ), + )); + } + } + } else { + // dst doesn't exist yet, so it cannot be the same file as src + // If src canonicalization fails, propagate the error + let _ = src_canonical.map_err(|e| { + io::Error::new( + io::ErrorKind::Other, + format!( + "failed to canonicalize source path (src: {}): {}", + src.display(), + e + ), + ) + })?; + } + + // Ensure both source and destination are regular files, preventing accidental operations on directories + { + let src_meta = tokio::fs::metadata(src).await.map_err(|e| { + io::Error::new( + e.kind(), + format!("failed to get source metadata for {}: {}", src.display(), e), + ) + })?; + if !src_meta.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("source is not a regular file: {}", src.display()), + )); + } + + if dst_exists { + let dst_meta = tokio::fs::metadata(dst).await.map_err(|e| { + io::Error::new( + e.kind(), + format!( + "failed to get destination metadata for {}: {}", + dst.display(), + e + ), + ) + })?; + if !dst_meta.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "destination exists and is not a regular file: {}", + dst.display() + ), + )); + } + } + } + + // Ensure the parent directory of the destination exists, creating it if necessary + if let Some(parent) = dst.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + // Create a temporary file in the destination directory as an intermediate write target + let temp_dir = dst.parent().unwrap_or_else(|| Path::new(".")); + let base = src + .file_name() + .map(|f| f.to_string_lossy()) + .unwrap_or_else(|| Cow::Borrowed("temp")); + let temp_path = atomic_create_temp(temp_dir, &base).await?; + + // Copy source file contents to the temporary file; clean up on failure + if let Err(e) = tokio::fs::copy(src, &temp_path).await { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(e); + } + + // Atomically replace the destination file; clean up temporary file on failure + if let Err(e) = atomic_rename(&temp_path, dst).await { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(e); + } + + // Copy source file permissions to the destination + let src_meta = tokio::fs::metadata(src).await?; + let _ = tokio::fs::set_permissions(dst, src_meta.permissions()).await; + + Ok(()) +} + +/// Atomically renames a path, replacing the destination if it exists. +async fn atomic_rename(src: &Path, dst: &Path) -> io::Result<()> { + #[cfg(unix)] + { + tokio::fs::rename(src, dst).await + } + + #[cfg(not(unix))] + { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + + // Convert OsStr to a null-terminated UTF-16 vector + fn to_wide_null(os_str: &OsStr) -> Vec<u16> { + os_str.encode_wide().chain(Some(0)).collect() + } + + let src_wide = to_wide_null(src.as_os_str()); + let dst_wide = to_wide_null(dst.as_os_str()); + + // SAFETY: `src_wide` and `dst_wide` are valid, owned `Vec<u16>`s, + // the pointers returned by `as_ptr()` are valid for their lifetime and are null-terminated. + // Declares the Windows API MoveFileExW for atomic renaming. + unsafe extern "system" { + /// MOVEFILE_REPLACE_EXISTING = 1 + fn MoveFileExW( + lpExistingFileName: *const u16, + lpNewFileName: *const u16, + dwFlags: u32, + ) -> i32; + } + + const MOVEFILE_REPLACE_EXISTING: u32 = 1; + + // SAFETY: + // 1. `src_wide` and `dst_wide` are null-terminated UTF-16 strings, valid for their lifetime. + // 2. `MoveFileExW` is a Windows system API declared via extern "system", + // with correct calling convention and ABI. + // 3. Return value is checked; errors are handled via `last_os_error()`. + let ret = unsafe { + MoveFileExW( + src_wide.as_ptr(), + dst_wide.as_ptr(), + MOVEFILE_REPLACE_EXISTING, + ) + }; + + if ret == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } +} + +async fn atomic_create_temp(dir: &Path, base: &str) -> io::Result<PathBuf> { + use tokio::fs::OpenOptions; + + // Generate temporary file names; first attempt without index, then incrementing to avoid conflicts + let make_name = |i: usize| { + let name = if i == 0 { + format!(".temp_{}", base) + } else { + format!(".temp_{}_{}", base, i) + }; + dir.join(name) + }; + + const MAX_ATTEMPTS: usize = 10000; + // Loop to create a new file; create_new ensures the file does not already exist + for i in 0..MAX_ATTEMPTS { + let path = make_name(i); + match OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + .await + { + Ok(_) => return Ok(path), + Err(ref e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + + Err(io::Error::new( + io::ErrorKind::Other, + "exceeded max attempts to create temp file", + )) +} diff --git a/rola-utils/functions/src/copy_with_temp_rename/sync.rs b/rola-utils/functions/src/copy_with_temp_rename/sync.rs new file mode 100644 index 0000000..cdbe3db --- /dev/null +++ b/rola-utils/functions/src/copy_with_temp_rename/sync.rs @@ -0,0 +1,209 @@ +use std::borrow::Cow; +use std::io; +use std::path::{Path, PathBuf}; + +/// Safely copies a file: first copies to a temporary file, then atomically replaces the destination. +pub fn copy_with_temp_rename(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> { + internal_copy_with_temp_rename(src.as_ref(), dst.as_ref()) +} + +fn internal_copy_with_temp_rename(src: &Path, dst: &Path) -> io::Result<()> { + // Check if source file exists to avoid later failures + if !src.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("source file does not exist: {}", src.display()), + )); + } + + // Canonicalize paths and compare; if same file, return early to avoid unnecessary copy + // Only canonicalize destination if it exists; if it doesn't exist, skip the comparison + let src_canonical = std::fs::canonicalize(src); + if dst.exists() { + let dst_canonical = std::fs::canonicalize(dst); + match (src_canonical, dst_canonical) { + (Ok(src_c), Ok(dst_c)) => { + if src_c == dst_c { + return Ok(()); + } + } + (Err(e), _) | (_, Err(e)) => { + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "failed to canonicalize paths (src: {}, dst: {}): {}", + src.display(), + dst.display(), + e + ), + )); + } + } + } else { + // dst doesn't exist yet, so it cannot be the same file as src + // If src canonicalization fails, propagate the error + let _ = src_canonical.map_err(|e| { + io::Error::new( + io::ErrorKind::Other, + format!( + "failed to canonicalize source path (src: {}): {}", + src.display(), + e + ), + ) + })?; + } + + // Ensure both source and destination are regular files, preventing accidental operations on directories + { + let src_meta = src.metadata().map_err(|e| { + io::Error::new( + e.kind(), + format!("failed to get source metadata for {}: {}", src.display(), e), + ) + })?; + if !src_meta.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("source is not a regular file: {}", src.display()), + )); + } + + if dst.exists() { + let dst_meta = dst.metadata().map_err(|e| { + io::Error::new( + e.kind(), + format!( + "failed to get destination metadata for {}: {}", + dst.display(), + e + ), + ) + })?; + if !dst_meta.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "destination exists and is not a regular file: {}", + dst.display() + ), + )); + } + } + } + + // Ensure the parent directory of the destination exists, creating it if necessary + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + + // Create a temporary file in the destination directory as an intermediate write target + let temp_dir = dst.parent().unwrap_or_else(|| Path::new(".")); + let base = src + .file_name() + .map(|f| f.to_string_lossy()) + .unwrap_or_else(|| Cow::Borrowed("temp")); + let temp_path = atomic_create_temp(temp_dir, &base)?; + + // Copy source file contents to the temporary file; clean up on failure + if let Err(e) = std::fs::copy(src, &temp_path) { + let _ = std::fs::remove_file(&temp_path); + return Err(e); + } + + // Atomically replace the destination file; clean up temporary file on failure + if let Err(e) = atomic_rename(&temp_path, dst) { + let _ = std::fs::remove_file(&temp_path); + return Err(e); + } + + // Copy source file permissions to the destination + let _ = std::fs::set_permissions(dst, src.metadata()?.permissions()); + + Ok(()) +} + +/// Atomically renames a path, replacing the destination if it exists. +fn atomic_rename(src: &Path, dst: &Path) -> io::Result<()> { + #[cfg(unix)] + { + std::fs::rename(src, dst) + } + + #[cfg(not(unix))] + { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + + // Convert OsStr to a null-terminated UTF-16 vector + fn to_wide_null(os_str: &OsStr) -> Vec<u16> { + os_str.encode_wide().chain(Some(0)).collect() + } + + let src_wide = to_wide_null(src.as_os_str()); + let dst_wide = to_wide_null(dst.as_os_str()); + + // SAFETY: `src_wide` and `dst_wide` are valid, owned `Vec<u16>`s, + // the pointers returned by `as_ptr()` are valid for their lifetime and are null-terminated. + // Declares the Windows API MoveFileExW for atomic renaming. + unsafe extern "system" { + /// MOVEFILE_REPLACE_EXISTING = 1 + fn MoveFileExW( + lpExistingFileName: *const u16, + lpNewFileName: *const u16, + dwFlags: u32, + ) -> i32; + } + + const MOVEFILE_REPLACE_EXISTING: u32 = 1; + + // SAFETY: + // 1. `src_wide` and `dst_wide` are null-terminated UTF-16 strings, valid for their lifetime. + // 2. `MoveFileExW` is a Windows system API declared via extern "system", + // with correct calling convention and ABI. + // 3. Return value is checked; errors are handled via `last_os_error()`. + let ret = unsafe { + MoveFileExW( + src_wide.as_ptr(), + dst_wide.as_ptr(), + MOVEFILE_REPLACE_EXISTING, + ) + }; + + if ret == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } +} + +fn atomic_create_temp(dir: &Path, base: &str) -> io::Result<PathBuf> { + use std::fs::OpenOptions; + + // Generate temporary file names; first attempt without index, then incrementing to avoid conflicts + let make_name = |i: usize| { + let name = if i == 0 { + format!(".temp_{}", base) + } else { + format!(".temp_{}_{}", base, i) + }; + dir.join(name) + }; + + const MAX_ATTEMPTS: usize = 10000; + // Loop to create a new file; create_new ensures the file does not already exist + for i in 0..MAX_ATTEMPTS { + let path = make_name(i); + match OpenOptions::new().create_new(true).write(true).open(&path) { + Ok(_) => return Ok(path), + Err(ref e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + + Err(io::Error::new( + io::ErrorKind::Other, + "exceeded max attempts to create temp file", + )) +} diff --git a/rola-utils/functions/src/lib.rs b/rola-utils/functions/src/lib.rs index 3ba2507..72d5b9c 100644 --- a/rola-utils/functions/src/lib.rs +++ b/rola-utils/functions/src/lib.rs @@ -1,3 +1,6 @@ +mod copy_with_temp_rename; +pub use copy_with_temp_rename::*; + mod levenshtein_distance; pub use levenshtein_distance::*; |
