Skip to content

Commit 922d8dd

Browse files
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.
1 parent 715e4ed commit 922d8dd

10 files changed

Lines changed: 288 additions & 18 deletions

File tree

‎cap-primitives/src/fs/mod.rs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,16 @@ pub use canonicalize::canonicalize;
6464
pub use copy::copy;
6565
pub use create_dir::create_dir;
6666
pub use dir_builder::*;
67-
pub use dir_entry::DirEntry;
6867
#[cfg(windows)]
6968
pub use dir_entry::_WindowsDirEntryExt;
69+
pub use dir_entry::DirEntry;
7070
pub use dir_options::DirOptions;
7171
pub use file::FileExt;
72+
#[cfg(windows)]
73+
pub use file_type::_WindowsFileTypeExt;
7274
pub use file_type::FileType;
7375
#[cfg(any(unix, target_os = "vxworks", all(windows, windows_file_type_ext)))]
7476
pub use file_type::FileTypeExt;
75-
#[cfg(windows)]
76-
pub use file_type::_WindowsFileTypeExt;
7777
pub use follow_symlinks::FollowSymlinks;
7878
pub use hard_link::hard_link;
7979
pub use is_file_read_write::is_file_read_write;
@@ -94,6 +94,8 @@ pub use remove_dir_all::remove_dir_all;
9494
pub use remove_file::remove_file;
9595
pub use remove_open_dir::{remove_open_dir, remove_open_dir_all};
9696
pub use rename::rename;
97+
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))]
98+
pub use rename::rename_exclusive;
9799
pub use reopen::reopen;
98100
#[cfg(not(target_os = "wasi"))]
99101
pub use set_permissions::{set_permissions, set_symlink_permissions};

‎cap-primitives/src/fs/rename.rs‎

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
33
#[cfg(all(racy_asserts, not(windows)))]
44
use crate::fs::append_dir_suffix;
5+
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))]
6+
use crate::fs::rename_excl_impl;
57
use crate::fs::rename_impl;
68
use std::path::Path;
79
use std::{fs, io};
@@ -14,6 +16,12 @@ use {
1416
std::path::PathBuf,
1517
};
1618

19+
#[cfg(all(
20+
racy_asserts,
21+
any(target_os = "macos", target_os = "linux", target_os = "redox")
22+
))]
23+
use crate::fs::rename_excl_unchecked;
24+
1725
/// Perform a `renameat`-like operation, ensuring that the resolution of both
1826
/// the old and new paths never escape the directory tree rooted at their
1927
/// respective starts.
@@ -51,6 +59,54 @@ pub fn rename(
5159
&result,
5260
&old_metadata_after,
5361
&new_metadata_after,
62+
rename_unchecked,
63+
);
64+
65+
result
66+
}
67+
68+
/// Perform a `renameat`-like operation, ensuring that the resolution of both
69+
/// the old and new paths never escape the directory tree rooted at their
70+
/// respective starts.
71+
///
72+
/// Unlike [`rename`], the rename fails if the target exists. The check is atomic on supported
73+
/// platform which mitigates potential races (TOCTOU).
74+
#[cfg_attr(not(racy_asserts), allow(clippy::let_and_return))]
75+
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))]
76+
#[inline]
77+
pub fn rename_exclusive(
78+
old_start: &fs::File,
79+
old_path: &Path,
80+
new_start: &fs::File,
81+
new_path: &Path,
82+
) -> io::Result<()> {
83+
#[cfg(racy_asserts)]
84+
let (old_metadata_before, new_metadata_before) = (
85+
stat_unchecked(old_start, old_path, FollowSymlinks::No),
86+
stat_unchecked(new_start, new_path, FollowSymlinks::No),
87+
);
88+
89+
// Call the underlying implementation.
90+
let result = rename_excl_impl(old_start, old_path, new_start, new_path);
91+
92+
#[cfg(racy_asserts)]
93+
let (old_metadata_after, new_metadata_after) = (
94+
stat_unchecked(old_start, old_path, FollowSymlinks::No),
95+
stat_unchecked(new_start, new_path, FollowSymlinks::No),
96+
);
97+
98+
#[cfg(racy_asserts)]
99+
check_rename(
100+
old_start,
101+
old_path,
102+
new_start,
103+
new_path,
104+
&old_metadata_before,
105+
&new_metadata_before,
106+
&result,
107+
&old_metadata_after,
108+
&new_metadata_after,
109+
rename_excl_unchecked,
54110
);
55111

56112
result
@@ -69,6 +125,7 @@ fn check_rename(
69125
result: &io::Result<()>,
70126
old_metadata_after: &io::Result<Metadata>,
71127
new_metadata_after: &io::Result<Metadata>,
128+
rename_impl: impl Fn(&fs::File, &Path, &fs::File, &Path) -> io::Result<()>,
72129
) {
73130
use io::ErrorKind::*;
74131

@@ -97,20 +154,20 @@ fn check_rename(
97154
map_result(&canonicalize_for_rename(old_start, old_path)),
98155
map_result(&canonicalize_for_rename(new_start, new_path)),
99156
) {
100-
(Ok(old_canon), Ok(new_canon)) => match map_result(&rename_unchecked(
101-
old_start, &old_canon, new_start, &new_canon,
102-
)) {
103-
Err((_unchecked_kind, _unchecked_message)) => {
104-
/* TODO: Check error messages.
105-
assert_eq!(kind, unchecked_kind);
106-
assert_eq!(message, unchecked_message);
107-
*/
157+
(Ok(old_canon), Ok(new_canon)) => {
158+
match map_result(&rename_impl(old_start, &old_canon, new_start, &new_canon)) {
159+
Err((_unchecked_kind, _unchecked_message)) => {
160+
/* TODO: Check error messages.
161+
assert_eq!(kind, unchecked_kind);
162+
assert_eq!(message, unchecked_message);
163+
*/
164+
}
165+
other => panic!(
166+
"unsandboxed rename success:\n{:#?}\n{:?} {:?}",
167+
other, kind, message
168+
),
108169
}
109-
other => panic!(
110-
"unsandboxed rename success:\n{:#?}\n{:?} {:?}",
111-
other, kind, message
112-
),
113-
},
170+
}
114171
(Err((_old_canon_kind, _old_canon_message)), _) => {
115172
/* TODO: Check error messages.
116173
assert_eq!(kind, old_canon_kind);

‎cap-primitives/src/fs/via_parent/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ pub(crate) use read_link::read_link;
2929
pub(crate) use remove_dir::remove_dir;
3030
pub(crate) use remove_file::remove_file;
3131
pub(crate) use rename::rename;
32+
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))]
33+
pub(crate) use rename::rename_exclusive;
3234
#[cfg(windows)]
3335
pub(crate) use set_permissions::set_permissions;
3436
#[cfg(not(target_os = "wasi"))]

‎cap-primitives/src/fs/via_parent/rename.rs‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use super::open_parent;
22
#[cfg(unix)]
33
use crate::fs::{append_dir_suffix, path_has_trailing_slash};
4-
use crate::fs::{rename_unchecked, strip_dir_suffix, MaybeOwnedFile};
4+
use crate::fs::{rename_excl_unchecked, rename_unchecked, strip_dir_suffix, MaybeOwnedFile};
55
use std::path::Path;
66
use std::{fs, io};
77

@@ -12,6 +12,34 @@ pub(crate) fn rename(
1212
old_path: &Path,
1313
new_start: &fs::File,
1414
new_path: &Path,
15+
) -> io::Result<()> {
16+
do_rename(old_start, old_path, new_start, new_path, rename_unchecked)
17+
}
18+
19+
/// Implement `rename_exclusive` by `open`ing up the parent component of the path and then
20+
/// calling `rename_excl_unchecked` on the last component.
21+
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))]
22+
pub(crate) fn rename_exclusive(
23+
old_start: &fs::File,
24+
old_path: &Path,
25+
new_start: &fs::File,
26+
new_path: &Path,
27+
) -> io::Result<()> {
28+
do_rename(
29+
old_start,
30+
old_path,
31+
new_start,
32+
new_path,
33+
rename_excl_unchecked,
34+
)
35+
}
36+
37+
fn do_rename(
38+
old_start: &fs::File,
39+
old_path: &Path,
40+
new_start: &fs::File,
41+
new_path: &Path,
42+
rename_impl: impl Fn(&fs::File, &Path, &fs::File, &Path) -> io::Result<()>,
1543
) -> io::Result<()> {
1644
let old_start = MaybeOwnedFile::borrowed(old_start);
1745
let new_start = MaybeOwnedFile::borrowed(new_start);
@@ -41,7 +69,7 @@ pub(crate) fn rename(
4169
old_basename
4270
};
4371

44-
rename_unchecked(
72+
rename_impl(
4573
&old_dir,
4674
old_basename.as_ref(),
4775
&new_dir,

‎cap-primitives/src/rustix/fs/mod.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ mod remove_dir_all_impl;
2323
mod remove_dir_unchecked;
2424
mod remove_file_unchecked;
2525
mod remove_open_dir_by_searching;
26+
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))]
27+
mod rename_excl_unchecked;
2628
mod rename_unchecked;
2729
mod reopen_impl;
2830
#[cfg(not(any(target_os = "android", target_os = "linux", target_os = "wasi")))]
@@ -106,6 +108,8 @@ pub(crate) use crate::fs::{
106108
via_parent::symlink as symlink_impl,
107109
remove_open_dir_by_searching as remove_open_dir_impl,
108110
};
111+
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))]
112+
pub(crate) use crate::fs::via_parent::rename_exclusive as rename_excl_impl;
109113
#[cfg(not(target_os = "wasi"))]
110114
pub(crate) use crate::fs::via_parent::set_symlink_permissions as set_symlink_permissions_impl;
111115
#[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
138142
pub(crate) use remove_dir_unchecked::remove_dir_unchecked;
139143
pub(crate) use remove_file_unchecked::remove_file_unchecked;
140144
pub(crate) use remove_open_dir_by_searching::remove_open_dir_by_searching;
145+
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))]
146+
pub(crate) use rename_excl_unchecked::rename_excl_unchecked;
141147
pub(crate) use rename_unchecked::rename_unchecked;
142148
pub(crate) use reopen_impl::reopen_impl;
143149
pub(crate) use stat_unchecked::stat_unchecked;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
use rustix::fs::{renameat_with, RenameFlags};
2+
use std::path::Path;
3+
use std::{fs, io};
4+
5+
pub(crate) fn rename_excl_unchecked(
6+
old_start: &fs::File,
7+
old_path: &Path,
8+
new_start: &fs::File,
9+
new_path: &Path,
10+
) -> io::Result<()> {
11+
renameat_with(
12+
old_start,
13+
old_path,
14+
new_start,
15+
new_path,
16+
RenameFlags::NOREPLACE,
17+
)
18+
.map_err(Into::into)
19+
}

‎cap-primitives/src/windows/fs/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ mod remove_dir_all_impl;
2222
mod remove_dir_unchecked;
2323
mod remove_file_unchecked;
2424
mod remove_open_dir_impl;
25+
mod rename_excl_unchecked;
2526
mod rename_unchecked;
2627
mod reopen_impl;
2728
mod set_permissions_unchecked;
@@ -69,6 +70,7 @@ pub(crate) use remove_dir_all_impl::*;
6970
pub(crate) use remove_dir_unchecked::*;
7071
pub(crate) use remove_file_unchecked::*;
7172
pub(crate) use remove_open_dir_impl::*;
73+
pub(crate) use rename_excl_unchecked::*;
7274
pub(crate) use rename_unchecked::*;
7375
pub(crate) use reopen_impl::reopen_impl;
7476
pub(crate) use set_permissions_unchecked::*;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
use super::get_path::concatenate;
2+
use std::{fs, io, iter, path::Path, ptr};
3+
use windows_sys::Win32::{
4+
Foundation::{LocalFree, FALSE},
5+
Security::{
6+
Authorization::{GetNamedSecurityInfoW, SetNamedSecurityInfoW, SE_FILE_OBJECT},
7+
ACL, DACL_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION,
8+
PSECURITY_DESCRIPTOR, PSID,
9+
},
10+
Storage::FileSystem::{MoveFileExW, MOVEFILE_COPY_ALLOWED},
11+
};
12+
13+
pub(crate) fn rename_excl_unchecked(
14+
old_start: &fs::File,
15+
old_path: &Path,
16+
new_start: &fs::File,
17+
new_path: &Path,
18+
) -> io::Result<()> {
19+
let old_full_path: Vec<u16> = concatenate(old_start, old_path)?
20+
.into_iter()
21+
.chain(iter::once(0u16))
22+
.collect();
23+
let new_full_path: Vec<u16> = concatenate(new_start, new_path)?
24+
.into_iter()
25+
.chain(iter::once(0u16))
26+
.collect();
27+
28+
// Save permissions in case file will be moved across volumes
29+
// https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getnamedsecurityinfoa
30+
// https://learn.microsoft.com/en-us/windows/win32/secauthz/security-information
31+
let mut owner: PSID = ptr::null_mut();
32+
let mut group: PSID = ptr::null_mut();
33+
let mut dacl: ACL = ptr::null_mut();
34+
// According to the docs, the pointers above are pointers into the PSECURITY_DESCRIPTOR
35+
// struct, so `security` should only be freed after using `owner`, `group`, and `dacl`
36+
let mut security: PSECURITY_DESCRIPTOR = ptr::null_mut();
37+
38+
let move_result = unsafe {
39+
// SAFETY: `old_full_path` is a valid pointer to a NUL terminated wide string
40+
GetNamedSecurityInfoW(
41+
old_full_path.as_ptr(),
42+
SE_FILE_OBJECT,
43+
OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
44+
&mut owner,
45+
&mut group,
46+
&mut dacl,
47+
ptr::null_mut(),
48+
&mut security,
49+
)
50+
};
51+
52+
// Set/GetNamedSecurityInfoW return the error code directly rather than a bool
53+
if move_result != 0 {
54+
return Err(io::Error::from_raw_os_error(move_result));
55+
}
56+
57+
unsafe {
58+
// SAFETY:
59+
// * `concatenate` calls `get_path` which calls `encode_wide` so we have a wide string
60+
// * Both paths are NUL terminated above
61+
if MoveFileExW(
62+
old_full_path.as_ptr(),
63+
new_full_path.as_ptr(),
64+
MOVEFILE_COPY_ALLOWED,
65+
) == FALSE
66+
{
67+
LocalFree(security);
68+
return Err(io::Error::last_os_error());
69+
}
70+
}
71+
72+
let set_result = unsafe {
73+
SetNamedSecurityInfoW(
74+
new_full_path.as_ptr(),
75+
SE_FILE_OBJECT,
76+
OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
77+
owner,
78+
group,
79+
dacl,
80+
ptr::null_mut(),
81+
)
82+
};
83+
84+
unsafe {
85+
LocalFree(security);
86+
}
87+
88+
if set_result != 0 {
89+
Err(io::Error::from_raw_os_error(set_result))
90+
} else {
91+
Ok(())
92+
}
93+
}

‎cap-std/src/fs/dir.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use crate::fs::{DirBuilder, File, Metadata, OpenOptions, ReadDir};
55
use crate::fs_utf8::Dir as DirUtf8;
66
#[cfg(unix)]
77
use crate::os::unix::net::{UnixDatagram, UnixListener, UnixStream};
8+
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))]
9+
use cap_primitives::fs::rename_exclusive;
810
#[cfg(not(target_os = "wasi"))]
911
use cap_primitives::fs::set_permissions;
1012
use cap_primitives::fs::{
@@ -397,6 +399,18 @@ impl Dir {
397399
rename(&self.std_file, from.as_ref(), &to_dir.std_file, to.as_ref())
398400
}
399401

402+
/// Rename a file or a directory to a new name but only if the target does not exist.
403+
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "redox"))]
404+
#[inline]
405+
pub fn rename_exclusive<P: AsRef<Path>, Q: AsRef<Path>>(
406+
&self,
407+
from: P,
408+
to_dir: &Self,
409+
to: Q,
410+
) -> io::Result<()> {
411+
rename_exclusive(&self.std_file, from.as_ref(), &to_dir.std_file, to.as_ref())
412+
}
413+
400414
/// Changes the permissions found on a file or a directory.
401415
///
402416
/// This corresponds to [`std::fs::set_permissions`], but only accesses

0 commit comments

Comments
 (0)