From 922d8dd650e73dcba60c7a7087593a3404cb3071 Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Wed, 1 Jul 2026 20:41:43 -0400 Subject: [PATCH] fs: rename_exclusive (renameat2 + Windows impl) Closes: #406 POSIX's `rename` atomically replaces targets. However, if a caller wants to optionally replace a target, they have to check if the target exists and then rename onto the target. This sequence of events is subject to TOCTOU where the target may be created between the check and the call to rename(). `renameat2` for some of the Unixes and MoveFileExW on Windows both support atomically checking if a target exists on rename. `renameat2` is supported on Linux, FreeBSD 16 (not exposed yet in libc), Redox, and macOS. --- cap-primitives/src/fs/mod.rs | 8 +- cap-primitives/src/fs/rename.rs | 83 ++++++++++++++--- cap-primitives/src/fs/via_parent/mod.rs | 2 + cap-primitives/src/fs/via_parent/rename.rs | 32 ++++++- cap-primitives/src/rustix/fs/mod.rs | 6 ++ .../src/rustix/fs/rename_excl_unchecked.rs | 19 ++++ cap-primitives/src/windows/fs/mod.rs | 2 + .../src/windows/fs/rename_excl_unchecked.rs | 93 +++++++++++++++++++ cap-std/src/fs/dir.rs | 14 +++ tests/rename.rs | 47 ++++++++++ 10 files changed, 288 insertions(+), 18 deletions(-) create mode 100644 cap-primitives/src/rustix/fs/rename_excl_unchecked.rs create mode 100644 cap-primitives/src/windows/fs/rename_excl_unchecked.rs diff --git a/cap-primitives/src/fs/mod.rs b/cap-primitives/src/fs/mod.rs index 62ef8c9c6..731d39b65 100644 --- a/cap-primitives/src/fs/mod.rs +++ b/cap-primitives/src/fs/mod.rs @@ -64,16 +64,16 @@ pub use canonicalize::canonicalize; pub use copy::copy; pub use create_dir::create_dir; pub use dir_builder::*; -pub use dir_entry::DirEntry; #[cfg(windows)] pub use dir_entry::_WindowsDirEntryExt; +pub use dir_entry::DirEntry; pub use dir_options::DirOptions; pub use file::FileExt; +#[cfg(windows)] +pub use file_type::_WindowsFileTypeExt; pub use file_type::FileType; #[cfg(any(unix, target_os = "vxworks", all(windows, windows_file_type_ext)))] pub use file_type::FileTypeExt; -#[cfg(windows)] -pub use file_type::_WindowsFileTypeExt; pub use follow_symlinks::FollowSymlinks; pub use hard_link::hard_link; pub use is_file_read_write::is_file_read_write; @@ -94,6 +94,8 @@ pub use remove_dir_all::remove_dir_all; pub use remove_file::remove_file; pub use remove_open_dir::{remove_open_dir, remove_open_dir_all}; pub use rename::rename; +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))] +pub use rename::rename_exclusive; pub use reopen::reopen; #[cfg(not(target_os = "wasi"))] pub use set_permissions::{set_permissions, set_symlink_permissions}; diff --git a/cap-primitives/src/fs/rename.rs b/cap-primitives/src/fs/rename.rs index 2af4ef959..ececbab1f 100644 --- a/cap-primitives/src/fs/rename.rs +++ b/cap-primitives/src/fs/rename.rs @@ -2,6 +2,8 @@ #[cfg(all(racy_asserts, not(windows)))] use crate::fs::append_dir_suffix; +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))] +use crate::fs::rename_excl_impl; use crate::fs::rename_impl; use std::path::Path; use std::{fs, io}; @@ -14,6 +16,12 @@ use { std::path::PathBuf, }; +#[cfg(all( + racy_asserts, + any(target_os = "macos", target_os = "linux", target_os = "redox") +))] +use crate::fs::rename_excl_unchecked; + /// Perform a `renameat`-like operation, ensuring that the resolution of both /// the old and new paths never escape the directory tree rooted at their /// respective starts. @@ -51,6 +59,54 @@ pub fn rename( &result, &old_metadata_after, &new_metadata_after, + rename_unchecked, + ); + + result +} + +/// Perform a `renameat`-like operation, ensuring that the resolution of both +/// the old and new paths never escape the directory tree rooted at their +/// respective starts. +/// +/// Unlike [`rename`], the rename fails if the target exists. The check is atomic on supported +/// platform which mitigates potential races (TOCTOU). +#[cfg_attr(not(racy_asserts), allow(clippy::let_and_return))] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))] +#[inline] +pub fn rename_exclusive( + old_start: &fs::File, + old_path: &Path, + new_start: &fs::File, + new_path: &Path, +) -> io::Result<()> { + #[cfg(racy_asserts)] + let (old_metadata_before, new_metadata_before) = ( + stat_unchecked(old_start, old_path, FollowSymlinks::No), + stat_unchecked(new_start, new_path, FollowSymlinks::No), + ); + + // Call the underlying implementation. + let result = rename_excl_impl(old_start, old_path, new_start, new_path); + + #[cfg(racy_asserts)] + let (old_metadata_after, new_metadata_after) = ( + stat_unchecked(old_start, old_path, FollowSymlinks::No), + stat_unchecked(new_start, new_path, FollowSymlinks::No), + ); + + #[cfg(racy_asserts)] + check_rename( + old_start, + old_path, + new_start, + new_path, + &old_metadata_before, + &new_metadata_before, + &result, + &old_metadata_after, + &new_metadata_after, + rename_excl_unchecked, ); result @@ -69,6 +125,7 @@ fn check_rename( result: &io::Result<()>, old_metadata_after: &io::Result, new_metadata_after: &io::Result, + rename_impl: impl Fn(&fs::File, &Path, &fs::File, &Path) -> io::Result<()>, ) { use io::ErrorKind::*; @@ -97,20 +154,20 @@ fn check_rename( map_result(&canonicalize_for_rename(old_start, old_path)), map_result(&canonicalize_for_rename(new_start, new_path)), ) { - (Ok(old_canon), Ok(new_canon)) => match map_result(&rename_unchecked( - old_start, &old_canon, new_start, &new_canon, - )) { - Err((_unchecked_kind, _unchecked_message)) => { - /* TODO: Check error messages. - assert_eq!(kind, unchecked_kind); - assert_eq!(message, unchecked_message); - */ + (Ok(old_canon), Ok(new_canon)) => { + match map_result(&rename_impl(old_start, &old_canon, new_start, &new_canon)) { + Err((_unchecked_kind, _unchecked_message)) => { + /* TODO: Check error messages. + assert_eq!(kind, unchecked_kind); + assert_eq!(message, unchecked_message); + */ + } + other => panic!( + "unsandboxed rename success:\n{:#?}\n{:?} {:?}", + other, kind, message + ), } - other => panic!( - "unsandboxed rename success:\n{:#?}\n{:?} {:?}", - other, kind, message - ), - }, + } (Err((_old_canon_kind, _old_canon_message)), _) => { /* TODO: Check error messages. assert_eq!(kind, old_canon_kind); diff --git a/cap-primitives/src/fs/via_parent/mod.rs b/cap-primitives/src/fs/via_parent/mod.rs index 84884cdc7..83b3ad886 100644 --- a/cap-primitives/src/fs/via_parent/mod.rs +++ b/cap-primitives/src/fs/via_parent/mod.rs @@ -29,6 +29,8 @@ pub(crate) use read_link::read_link; pub(crate) use remove_dir::remove_dir; pub(crate) use remove_file::remove_file; pub(crate) use rename::rename; +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))] +pub(crate) use rename::rename_exclusive; #[cfg(windows)] pub(crate) use set_permissions::set_permissions; #[cfg(not(target_os = "wasi"))] diff --git a/cap-primitives/src/fs/via_parent/rename.rs b/cap-primitives/src/fs/via_parent/rename.rs index aeb345e56..ccfca6499 100644 --- a/cap-primitives/src/fs/via_parent/rename.rs +++ b/cap-primitives/src/fs/via_parent/rename.rs @@ -1,7 +1,7 @@ use super::open_parent; #[cfg(unix)] use crate::fs::{append_dir_suffix, path_has_trailing_slash}; -use crate::fs::{rename_unchecked, strip_dir_suffix, MaybeOwnedFile}; +use crate::fs::{rename_excl_unchecked, rename_unchecked, strip_dir_suffix, MaybeOwnedFile}; use std::path::Path; use std::{fs, io}; @@ -12,6 +12,34 @@ pub(crate) fn rename( old_path: &Path, new_start: &fs::File, new_path: &Path, +) -> io::Result<()> { + do_rename(old_start, old_path, new_start, new_path, rename_unchecked) +} + +/// Implement `rename_exclusive` by `open`ing up the parent component of the path and then +/// calling `rename_excl_unchecked` on the last component. +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))] +pub(crate) fn rename_exclusive( + old_start: &fs::File, + old_path: &Path, + new_start: &fs::File, + new_path: &Path, +) -> io::Result<()> { + do_rename( + old_start, + old_path, + new_start, + new_path, + rename_excl_unchecked, + ) +} + +fn do_rename( + old_start: &fs::File, + old_path: &Path, + new_start: &fs::File, + new_path: &Path, + rename_impl: impl Fn(&fs::File, &Path, &fs::File, &Path) -> io::Result<()>, ) -> io::Result<()> { let old_start = MaybeOwnedFile::borrowed(old_start); let new_start = MaybeOwnedFile::borrowed(new_start); @@ -41,7 +69,7 @@ pub(crate) fn rename( old_basename }; - rename_unchecked( + rename_impl( &old_dir, old_basename.as_ref(), &new_dir, diff --git a/cap-primitives/src/rustix/fs/mod.rs b/cap-primitives/src/rustix/fs/mod.rs index b6a0def27..c2fca4bfc 100644 --- a/cap-primitives/src/rustix/fs/mod.rs +++ b/cap-primitives/src/rustix/fs/mod.rs @@ -23,6 +23,8 @@ mod remove_dir_all_impl; mod remove_dir_unchecked; mod remove_file_unchecked; mod remove_open_dir_by_searching; +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))] +mod rename_excl_unchecked; mod rename_unchecked; mod reopen_impl; #[cfg(not(any(target_os = "android", target_os = "linux", target_os = "wasi")))] @@ -106,6 +108,8 @@ pub(crate) use crate::fs::{ via_parent::symlink as symlink_impl, remove_open_dir_by_searching as remove_open_dir_impl, }; +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))] +pub(crate) use crate::fs::via_parent::rename_exclusive as rename_excl_impl; #[cfg(not(target_os = "wasi"))] pub(crate) use crate::fs::via_parent::set_symlink_permissions as set_symlink_permissions_impl; #[cfg(not(target_os = "freebsd"))] @@ -138,6 +142,8 @@ pub(crate) use remove_dir_all_impl::{remove_dir_all_impl, remove_open_dir_all_im pub(crate) use remove_dir_unchecked::remove_dir_unchecked; pub(crate) use remove_file_unchecked::remove_file_unchecked; pub(crate) use remove_open_dir_by_searching::remove_open_dir_by_searching; +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))] +pub(crate) use rename_excl_unchecked::rename_excl_unchecked; pub(crate) use rename_unchecked::rename_unchecked; pub(crate) use reopen_impl::reopen_impl; pub(crate) use stat_unchecked::stat_unchecked; diff --git a/cap-primitives/src/rustix/fs/rename_excl_unchecked.rs b/cap-primitives/src/rustix/fs/rename_excl_unchecked.rs new file mode 100644 index 000000000..ed7412bcd --- /dev/null +++ b/cap-primitives/src/rustix/fs/rename_excl_unchecked.rs @@ -0,0 +1,19 @@ +use rustix::fs::{renameat_with, RenameFlags}; +use std::path::Path; +use std::{fs, io}; + +pub(crate) fn rename_excl_unchecked( + old_start: &fs::File, + old_path: &Path, + new_start: &fs::File, + new_path: &Path, +) -> io::Result<()> { + renameat_with( + old_start, + old_path, + new_start, + new_path, + RenameFlags::NOREPLACE, + ) + .map_err(Into::into) +} diff --git a/cap-primitives/src/windows/fs/mod.rs b/cap-primitives/src/windows/fs/mod.rs index 0de735adb..58d9e2e7c 100644 --- a/cap-primitives/src/windows/fs/mod.rs +++ b/cap-primitives/src/windows/fs/mod.rs @@ -22,6 +22,7 @@ mod remove_dir_all_impl; mod remove_dir_unchecked; mod remove_file_unchecked; mod remove_open_dir_impl; +mod rename_excl_unchecked; mod rename_unchecked; mod reopen_impl; mod set_permissions_unchecked; @@ -69,6 +70,7 @@ pub(crate) use remove_dir_all_impl::*; pub(crate) use remove_dir_unchecked::*; pub(crate) use remove_file_unchecked::*; pub(crate) use remove_open_dir_impl::*; +pub(crate) use rename_excl_unchecked::*; pub(crate) use rename_unchecked::*; pub(crate) use reopen_impl::reopen_impl; pub(crate) use set_permissions_unchecked::*; diff --git a/cap-primitives/src/windows/fs/rename_excl_unchecked.rs b/cap-primitives/src/windows/fs/rename_excl_unchecked.rs new file mode 100644 index 000000000..cc5d1cc85 --- /dev/null +++ b/cap-primitives/src/windows/fs/rename_excl_unchecked.rs @@ -0,0 +1,93 @@ +use super::get_path::concatenate; +use std::{fs, io, iter, path::Path, ptr}; +use windows_sys::Win32::{ + Foundation::{LocalFree, FALSE}, + Security::{ + Authorization::{GetNamedSecurityInfoW, SetNamedSecurityInfoW, SE_FILE_OBJECT}, + ACL, DACL_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, + PSECURITY_DESCRIPTOR, PSID, + }, + Storage::FileSystem::{MoveFileExW, MOVEFILE_COPY_ALLOWED}, +}; + +pub(crate) fn rename_excl_unchecked( + old_start: &fs::File, + old_path: &Path, + new_start: &fs::File, + new_path: &Path, +) -> io::Result<()> { + let old_full_path: Vec = concatenate(old_start, old_path)? + .into_iter() + .chain(iter::once(0u16)) + .collect(); + let new_full_path: Vec = concatenate(new_start, new_path)? + .into_iter() + .chain(iter::once(0u16)) + .collect(); + + // Save permissions in case file will be moved across volumes + // https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getnamedsecurityinfoa + // https://learn.microsoft.com/en-us/windows/win32/secauthz/security-information + let mut owner: PSID = ptr::null_mut(); + let mut group: PSID = ptr::null_mut(); + let mut dacl: ACL = ptr::null_mut(); + // According to the docs, the pointers above are pointers into the PSECURITY_DESCRIPTOR + // struct, so `security` should only be freed after using `owner`, `group`, and `dacl` + let mut security: PSECURITY_DESCRIPTOR = ptr::null_mut(); + + let move_result = unsafe { + // SAFETY: `old_full_path` is a valid pointer to a NUL terminated wide string + GetNamedSecurityInfoW( + old_full_path.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + &mut group, + &mut dacl, + ptr::null_mut(), + &mut security, + ) + }; + + // Set/GetNamedSecurityInfoW return the error code directly rather than a bool + if move_result != 0 { + return Err(io::Error::from_raw_os_error(move_result)); + } + + unsafe { + // SAFETY: + // * `concatenate` calls `get_path` which calls `encode_wide` so we have a wide string + // * Both paths are NUL terminated above + if MoveFileExW( + old_full_path.as_ptr(), + new_full_path.as_ptr(), + MOVEFILE_COPY_ALLOWED, + ) == FALSE + { + LocalFree(security); + return Err(io::Error::last_os_error()); + } + } + + let set_result = unsafe { + SetNamedSecurityInfoW( + new_full_path.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + owner, + group, + dacl, + ptr::null_mut(), + ) + }; + + unsafe { + LocalFree(security); + } + + if set_result != 0 { + Err(io::Error::from_raw_os_error(set_result)) + } else { + Ok(()) + } +} diff --git a/cap-std/src/fs/dir.rs b/cap-std/src/fs/dir.rs index 71619eee0..3646071d2 100644 --- a/cap-std/src/fs/dir.rs +++ b/cap-std/src/fs/dir.rs @@ -5,6 +5,8 @@ use crate::fs::{DirBuilder, File, Metadata, OpenOptions, ReadDir}; use crate::fs_utf8::Dir as DirUtf8; #[cfg(unix)] use crate::os::unix::net::{UnixDatagram, UnixListener, UnixStream}; +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))] +use cap_primitives::fs::rename_exclusive; #[cfg(not(target_os = "wasi"))] use cap_primitives::fs::set_permissions; use cap_primitives::fs::{ @@ -397,6 +399,18 @@ impl Dir { rename(&self.std_file, from.as_ref(), &to_dir.std_file, to.as_ref()) } + /// Rename a file or a directory to a new name but only if the target does not exist. + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))] + #[inline] + pub fn rename_exclusive, Q: AsRef>( + &self, + from: P, + to_dir: &Self, + to: Q, + ) -> io::Result<()> { + rename_exclusive(&self.std_file, from.as_ref(), &to_dir.std_file, to.as_ref()) + } + /// Changes the permissions found on a file or a directory. /// /// This corresponds to [`std::fs::set_permissions`], but only accesses diff --git a/tests/rename.rs b/tests/rename.rs index 7b487fa89..62cd7fc09 100644 --- a/tests/rename.rs +++ b/tests/rename.rs @@ -176,3 +176,50 @@ fn rename_basics() { assert!(!tmpdir.exists("file.txt")); assert!(tmpdir.exists("existing.txt")); } + +#[test] +fn rename_exclusive_basics() { + let tmpdir = tmpdir(); + + let dir1 = "foo"; + tmpdir.create_dir_all(dir1).unwrap(); + let dir2 = "bar"; + tmpdir.create_dir_all(dir2).unwrap(); + + // Empty directory to empty directory + tmpdir.rename_exclusive(dir1, &tmpdir, dir2).unwrap_err(); + + // File to directory + let file1 = "foo/baz"; + tmpdir.create(file1).unwrap(); + tmpdir.rename_exclusive(file1, &tmpdir, dir2).unwrap_err(); + + // Directory to file + tmpdir.rename_exclusive(dir2, &tmpdir, file1).unwrap_err(); + + // File to file + let file2 = "bar/mane"; + tmpdir.create(file2).unwrap(); + tmpdir.rename_exclusive(file1, &tmpdir, file2).unwrap_err(); + + assert!(tmpdir.exists(dir1)); + assert!(tmpdir.exists(dir2)); + assert!(tmpdir.exists(file1)); + assert!(tmpdir.exists(file2)); + + // Now let's test successful renames! + let dir3 = "bar/quux"; + let file3 = "bar/quux/baz"; + tmpdir.create_dir_all(dir3).unwrap(); + + // File to file + tmpdir.rename_exclusive(file1, &tmpdir, file3).unwrap(); + + // Directory to directory + let dir4 = "foo/bar"; + tmpdir.rename_exclusive(dir2, &tmpdir, dir4).unwrap(); + assert!(tmpdir.exists(dir1)); + assert!(!tmpdir.exists(dir2)); + assert!(tmpdir.exists(dir4)); + assert!(tmpdir.exists("foo/bar/quux/baz")); +}