@@ -293,21 +293,128 @@ impl DirFd {
293293 mode : u32 ,
294294 symlink_behavior : SymlinkBehavior ,
295295 ) -> io:: Result < ( ) > {
296+ let name_cstr =
297+ CString :: new ( name. as_bytes ( ) ) . map_err ( |_| SafeTraversalError :: PathContainsNull ) ?;
298+
299+ // --- fchmodat2 path (Linux 6.6+, asm-generic arches only) ---
300+ // Uses the raw mode value directly; no nix::Mode conversion needed.
301+ // Only enabled on asm-generic architectures where syscall number 452 is
302+ // correct (x86_64, x86, arm, aarch64, riscv). MIPS/SPARC/PowerPC/Alpha
303+ // use different numbering and are not supported until libc exposes
304+ // SYS_fchmodat2 for them.
305+ #[ cfg( all(
306+ target_os = "linux" ,
307+ any(
308+ target_arch = "x86_64" ,
309+ target_arch = "x86" ,
310+ target_arch = "arm" ,
311+ target_arch = "aarch64" ,
312+ target_arch = "riscv64" ,
313+ target_arch = "riscv32" ,
314+ ) ,
315+ ) ) ]
316+ if matches ! ( symlink_behavior, SymlinkBehavior :: NoFollow ) {
317+ use std:: sync:: atomic:: { AtomicBool , Ordering } ;
318+
319+ // Cache: if fchmodat2 returned ENOSYS once, the kernel is too old
320+ // and will never support it. Skip the syscall on subsequent calls.
321+ static FCHMODAT2_UNAVAILABLE : AtomicBool = AtomicBool :: new ( false ) ;
322+
323+ if !FCHMODAT2_UNAVAILABLE . load ( Ordering :: Relaxed ) {
324+ // Syscall number for fchmodat2 on asm-generic architectures.
325+ const SYS_FCHMODAT2 : libc:: c_long = 452 ;
326+ // SAFETY: syscall(2) is an FFI call. We pass valid arguments:
327+ // - fd: valid open file descriptor
328+ // - name: valid C string pointer (name_cstr lives for the duration)
329+ // - mode: valid mode_t value
330+ // - flags: AT_SYMLINK_NOFOLLOW (valid flag for fchmodat2)
331+ let res = unsafe {
332+ libc:: syscall (
333+ SYS_FCHMODAT2 ,
334+ self . fd . as_raw_fd ( ) ,
335+ name_cstr. as_ptr ( ) ,
336+ mode as libc:: mode_t ,
337+ libc:: AT_SYMLINK_NOFOLLOW ,
338+ )
339+ } ;
340+ if res == 0 {
341+ return Ok ( ( ) ) ;
342+ }
343+ let err = io:: Error :: last_os_error ( ) ;
344+ match err. raw_os_error ( ) {
345+ Some ( libc:: ENOSYS ) => {
346+ FCHMODAT2_UNAVAILABLE . store ( true , Ordering :: Relaxed ) ;
347+ // Fall through to fchmodat
348+ }
349+ _ => return Err ( err) ,
350+ }
351+ }
352+ }
353+
354+ // --- fchmodat fallback path ---
355+ // nix::Mode conversion is needed here because fchmodat() requires it.
356+ let nix_mode = Mode :: from_bits_truncate ( mode as libc:: mode_t ) ;
357+
296358 let flags = if symlink_behavior. should_follow ( ) {
297359 FchmodatFlags :: FollowSymlink
298360 } else {
299361 FchmodatFlags :: NoFollowSymlink
300362 } ;
301363
302- let mode = Mode :: from_bits_truncate ( mode as libc:: mode_t ) ;
364+ match fchmodat ( & self . fd , name_cstr. as_c_str ( ) , nix_mode, flags) {
365+ Ok ( ( ) ) => Ok ( ( ) ) ,
366+ Err ( e)
367+ if !symlink_behavior. should_follow ( )
368+ && ( e == nix:: errno:: Errno :: EOPNOTSUPP || e == nix:: errno:: Errno :: ENOTSUP ) =>
369+ {
370+ // musl does not emulate AT_SYMLINK_NOFOLLOW via /proc/self/fd
371+ // like glibc does, so fchmodat returns EOPNOTSUPP on old kernels.
372+ // Fall back to O_PATH + /proc/self/fd/{fd} + fchmod.
373+ #[ cfg( target_os = "linux" ) ]
374+ {
375+ self . chmod_at_via_opath ( name_cstr. as_c_str ( ) , mode)
376+ }
377+ #[ cfg( not( target_os = "linux" ) ) ]
378+ {
379+ Err ( io:: Error :: from_raw_os_error ( e as i32 ) )
380+ }
381+ }
382+ Err ( e) => Err ( io:: Error :: from_raw_os_error ( e as i32 ) ) ,
383+ }
384+ }
303385
304- let name_cstr =
305- CString :: new ( name. as_bytes ( ) ) . map_err ( |_| SafeTraversalError :: PathContainsNull ) ?;
386+ /// O_PATH-based fallback for chmod when fchmodat with AT_SYMLINK_NOFOLLOW
387+ /// is not available (musl on kernel < 6.6).
388+ ///
389+ /// Opens the file with O_PATH|O_NOFOLLOW to get an fd without following
390+ /// symlinks, then chmods via /proc/self/fd/{fd}. This avoids the TOCTOU
391+ /// race because the fd pins the inode.
392+ #[ cfg( target_os = "linux" ) ]
393+ fn chmod_at_via_opath ( & self , name : & CStr , mode : u32 ) -> io:: Result < ( ) > {
394+ use std:: os:: unix:: io:: FromRawFd ;
395+
396+ let fd = unsafe {
397+ libc:: openat (
398+ self . fd . as_raw_fd ( ) ,
399+ name. as_ptr ( ) ,
400+ libc:: O_PATH | libc:: O_NOFOLLOW | libc:: O_CLOEXEC ,
401+ )
402+ } ;
403+ if fd < 0 {
404+ return Err ( io:: Error :: last_os_error ( ) ) ;
405+ }
406+ let fd = unsafe { OwnedFd :: from_raw_fd ( fd) } ;
306407
307- fchmodat ( & self . fd , name_cstr. as_c_str ( ) , mode, flags)
308- . map_err ( |e| io:: Error :: from_raw_os_error ( e as i32 ) ) ?;
408+ let proc_path = format ! ( "/proc/self/fd/{}\0 " , fd. as_raw_fd( ) ) ;
409+ let proc_cstr = CStr :: from_bytes_with_nul ( proc_path. as_bytes ( ) )
410+ . map_err ( |_| io:: Error :: new ( io:: ErrorKind :: InvalidInput , "invalid proc path" ) ) ?;
309411
310- Ok ( ( ) )
412+ let ret = unsafe { libc:: chmod ( proc_cstr. as_ptr ( ) , mode as libc:: mode_t ) } ;
413+ if ret < 0 {
414+ Err ( io:: Error :: last_os_error ( ) )
415+ } else {
416+ Ok ( ( ) )
417+ }
311418 }
312419
313420 /// Change mode of this directory
@@ -801,6 +908,7 @@ mod tests {
801908 use super :: * ;
802909 use std:: fs;
803910 use std:: os:: unix:: fs:: symlink;
911+ use std:: os:: unix:: fs:: MetadataExt ;
804912 use std:: os:: unix:: io:: IntoRawFd ;
805913 use tempfile:: TempDir ;
806914
@@ -1227,6 +1335,47 @@ mod tests {
12271335 assert ! ( result_nofollow. is_err( ) ) ;
12281336 }
12291337
1338+ /// Verify that chmod_at with NoFollow does not change the symlink target's mode.
1339+ /// This test demonstrates that the TOCTOU race in recursive chmod is closed:
1340+ /// chmod on a symlink entry should not affect the target file.
1341+ #[ test]
1342+ fn test_chmod_at_nofollow_preserves_target_mode ( ) {
1343+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1344+
1345+ // Create a sentinel file outside the traversal directory
1346+ let sentinel = temp_dir. path ( ) . join ( "sentinel" ) ;
1347+ fs:: write ( & sentinel, "victim" ) . unwrap ( ) ;
1348+ let sentinel_mode = fs:: symlink_metadata ( & sentinel)
1349+ . unwrap ( )
1350+ . mode ( ) ;
1351+
1352+ // Create a subdirectory with a symlink pointing to the sentinel
1353+ let subdir = temp_dir. path ( ) . join ( "subdir" ) ;
1354+ fs:: create_dir ( & subdir) . unwrap ( ) ;
1355+ let link = subdir. join ( "link" ) ;
1356+ symlink ( & sentinel, & link) . unwrap ( ) ;
1357+
1358+ // Open the subdirectory and chmod the symlink entry with NoFollow
1359+ let dir_fd = DirFd :: open ( & subdir, SymlinkBehavior :: Follow ) . unwrap ( ) ;
1360+ let result = dir_fd. chmod_at ( OsStr :: new ( "link" ) , 0o777 , SymlinkBehavior :: NoFollow ) ;
1361+
1362+ // On Linux 6.6+ (fchmodat2), the chmod should succeed without affecting the target.
1363+ // On older kernels, fchmodat with AT_SYMLINK_NOFOLLOW returns EOPNOTSUPP/ENOTSUP,
1364+ // which is acceptable — the important thing is the target is NOT modified.
1365+ if let Ok ( ( ) ) = result {
1366+ // fchmodat2 succeeded: verify sentinel mode is unchanged
1367+ let new_sentinel_mode = fs:: symlink_metadata ( & sentinel)
1368+ . unwrap ( )
1369+ . mode ( ) ;
1370+ assert_eq ! (
1371+ new_sentinel_mode, sentinel_mode,
1372+ "sentinel mode should not change when chmod'ing symlink with NoFollow"
1373+ ) ;
1374+ }
1375+ // If result is Err (EOPNOTSUPP on old kernels), the target is also unchanged,
1376+ // which is the correct behavior — no silent modification.
1377+ }
1378+
12301379 #[ test]
12311380 fn test_open_nofollow_fails_on_symlink ( ) {
12321381 let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
0 commit comments