summaryrefslogtreecommitdiff
path: root/rola-utils/functions/src/copy_with_temp_rename.rs
diff options
context:
space:
mode:
Diffstat (limited to 'rola-utils/functions/src/copy_with_temp_rename.rs')
-rw-r--r--rola-utils/functions/src/copy_with_temp_rename.rs404
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");
+ }
}