diff options
Diffstat (limited to 'rola-utils/functions/src/copy_with_temp_rename')
| -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 |
2 files changed, 431 insertions, 0 deletions
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", + )) +} |
