unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
error-handlingbackwards-compatibilityrefactoring

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 returns ENOTSUP, regardless of whether the target is a symlink.
  • On Android, fchmodat and open with O_NOFOLLOW have 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 fchmodat with AT_SYMLINK_NOFOLLOW.
  • If that returns Unsupported (e.g., on older Linux), fall back to opening the file with O_NOFOLLOW and then calling fchmod.
  • If the fallback fails due to a symlink (FilesystemLoop), convert the error to Unsupported for 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 new std::fs::set_permissions_nofollow fails on Ubuntu 20.04, as fchmodat(flags=AT_SYMLINK_NOFOLLOW) always returns ENOTSUP, 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 to open() + 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? If fchmodat errors with ENOTSUP because it's a symlink on other Linux distro and then we call open() + 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 original fchmodat call. 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.