From e342a7f522a236991ba9fa6d8a1daa22465ec217 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Thu, 18 Jun 2026 00:08:02 +0800 Subject: feat(functions): add safe file copy with atomic rename --- rola-utils/functions/Cargo.toml | 1 + rola-utils/functions/src/copy_with_temp_rename.rs | 209 ++++++++++++++++++++++ rola-utils/functions/src/lib.rs | 3 + 3 files changed, 213 insertions(+) create mode 100644 rola-utils/functions/src/copy_with_temp_rename.rs (limited to 'rola-utils/functions') diff --git a/rola-utils/functions/Cargo.toml b/rola-utils/functions/Cargo.toml index dd482c4..564dc54 100644 --- a/rola-utils/functions/Cargo.toml +++ b/rola-utils/functions/Cargo.toml @@ -6,3 +6,4 @@ authors.workspace = true license.workspace = true [dependencies] +tokio.workspace = true diff --git a/rola-utils/functions/src/copy_with_temp_rename.rs b/rola-utils/functions/src/copy_with_temp_rename.rs new file mode 100644 index 0000000..cdbe3db --- /dev/null +++ b/rola-utils/functions/src/copy_with_temp_rename.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, dst: impl AsRef) -> 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 { + 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`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 { + 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 ff2ee7e..40f2ca9 100644 --- a/rola-utils/functions/src/lib.rs +++ b/rola-utils/functions/src/lib.rs @@ -1,2 +1,5 @@ mod levenshtein_distance; pub use levenshtein_distance::*; + +mod copy_with_temp_rename; +pub use copy_with_temp_rename::*; -- cgit