diff options
Diffstat (limited to 'rola-utils/functions/src/copy_with_temp_rename.rs')
| -rw-r--r-- | rola-utils/functions/src/copy_with_temp_rename.rs | 404 |
1 files changed, 202 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"); + } } |
