diff --git a/cap-primitives/src/fs/mod.rs b/cap-primitives/src/fs/mod.rs index 62ef8c9c..731d39b6 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 2af4ef95..ececbab1 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 84884cdc..83b3ad88 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 aeb345e5..ccfca649 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 b6a0def2..c2fca4bf 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 00000000..ed7412bc --- /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 0de735ad..58d9e2e7 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 00000000..cc5d1cc8 --- /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 71619eee..3646071d 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 7b487fa8..62cd7fc0 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")); +}