Refactored docs for std::fs::set_permissions_nofollow + fix BSD-based systems to use fchmodat with AT_SYMLINK_NOFOLLOW flag
Project: rust-lang/rust | Language: Rust | Concepts: error-handling, backwards-compatibility, refactoring | Difficulty: mid
Link: https://github.com/rust-lang/rust/pull/160170
The problem
The std::fs::set_permissions_nofollow API was not correctly handling permission changes on symlinks for BSD-based systems and Android. The documentation was also out of date or misleading about platform-specific behaviors. On some systems, the function could set permissions on the target of a symlink instead of the symlink itself, or fail in unexpected ways. This caused inconsistent and potentially incorrect behavior across platforms.
Why it's non-trivial
A naive fix would simply call fchmodat with the AT_SYMLINK_NOFOLLOW flag everywhere, or always use open with O_NOFOLLOW and then fchmod. However, not all platforms support these flags or syscalls in the same way:
- On some Linux distributions (e.g., Ubuntu 20.04),
fchmodat(..., AT_SYMLINK_NOFOLLOW)always returnsENOTSUP, regardless of whether the target is a symlink. - On Android,
fchmodatandopenwithO_NOFOLLOWhave inconsistent or broken behavior. - On BSDs and other platforms, behavior varies between changing the symlink, the target, or failing.
- The fallback logic must avoid introducing TOCTOU (time-of-check-to-time-of-use) vulnerabilities and must return consistent error kinds.
The approach
Documentation updates
The documentation for set_permissions_nofollow was rewritten to accurately describe the platform-specific behavior:
-/// This function currently corresponds to:
-/// * `open` with `O_NOFOLLOW` flag enabled + `fchmod` on WASI
-/// * `fchmodat` function with the flag `AT_SYMLINK_NOFOLLOW` enabled
-/// on Unix platforms
-/// * The flag `FILE_FLAG_OPEN_REPARSE_POINT` is enabled and then the
-/// permissions of the file is set through `SetFileInformationByHandle`
-/// on Windows.
-/// * On all other platforms, the behavior remains the same with
-/// [`fs::set_permissions`].
+/// This function currently corresponds to the following underlying operations:
+/// * Android: returns [`Unsupported`] on all files.
+/// * Linux, BSD-based platforms, QNX, NTO: `fchmodat` with `AT_SYMLINK_NOFOLLOW`.
+/// If that is not supported, we fall back to:
+/// * Unix-based platforms with symlinks: `open` with `O_NOFOLLOW` followed by
+/// [`fs::set_permissions`].
+/// * Unix-based platforms without symlinks: `open` followed by [`fs::set_permissions`].
+/// * Windows: `CreateFileW` with `FILE_FLAG_OPEN_REPARSE_POINT` followed
+/// by `SetFileInformationByHandle`.
Annotation: This clarifies exactly what system calls or flags are used on each platform, and notes the fallback behavior.
Platform-specific logic in the implementation
Android: Always return Unsupported
#[cfg(target_os = "android")]
pub fn set_perm_nofollow(_p: &CStr, _perm: FilePermissions) -> io::Result<()> {
// ...explanation...
Err(crate::io::ErrorKind::Unsupported.into())
}
Annotation: On Android, the function always returns Unsupported because the underlying syscalls are unreliable.
Other Unix: Try fchmodat(..., AT_SYMLINK_NOFOLLOW), fall back to open+fchmod
#[cfg(not(target_os = "android"))]
pub fn set_perm_nofollow(p: &CStr, perm: FilePermissions) -> io::Result<()> {
#[inline]
fn open_and_set_permissions(p: &CStr, perm: FilePermissions) -> io::Result<()> {
// ...open with O_NOFOLLOW, then set permissions...
}
let mut res: Result<(), core::io::Error> = Err(crate::io::ErrorKind::Unsupported.into());
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly",
target_os = "nto",
target_os = "qnx"
))]
{
res = cvt_r(|| unsafe {
libc::fchmodat(libc::AT_FDCWD, p.as_ptr(), perm.mode, libc::AT_SYMLINK_NOFOLLOW)
})
.map(|_| ());
}
match res {
Ok(_) => Ok(()),
Err(err) => {
if err.kind() == crate::io::ErrorKind::Unsupported {
match open_and_set_permissions(p, perm) {
Ok(_) => return Ok(()),
Err(e) => {
if e.kind() == crate::io::ErrorKind::FilesystemLoop {
// ...return Unsupported for consistency...
return Err(err);
}
return Err(e);
}
}
}
Err(err)
}
}
}
Annotation:
- First, try
fchmodatwithAT_SYMLINK_NOFOLLOW. - If that returns
Unsupported(e.g., on older Linux), fall back to opening the file withO_NOFOLLOWand then callingfchmod. - If the fallback fails due to a symlink (
FilesystemLoop), convert the error toUnsupportedfor API consistency.
Test updates
The tests were updated to:
- Exclude Android, where the API is always unsupported.
- More clearly separate the platforms where setting permissions on symlinks is expected to succeed or fail.
- Assert that the target file's permissions are not changed when operating on a symlink.
+#[cfg(not(target_os = "android"))]
#[test]
fn set_get_permissions_nofollows() { ... }
Annotation: This ensures the tests match the new, more accurate platform behavior.
From the review thread
fs-rachel:
It seems that the newstd::fs::set_permissions_nofollowfails on Ubuntu 20.04, asfchmodat(flags=AT_SYMLINK_NOFOLLOW)always returnsENOTSUP, not just when the target is a symlink. However the same code path succeeds on Ubuntu 24.04, suggesting that support was added somewhere in between.
...
So I think ideally on Linux we should try the fast path but fall back toopen() + fchmod()if it fails, to maintain support for older distros.
asder8215:
I guess what I'm worried about with doing this is: will this cause a TOCTOU bug? Iffchmodaterrors withENOTSUPbecause it's a symlink on other Linux distro and then we callopen() + fchmod(), can't someone in between those calls delete the symlink and put in a file that will get its permission changed accordingly?
fs-rachel:
I don't think that's an exploitable TOCTTOU bug - the behaviour in that situation would be the same as if they deleted the symlink and replaced it with a regular file before we made the originalfchmodatcall. There isn't a race window where an attacker can induce behaviour that wouldn't normally happen.
Takeaways
- Platform-specific system calls may have inconsistent or incomplete support across OS versions; always check for and handle partial support or missing flags.
- When falling back from a newer syscall to an older pattern, ensure API-level error consistency (e.g., mapping low-level errors to the documented error kinds).
- Documenting platform-specific behavior in public APIs is essential for user clarity and for robust cross-platform support.
- TOCTOU (time-of-check-to-time-of-use) concerns must be considered, but not all fallback logic introduces new vulnerabilities if the observable behavior is unchanged.