Skip to main content

uucore/features/
safe_traversal.rs

1// This file is part of the uutils coreutils package.
2//
3// For the full copyright and license information, please view the LICENSE
4// file that was distributed with this source code.
5//
6// Safe directory traversal using openat() and related syscalls
7// This module provides TOCTOU-safe filesystem operations for recursive traversal
8//
9// Available on Unix
10//
11// spell-checker:ignore CLOEXEC RDONLY TOCTOU closedir dirp fdopendir fstatat openat REMOVEDIR unlinkat smallfile
12// spell-checker:ignore RAII dirfd fchownat fchown FchmodatFlags fchmodat fchmod mkdirat CREAT WRONLY ELOOP ENOTDIR
13// spell-checker:ignore atimensec mtimensec ctimensec opath chmods
14
15#[cfg(test)]
16use std::os::unix::ffi::OsStringExt;
17
18use std::ffi::{CString, OsStr, OsString};
19use std::fs;
20use std::io;
21use std::os::unix::ffi::OsStrExt;
22use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd};
23use std::path::{Path, PathBuf};
24
25use nix::dir::Dir;
26use nix::fcntl::{OFlag, openat};
27use nix::libc;
28use nix::sys::stat::{FchmodatFlags, FileStat, Mode, fchmodat, fstatat, mkdirat};
29use nix::unistd::{Gid, Uid, UnlinkatFlags, fchown, fchownat, unlinkat};
30use os_display::Quotable;
31
32use crate::translate;
33
34/// Enum to specify symlink following behavior.
35///
36/// This replaces boolean `follow_symlinks` parameters for better readability
37/// at call sites. Instead of `open(path, true)`, use `open(path, SymlinkBehavior::Follow)`.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum SymlinkBehavior {
40    /// Follow symlinks (resolve to their target)
41    #[default]
42    Follow,
43    /// Do not follow symlinks (operate on the symlink itself)
44    NoFollow,
45}
46
47impl SymlinkBehavior {
48    /// Returns `true` if symlinks should be followed
49    #[inline]
50    pub fn should_follow(self) -> bool {
51        matches!(self, Self::Follow)
52    }
53}
54
55impl From<bool> for SymlinkBehavior {
56    fn from(follow: bool) -> Self {
57        if follow { Self::Follow } else { Self::NoFollow }
58    }
59}
60
61// Custom error types for better error reporting
62#[derive(thiserror::Error, Debug)]
63pub enum SafeTraversalError {
64    #[error("{}", translate!("safe-traversal-error-path-contains-null"))]
65    PathContainsNull,
66
67    #[error("{}", translate!("safe-traversal-error-open-failed", "path" => path.quote(), "source" => source))]
68    OpenFailed {
69        path: PathBuf,
70        #[source]
71        source: io::Error,
72    },
73
74    #[error("{}", translate!("safe-traversal-error-stat-failed", "path" => path.quote(), "source" => source))]
75    StatFailed {
76        path: PathBuf,
77        #[source]
78        source: io::Error,
79    },
80
81    #[error("{}", translate!("safe-traversal-error-read-dir-failed", "path" => path.quote(), "source" => source))]
82    ReadDirFailed {
83        path: PathBuf,
84        #[source]
85        source: io::Error,
86    },
87
88    #[error("{}", translate!("safe-traversal-error-unlink-failed", "path" => path.quote(), "source" => source))]
89    UnlinkFailed {
90        path: PathBuf,
91        #[source]
92        source: io::Error,
93    },
94}
95
96impl From<SafeTraversalError> for io::Error {
97    fn from(err: SafeTraversalError) -> Self {
98        match err {
99            SafeTraversalError::PathContainsNull => Self::new(
100                io::ErrorKind::InvalidInput,
101                translate!("safe-traversal-error-path-contains-null"),
102            ),
103            SafeTraversalError::OpenFailed { source, .. } => source,
104            SafeTraversalError::StatFailed { source, .. } => source,
105            SafeTraversalError::ReadDirFailed { source, .. } => source,
106            SafeTraversalError::UnlinkFailed { source, .. } => source,
107        }
108    }
109}
110
111// Helper function to read directory entries using nix
112fn read_dir_entries(fd: &OwnedFd) -> io::Result<Vec<OsString>> {
113    let mut entries = Vec::new();
114
115    // Duplicate the fd for Dir (it takes ownership)
116    let dup_fd = nix::unistd::dup(fd).map_err(|e| io::Error::from_raw_os_error(e as i32))?;
117    let mut dir = Dir::from_fd(dup_fd).map_err(|e| io::Error::from_raw_os_error(e as i32))?;
118    for entry_result in dir.iter() {
119        let entry = entry_result.map_err(|e| io::Error::from_raw_os_error(e as i32))?;
120        let name = entry.file_name();
121        let name_os = OsStr::from_bytes(name.to_bytes());
122        if name_os != "." && name_os != ".." {
123            entries.push(name_os.to_os_string());
124        }
125    }
126
127    Ok(entries)
128}
129
130/// A directory file descriptor that enables safe traversal
131pub struct DirFd {
132    fd: OwnedFd,
133}
134
135impl DirFd {
136    /// Open a directory and return a file descriptor
137    ///
138    /// # Arguments
139    /// * `path` - The path to the directory to open
140    /// * `symlink_behavior` - Whether to follow symlinks when opening
141    pub fn open(path: &Path, symlink_behavior: SymlinkBehavior) -> io::Result<Self> {
142        let mut flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC;
143        if !symlink_behavior.should_follow() {
144            flags |= OFlag::O_NOFOLLOW;
145        }
146        let fd = nix::fcntl::open(path, flags, Mode::empty()).map_err(|e| {
147            SafeTraversalError::OpenFailed {
148                path: path.into(),
149                source: io::Error::from_raw_os_error(e as i32),
150            }
151        })?;
152        Ok(Self { fd })
153    }
154
155    /// Open a subdirectory relative to this directory
156    ///
157    /// # Arguments
158    /// * `name` - The name of the subdirectory to open
159    /// * `symlink_behavior` - Whether to follow symlinks when opening
160    pub fn open_subdir(&self, name: &OsStr, symlink_behavior: SymlinkBehavior) -> io::Result<Self> {
161        let name_cstr =
162            CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
163        let mut flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC;
164        if !symlink_behavior.should_follow() {
165            flags |= OFlag::O_NOFOLLOW;
166        }
167        let fd = openat(&self.fd, name_cstr.as_c_str(), flags, Mode::empty()).map_err(|e| {
168            SafeTraversalError::OpenFailed {
169                path: name.into(),
170                source: io::Error::from_raw_os_error(e as i32),
171            }
172        })?;
173        Ok(Self { fd })
174    }
175
176    /// Get raw stat data for a file relative to this directory
177    pub fn stat_at(&self, name: &OsStr, symlink_behavior: SymlinkBehavior) -> io::Result<FileStat> {
178        let name_cstr =
179            CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
180
181        let flags = if symlink_behavior.should_follow() {
182            nix::fcntl::AtFlags::empty()
183        } else {
184            nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW
185        };
186
187        let stat = fstatat(&self.fd, name_cstr.as_c_str(), flags).map_err(|e| {
188            SafeTraversalError::StatFailed {
189                path: name.into(),
190                source: io::Error::from_raw_os_error(e as i32),
191            }
192        })?;
193
194        Ok(stat)
195    }
196
197    /// Get metadata for a file relative to this directory
198    pub fn metadata_at(
199        &self,
200        name: &OsStr,
201        symlink_behavior: SymlinkBehavior,
202    ) -> io::Result<Metadata> {
203        self.stat_at(name, symlink_behavior)
204            .map(Metadata::from_stat)
205    }
206
207    /// Get metadata for this directory
208    pub fn metadata(&self) -> io::Result<Metadata> {
209        self.fstat().map(Metadata::from_stat)
210    }
211
212    /// Get raw stat data for this directory
213    pub fn fstat(&self) -> io::Result<FileStat> {
214        let stat = nix::sys::stat::fstat(&self.fd).map_err(|e| SafeTraversalError::StatFailed {
215            path: translate!("safe-traversal-current-directory").into(),
216            source: io::Error::from_raw_os_error(e as i32),
217        })?;
218        Ok(stat)
219    }
220
221    /// Read directory entries
222    pub fn read_dir(&self) -> io::Result<Vec<OsString>> {
223        read_dir_entries(&self.fd).map_err(|e| {
224            SafeTraversalError::ReadDirFailed {
225                path: translate!("safe-traversal-directory").into(),
226                source: e,
227            }
228            .into()
229        })
230    }
231
232    /// Remove a file or empty directory relative to this directory
233    pub fn unlink_at(&self, name: &OsStr, is_dir: bool) -> io::Result<()> {
234        let name_cstr =
235            CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
236        let flags = if is_dir {
237            UnlinkatFlags::RemoveDir
238        } else {
239            UnlinkatFlags::NoRemoveDir
240        };
241
242        unlinkat(&self.fd, name_cstr.as_c_str(), flags).map_err(|e| {
243            SafeTraversalError::UnlinkFailed {
244                path: name.into(),
245                source: io::Error::from_raw_os_error(e as i32),
246            }
247        })?;
248
249        Ok(())
250    }
251
252    /// Change ownership of a file relative to this directory
253    /// Use uid/gid of None to keep the current value
254    pub fn chown_at(
255        &self,
256        name: &OsStr,
257        uid: Option<u32>,
258        gid: Option<u32>,
259        symlink_behavior: SymlinkBehavior,
260    ) -> io::Result<()> {
261        let name_cstr =
262            CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
263
264        let flags = if symlink_behavior.should_follow() {
265            nix::fcntl::AtFlags::empty()
266        } else {
267            nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW
268        };
269
270        let uid = uid.map(Uid::from_raw);
271        let gid = gid.map(Gid::from_raw);
272
273        fchownat(&self.fd, name_cstr.as_c_str(), uid, gid, flags)
274            .map_err(|e| io::Error::from_raw_os_error(e as i32))?;
275
276        Ok(())
277    }
278
279    /// Change ownership of this directory
280    pub fn fchown(&self, uid: Option<u32>, gid: Option<u32>) -> io::Result<()> {
281        let uid = uid.map(Uid::from_raw);
282        let gid = gid.map(Gid::from_raw);
283
284        fchown(&self.fd, uid, gid).map_err(|e| io::Error::from_raw_os_error(e as i32))?;
285
286        Ok(())
287    }
288
289    /// Change mode of a file relative to this directory
290    pub fn chmod_at(
291        &self,
292        name: &OsStr,
293        mode: u32,
294        symlink_behavior: SymlinkBehavior,
295    ) -> 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
358        let flags = if symlink_behavior.should_follow() {
359            FchmodatFlags::FollowSymlink
360        } else {
361            FchmodatFlags::NoFollowSymlink
362        };
363
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    }
385
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    ///
393    #[cfg(target_os = "linux")]
394    fn chmod_at_via_opath(&self, name: &std::ffi::CStr, mode: u32) -> io::Result<()> {
395        use rustix::fs::{Mode, OFlags, chmod, openat};
396
397        let fd = openat(
398            &self.fd,
399            name,
400            OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
401            Mode::empty(),
402        )
403        .map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))?;
404
405        let proc_path = format!("/proc/self/fd/{}\0", fd.as_raw_fd());
406        let proc_cstr = std::ffi::CStr::from_bytes_with_nul(proc_path.as_bytes())
407            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid proc path"))?;
408
409        chmod(proc_cstr, Mode::from_bits_truncate(mode))
410            .map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))
411    }
412
413    /// Change mode of this directory
414    pub fn fchmod(&self, mode: u32) -> io::Result<()> {
415        let mode = Mode::from_bits_truncate(mode as libc::mode_t);
416
417        nix::sys::stat::fchmod(&self.fd, mode)
418            .map_err(|e| io::Error::from_raw_os_error(e as i32))?;
419
420        Ok(())
421    }
422
423    /// Create a directory relative to this directory
424    pub fn mkdir_at(&self, name: &OsStr, mode: u32) -> io::Result<()> {
425        let name_cstr =
426            CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
427        let mode = Mode::from_bits_truncate(mode as libc::mode_t);
428
429        if let Err(e) = mkdirat(self.fd.as_fd(), name_cstr.as_c_str(), mode) {
430            let err = io::Error::from_raw_os_error(e as i32);
431            return Err(SafeTraversalError::OpenFailed {
432                path: name.into(),
433                source: err,
434            }
435            .into());
436        }
437        Ok(())
438    }
439
440    /// Open a file for writing relative to this directory
441    /// Creates the file if it doesn't exist, truncates if it does
442    pub fn open_file_at(&self, name: &OsStr) -> io::Result<fs::File> {
443        let name_cstr =
444            CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
445        let flags = OFlag::O_CREAT | OFlag::O_WRONLY | OFlag::O_TRUNC | OFlag::O_CLOEXEC;
446        let mode = Mode::from_bits_truncate(0o666); // Default file permissions
447
448        let fd: OwnedFd = openat(self.fd.as_fd(), name_cstr.as_c_str(), flags, mode)
449            .map_err(|e| io::Error::from_raw_os_error(e as i32))?;
450
451        Ok(fs::File::from(fd))
452    }
453
454    /// Create a DirFd from an existing file descriptor (takes ownership)
455    pub fn from_raw_fd(fd: RawFd) -> io::Result<Self> {
456        if fd < 0 {
457            return Err(io::Error::new(
458                io::ErrorKind::InvalidInput,
459                translate!("safe-traversal-error-invalid-fd"),
460            ));
461        }
462        // SAFETY: We've verified fd >= 0, and the caller is transferring ownership
463        let owned_fd = unsafe { OwnedFd::from_raw_fd(fd) };
464        Ok(Self { fd: owned_fd })
465    }
466}
467
468/// Find the deepest existing directory ancestor for a path.
469///
470/// Returns the existing ancestor path and a list of components that need to be created.
471/// Uses `metadata` (follows symlinks) so that symlinks to directories are treated as
472/// existing ancestors rather than components to create.
473fn find_existing_ancestor(path: &Path) -> io::Result<(PathBuf, Vec<OsString>)> {
474    let mut current = path.to_path_buf();
475    let mut components: Vec<OsString> = Vec::new();
476
477    loop {
478        // Use metadata (follow symlinks) so that symlinks to directories are
479        // treated as existing ancestors rather than components to create.
480        match fs::metadata(&current) {
481            Ok(meta) => {
482                if meta.is_dir() {
483                    // Found a directory (real or via symlink)
484                    components.reverse();
485                    return Ok((current, components));
486                }
487                // It's a file or other non-directory - treat as needing creation
488                if let Some(file_name) = current.file_name() {
489                    components.push(file_name.to_os_string());
490                }
491                if let Some(parent) = current.parent() {
492                    if parent.as_os_str().is_empty() {
493                        // Reached empty parent (for relative paths), use "."
494                        components.reverse();
495                        return Ok((PathBuf::from("."), components));
496                    }
497                    current = parent.to_path_buf();
498                } else {
499                    // Reached filesystem root
500                    let root = if path.is_absolute() {
501                        PathBuf::from("/")
502                    } else {
503                        PathBuf::from(".")
504                    };
505                    components.reverse();
506                    return Ok((root, components));
507                }
508            }
509            Err(e) if e.kind() == io::ErrorKind::NotFound => {
510                // Doesn't exist, record component and move up to parent
511                if let Some(file_name) = current.file_name() {
512                    components.push(file_name.to_os_string());
513                }
514                if let Some(parent) = current.parent() {
515                    if parent.as_os_str().is_empty() {
516                        // Reached empty parent (for relative paths), use "."
517                        components.reverse();
518                        return Ok((PathBuf::from("."), components));
519                    }
520                    current = parent.to_path_buf();
521                } else {
522                    // Reached filesystem root
523                    let root = if path.is_absolute() {
524                        PathBuf::from("/")
525                    } else {
526                        PathBuf::from(".")
527                    };
528                    components.reverse();
529                    return Ok((root, components));
530                }
531            }
532            Err(e) => return Err(e),
533        }
534    }
535}
536
537/// Open or create a subdirectory using fd-based operations only.
538///
539/// This is a helper function for `create_dir_all_safe` that handles a single
540/// path component. If a symlink to a directory exists, it is followed (GNU
541/// coreutils behavior). Dangling symlinks and non-directory entries are errors.
542///
543/// # Arguments
544/// * `parent_fd` - The parent directory file descriptor
545/// * `name` - The name of the subdirectory to open or create
546/// * `mode` - The mode to use when creating a new directory
547///
548/// # Returns
549/// A DirFd for the subdirectory
550fn open_or_create_subdir(parent_fd: &DirFd, name: &OsStr, mode: u32) -> io::Result<DirFd> {
551    match parent_fd.stat_at(name, SymlinkBehavior::NoFollow) {
552        Ok(stat) => {
553            let file_type = (stat.st_mode as libc::mode_t) & libc::S_IFMT;
554            match file_type {
555                libc::S_IFDIR => parent_fd.open_subdir(name, SymlinkBehavior::NoFollow),
556                libc::S_IFLNK => {
557                    // Follow symlinks to directories (GNU coreutils behavior).
558                    // O_DIRECTORY in open_subdir ensures we only succeed if the
559                    // symlink resolves to a directory; dangling or non-dir symlinks error out.
560                    parent_fd.open_subdir(name, SymlinkBehavior::Follow)
561                }
562                _ => Err(io::Error::new(
563                    io::ErrorKind::AlreadyExists,
564                    format!(
565                        "path component exists but is not a directory: {}",
566                        name.display()
567                    ),
568                )),
569            }
570        }
571        Err(e) if e.kind() == io::ErrorKind::NotFound => {
572            parent_fd.mkdir_at(name, mode)?;
573            parent_fd.open_subdir(name, SymlinkBehavior::NoFollow)
574        }
575        Err(e) => Err(e),
576    }
577}
578
579/// Safely create all parent directories for a path using directory file descriptors.
580/// This prevents symlink race conditions by anchoring all operations to directory fds.
581///
582/// # Security
583/// This function prevents TOCTOU race conditions for newly created directories by:
584/// 1. Finding the deepest existing ancestor directory (path-based, following symlinks)
585/// 2. Opening that ancestor with a file descriptor
586/// 3. Creating all new directories using fd-based operations (mkdirat, openat with O_NOFOLLOW)
587///
588/// Once we have a fd for an existing ancestor, all subsequent operations use that fd
589/// as the anchor. If an attacker replaces a newly-created directory with a symlink,
590/// our openat with O_NOFOLLOW will fail, preventing the attack.
591///
592/// Pre-existing symlinks to directories in the path are followed (GNU coreutils behavior).
593/// `O_DIRECTORY` is used when opening them, so dangling or non-directory symlinks error out.
594/// Note that a residual TOCTOU window exists between stat and open for such symlinks,
595/// which is the same trade-off made by GNU coreutils.
596///
597/// # Arguments
598/// * `path` - The path to create directories for
599/// * `mode` - The mode to use when creating new directories (e.g., 0o755). The actual
600///   mode will be modified by the process umask.
601///
602/// # Returns
603/// A DirFd for the final created directory, or the first existing parent if
604/// all directories already exist.
605#[cfg(unix)]
606pub fn create_dir_all_safe(path: &Path, mode: u32) -> io::Result<DirFd> {
607    let (existing_ancestor, components_to_create) = find_existing_ancestor(path)?;
608    let mut dir_fd = DirFd::open(&existing_ancestor, SymlinkBehavior::Follow)?;
609
610    for component in &components_to_create {
611        dir_fd = open_or_create_subdir(&dir_fd, component.as_os_str(), mode)?;
612    }
613
614    Ok(dir_fd)
615}
616
617impl AsRawFd for DirFd {
618    fn as_raw_fd(&self) -> RawFd {
619        self.fd.as_raw_fd()
620    }
621}
622
623impl AsFd for DirFd {
624    fn as_fd(&self) -> BorrowedFd<'_> {
625        self.fd.as_fd()
626    }
627}
628
629/// File information for tracking inodes
630#[derive(Debug, Clone, Hash, PartialEq, Eq)]
631pub struct FileInfo {
632    pub dev: u64,
633    pub ino: u64,
634}
635
636impl FileInfo {
637    pub fn from_stat(stat: &libc::stat) -> Self {
638        // Allow unnecessary cast because st_dev and st_ino have different types on different platforms
639        #[allow(clippy::unnecessary_cast)]
640        Self {
641            dev: stat.st_dev as u64,
642            ino: stat.st_ino as u64,
643        }
644    }
645
646    /// Create FileInfo from device and inode numbers
647    pub fn new(dev: u64, ino: u64) -> Self {
648        Self { dev, ino }
649    }
650
651    /// Get the device number
652    pub fn device(&self) -> u64 {
653        self.dev
654    }
655
656    /// Get the inode number
657    pub fn inode(&self) -> u64 {
658        self.ino
659    }
660}
661
662/// File type enumeration for better type safety
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
664pub enum FileType {
665    Directory,
666    RegularFile,
667    Symlink,
668    Other,
669}
670
671impl FileType {
672    pub fn from_mode(mode: libc::mode_t) -> Self {
673        match mode & libc::S_IFMT {
674            libc::S_IFDIR => Self::Directory,
675            libc::S_IFREG => Self::RegularFile,
676            libc::S_IFLNK => Self::Symlink,
677            _ => Self::Other,
678        }
679    }
680
681    pub fn is_directory(self) -> bool {
682        matches!(self, Self::Directory)
683    }
684
685    pub fn is_regular_file(self) -> bool {
686        matches!(self, Self::RegularFile)
687    }
688
689    pub fn is_symlink(self) -> bool {
690        matches!(self, Self::Symlink)
691    }
692}
693
694/// Metadata wrapper for safer access to file information
695#[derive(Debug, Clone)]
696pub struct Metadata {
697    stat: FileStat,
698}
699
700impl Metadata {
701    pub fn from_stat(stat: FileStat) -> Self {
702        Self { stat }
703    }
704
705    pub fn file_type(&self) -> FileType {
706        FileType::from_mode(self.stat.st_mode as libc::mode_t)
707    }
708
709    pub fn file_info(&self) -> FileInfo {
710        FileInfo::from_stat(&self.stat)
711    }
712
713    // st_size type varies by platform (i64 vs u64)
714    #[allow(clippy::unnecessary_cast)]
715    pub fn size(&self) -> u64 {
716        self.stat.st_size as u64
717    }
718
719    // st_mode type varies by platform (u16 on macOS, u32 on Linux)
720    #[allow(clippy::unnecessary_cast)]
721    pub fn mode(&self) -> u32 {
722        self.stat.st_mode as u32
723    }
724
725    pub fn nlink(&self) -> u64 {
726        // st_nlink type varies by platform (u16 on FreeBSD, u32/u64 on others)
727        #[allow(clippy::unnecessary_cast)]
728        {
729            self.stat.st_nlink as u64
730        }
731    }
732
733    /// Compatibility methods to match std::fs::Metadata interface
734    pub fn is_dir(&self) -> bool {
735        self.file_type().is_directory()
736    }
737
738    pub fn len(&self) -> u64 {
739        self.size()
740    }
741
742    pub fn is_empty(&self) -> bool {
743        self.len() == 0
744    }
745}
746
747// Add MetadataExt trait implementation for compatibility
748impl std::os::unix::fs::MetadataExt for Metadata {
749    // st_dev type varies by platform (i32 on macOS, u64 on Linux)
750    #[allow(clippy::unnecessary_cast)]
751    fn dev(&self) -> u64 {
752        self.stat.st_dev as u64
753    }
754
755    fn ino(&self) -> u64 {
756        // st_ino type varies by platform (u32 on FreeBSD, u64 on Linux)
757        #[allow(clippy::unnecessary_cast)]
758        {
759            self.stat.st_ino as u64
760        }
761    }
762
763    // st_mode type varies by platform (u16 on macOS, u32 on Linux)
764    #[allow(clippy::unnecessary_cast)]
765    fn mode(&self) -> u32 {
766        self.stat.st_mode as u32
767    }
768
769    fn nlink(&self) -> u64 {
770        // st_nlink type varies by platform (u16 on FreeBSD, u32/u64 on others)
771        #[allow(clippy::unnecessary_cast)]
772        {
773            self.stat.st_nlink as u64
774        }
775    }
776
777    fn uid(&self) -> u32 {
778        self.stat.st_uid
779    }
780
781    fn gid(&self) -> u32 {
782        self.stat.st_gid
783    }
784
785    // st_rdev type varies by platform (i32 on macOS, u64 on Linux)
786    #[allow(clippy::unnecessary_cast)]
787    fn rdev(&self) -> u64 {
788        self.stat.st_rdev as u64
789    }
790
791    // st_size type varies by platform (i64 on some platforms, u64 on others)
792    #[allow(clippy::unnecessary_cast)]
793    fn size(&self) -> u64 {
794        self.stat.st_size as u64
795    }
796
797    fn atime(&self) -> i64 {
798        #[cfg(target_pointer_width = "32")]
799        {
800            self.stat.st_atime.into()
801        }
802        #[cfg(not(target_pointer_width = "32"))]
803        {
804            self.stat.st_atime
805        }
806    }
807
808    fn atime_nsec(&self) -> i64 {
809        #[cfg(target_os = "netbsd")]
810        {
811            self.stat.st_atimensec
812        }
813
814        #[cfg(not(target_os = "netbsd"))]
815        {
816            #[cfg(target_pointer_width = "32")]
817            {
818                self.stat.st_atime_nsec.into()
819            }
820            #[cfg(not(target_pointer_width = "32"))]
821            {
822                self.stat.st_atime_nsec
823            }
824        }
825    }
826
827    fn mtime(&self) -> i64 {
828        #[cfg(target_pointer_width = "32")]
829        {
830            self.stat.st_mtime.into()
831        }
832        #[cfg(not(target_pointer_width = "32"))]
833        {
834            self.stat.st_mtime
835        }
836    }
837
838    fn mtime_nsec(&self) -> i64 {
839        #[cfg(target_os = "netbsd")]
840        {
841            self.stat.st_mtimensec
842        }
843
844        #[cfg(not(target_os = "netbsd"))]
845        {
846            #[cfg(target_pointer_width = "32")]
847            {
848                self.stat.st_mtime_nsec.into()
849            }
850            #[cfg(not(target_pointer_width = "32"))]
851            {
852                self.stat.st_mtime_nsec
853            }
854        }
855    }
856
857    fn ctime(&self) -> i64 {
858        #[cfg(target_pointer_width = "32")]
859        {
860            self.stat.st_ctime.into()
861        }
862        #[cfg(not(target_pointer_width = "32"))]
863        {
864            self.stat.st_ctime
865        }
866    }
867
868    fn ctime_nsec(&self) -> i64 {
869        #[cfg(target_os = "netbsd")]
870        {
871            self.stat.st_ctimensec
872        }
873
874        #[cfg(not(target_os = "netbsd"))]
875        {
876            #[cfg(target_pointer_width = "32")]
877            {
878                self.stat.st_ctime_nsec.into()
879            }
880            #[cfg(not(target_pointer_width = "32"))]
881            {
882                self.stat.st_ctime_nsec
883            }
884        }
885    }
886
887    // st_blksize type varies by platform (i32/i64/u32/u64 depending on platform)
888    #[allow(clippy::unnecessary_cast)]
889    fn blksize(&self) -> u64 {
890        self.stat.st_blksize as u64
891    }
892
893    // st_blocks type varies by platform (i64 on some platforms, u64 on others)
894    #[allow(clippy::unnecessary_cast)]
895    fn blocks(&self) -> u64 {
896        self.stat.st_blocks as u64
897    }
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903    use std::fs;
904    use std::os::unix::fs::MetadataExt;
905    use std::os::unix::fs::symlink;
906    use std::os::unix::io::IntoRawFd;
907    use tempfile::TempDir;
908
909    #[test]
910    fn test_dirfd_open_valid_directory() {
911        let temp_dir = TempDir::new().unwrap();
912        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
913        assert!(dir_fd.as_raw_fd() >= 0);
914    }
915
916    #[test]
917    fn test_dirfd_open_nonexistent_directory() {
918        let result = DirFd::open("/nonexistent/path".as_ref(), SymlinkBehavior::Follow);
919        assert!(result.is_err());
920        if let Err(e) = result {
921            // The error should be the underlying io::Error
922            assert!(
923                e.kind() == io::ErrorKind::NotFound || e.kind() == io::ErrorKind::PermissionDenied
924            );
925        }
926    }
927
928    #[test]
929    fn test_dirfd_open_file_not_directory() {
930        let temp_dir = TempDir::new().unwrap();
931        let file_path = temp_dir.path().join("test_file");
932        fs::write(&file_path, "test content").unwrap();
933
934        let result = DirFd::open(&file_path, SymlinkBehavior::Follow);
935        assert!(result.is_err());
936    }
937
938    #[test]
939    fn test_dirfd_open_subdir() {
940        let temp_dir = TempDir::new().unwrap();
941        let subdir = temp_dir.path().join("subdir");
942        fs::create_dir(&subdir).unwrap();
943
944        let parent_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
945        let subdir_fd = parent_fd
946            .open_subdir(OsStr::new("subdir"), SymlinkBehavior::Follow)
947            .unwrap();
948        assert!(subdir_fd.as_raw_fd() >= 0);
949    }
950
951    #[test]
952    fn test_dirfd_open_nonexistent_subdir() {
953        let temp_dir = TempDir::new().unwrap();
954        let parent_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
955
956        let result = parent_fd.open_subdir(OsStr::new("nonexistent"), SymlinkBehavior::Follow);
957        assert!(result.is_err());
958    }
959
960    #[test]
961    fn test_dirfd_stat_at() {
962        let temp_dir = TempDir::new().unwrap();
963        let file_path = temp_dir.path().join("test_file");
964        fs::write(&file_path, "test content").unwrap();
965
966        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
967        let stat = dir_fd
968            .stat_at(OsStr::new("test_file"), SymlinkBehavior::Follow)
969            .unwrap();
970
971        assert!(stat.st_size > 0);
972        assert_eq!(stat.st_mode & libc::S_IFMT, libc::S_IFREG);
973    }
974
975    #[test]
976    fn test_dirfd_stat_at_symlink() {
977        let temp_dir = TempDir::new().unwrap();
978        let target_file = temp_dir.path().join("target");
979        let symlink_file = temp_dir.path().join("link");
980
981        fs::write(&target_file, "target content").unwrap();
982        symlink(&target_file, &symlink_file).unwrap();
983
984        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
985
986        // Follow symlinks
987        let stat_follow = dir_fd
988            .stat_at(OsStr::new("link"), SymlinkBehavior::Follow)
989            .unwrap();
990        assert_eq!(stat_follow.st_mode & libc::S_IFMT, libc::S_IFREG);
991
992        // Don't follow symlinks
993        let stat_nofollow = dir_fd
994            .stat_at(OsStr::new("link"), SymlinkBehavior::NoFollow)
995            .unwrap();
996        assert_eq!(stat_nofollow.st_mode & libc::S_IFMT, libc::S_IFLNK);
997    }
998
999    #[test]
1000    fn test_dirfd_fstat() {
1001        let temp_dir = TempDir::new().unwrap();
1002        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1003        let stat = dir_fd.fstat().unwrap();
1004
1005        assert_eq!(stat.st_mode & libc::S_IFMT, libc::S_IFDIR);
1006    }
1007
1008    #[test]
1009    fn test_dirfd_read_dir() {
1010        let temp_dir = TempDir::new().unwrap();
1011        let file1 = temp_dir.path().join("file1");
1012        let file2 = temp_dir.path().join("file2");
1013
1014        fs::write(&file1, "content1").unwrap();
1015        fs::write(&file2, "content2").unwrap();
1016
1017        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1018        let entries = dir_fd.read_dir().unwrap();
1019
1020        assert_eq!(entries.len(), 2);
1021        assert!(entries.contains(&OsString::from("file1")));
1022        assert!(entries.contains(&OsString::from("file2")));
1023    }
1024
1025    #[test]
1026    fn test_dirfd_unlink_at_file() {
1027        let temp_dir = TempDir::new().unwrap();
1028        let file_path = temp_dir.path().join("test_file");
1029        fs::write(&file_path, "test content").unwrap();
1030
1031        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1032        dir_fd.unlink_at(OsStr::new("test_file"), false).unwrap();
1033
1034        assert!(!file_path.exists());
1035    }
1036
1037    #[test]
1038    fn test_dirfd_unlink_at_directory() {
1039        let temp_dir = TempDir::new().unwrap();
1040        let subdir = temp_dir.path().join("empty_dir");
1041        fs::create_dir(&subdir).unwrap();
1042
1043        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1044        dir_fd.unlink_at(OsStr::new("empty_dir"), true).unwrap();
1045
1046        assert!(!subdir.exists());
1047    }
1048
1049    #[test]
1050    fn test_from_raw_fd() {
1051        let temp_dir = TempDir::new().unwrap();
1052        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1053
1054        // Duplicate the fd first so we don't have ownership conflicts
1055        let dup_fd = nix::unistd::dup(&dir_fd).unwrap();
1056        let from_raw_fd = DirFd::from_raw_fd(dup_fd.into_raw_fd()).unwrap();
1057
1058        // Both should refer to the same directory
1059        let stat1 = dir_fd.fstat().unwrap();
1060        let stat2 = from_raw_fd.fstat().unwrap();
1061        assert_eq!(stat1.st_ino, stat2.st_ino);
1062        assert_eq!(stat1.st_dev, stat2.st_dev);
1063    }
1064
1065    #[test]
1066    fn test_from_raw_fd_invalid() {
1067        let result = DirFd::from_raw_fd(-1);
1068        assert!(result.is_err());
1069    }
1070
1071    #[test]
1072    #[allow(clippy::unnecessary_cast)]
1073    fn test_file_info() {
1074        let temp_dir = TempDir::new().unwrap();
1075        let file_path = temp_dir.path().join("test_file");
1076        fs::write(&file_path, "test content").unwrap();
1077
1078        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1079        let stat = dir_fd
1080            .stat_at(OsStr::new("test_file"), SymlinkBehavior::Follow)
1081            .unwrap();
1082        let file_info = FileInfo::from_stat(&stat);
1083        assert_eq!(file_info.device(), stat.st_dev as u64);
1084        assert_eq!(file_info.inode(), stat.st_ino as u64);
1085    }
1086
1087    #[test]
1088    fn test_file_info_new() {
1089        let file_info = FileInfo::new(123, 456);
1090        assert_eq!(file_info.device(), 123);
1091        assert_eq!(file_info.inode(), 456);
1092    }
1093
1094    #[test]
1095    fn test_file_type() {
1096        // Test directory
1097        let dir_mode = libc::S_IFDIR | 0o755;
1098        let file_type = FileType::from_mode(dir_mode);
1099        assert_eq!(file_type, FileType::Directory);
1100        assert!(file_type.is_directory());
1101        assert!(!file_type.is_regular_file());
1102        assert!(!file_type.is_symlink());
1103
1104        // Test regular file
1105        let file_mode = libc::S_IFREG | 0o644;
1106        let file_type = FileType::from_mode(file_mode);
1107        assert_eq!(file_type, FileType::RegularFile);
1108        assert!(!file_type.is_directory());
1109        assert!(file_type.is_regular_file());
1110        assert!(!file_type.is_symlink());
1111
1112        // Test symlink
1113        let link_mode = libc::S_IFLNK | 0o777;
1114        let file_type = FileType::from_mode(link_mode);
1115        assert_eq!(file_type, FileType::Symlink);
1116        assert!(!file_type.is_directory());
1117        assert!(!file_type.is_regular_file());
1118        assert!(file_type.is_symlink());
1119    }
1120
1121    #[test]
1122    #[allow(clippy::unnecessary_cast)]
1123    fn test_metadata_wrapper() {
1124        let temp_dir = TempDir::new().unwrap();
1125        let file_path = temp_dir.path().join("test_file");
1126        fs::write(&file_path, "test content with some length").unwrap();
1127
1128        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1129        let metadata = dir_fd
1130            .metadata_at(OsStr::new("test_file"), SymlinkBehavior::Follow)
1131            .unwrap();
1132
1133        assert_eq!(metadata.file_type(), FileType::RegularFile);
1134        assert!(metadata.size() > 0);
1135        assert_eq!(metadata.mode() & libc::S_IFMT as u32, libc::S_IFREG as u32);
1136        assert_eq!(metadata.nlink(), 1);
1137
1138        assert!(metadata.size() > 0);
1139    }
1140
1141    #[test]
1142    fn test_metadata_directory() {
1143        let temp_dir = TempDir::new().unwrap();
1144        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1145        let metadata = dir_fd.metadata().unwrap();
1146
1147        assert_eq!(metadata.file_type(), FileType::Directory);
1148        assert!(metadata.file_type().is_directory());
1149    }
1150
1151    #[test]
1152    fn test_path_with_null_byte() {
1153        let path_with_null = OsString::from_vec(b"test\0file".to_vec());
1154        let temp_dir = TempDir::new().unwrap();
1155        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1156
1157        let result = dir_fd.open_subdir(&path_with_null, SymlinkBehavior::Follow);
1158        assert!(result.is_err());
1159        if let Err(e) = result {
1160            // Should be InvalidInput for null byte error
1161            assert_eq!(e.kind(), io::ErrorKind::InvalidInput);
1162        }
1163    }
1164
1165    #[test]
1166    fn test_error_chain() {
1167        let result = DirFd::open(
1168            "/nonexistent/deeply/nested/path".as_ref(),
1169            SymlinkBehavior::Follow,
1170        );
1171        assert!(result.is_err());
1172
1173        if let Err(e) = result {
1174            // Test that we get the proper underlying error
1175            let io_err: io::Error = e;
1176            assert!(
1177                io_err.kind() == io::ErrorKind::NotFound
1178                    || io_err.kind() == io::ErrorKind::PermissionDenied
1179            );
1180        }
1181    }
1182
1183    #[test]
1184    fn test_mkdir_at_creates_directory() {
1185        let temp_dir = TempDir::new().unwrap();
1186        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1187
1188        dir_fd.mkdir_at(OsStr::new("new_subdir"), 0o755).unwrap();
1189
1190        assert!(temp_dir.path().join("new_subdir").is_dir());
1191    }
1192
1193    #[test]
1194    fn test_mkdir_at_fails_if_exists() {
1195        let temp_dir = TempDir::new().unwrap();
1196        let subdir = temp_dir.path().join("existing");
1197        fs::create_dir(&subdir).unwrap();
1198
1199        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1200        let result = dir_fd.mkdir_at(OsStr::new("existing"), 0o755);
1201
1202        assert!(result.is_err());
1203    }
1204
1205    #[test]
1206    fn test_open_file_at_creates_file() {
1207        use std::io::Write;
1208
1209        let temp_dir = TempDir::new().unwrap();
1210        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1211
1212        let mut file = dir_fd.open_file_at(OsStr::new("new_file.txt")).unwrap();
1213        file.write_all(b"test content").unwrap();
1214
1215        let content = fs::read_to_string(temp_dir.path().join("new_file.txt")).unwrap();
1216        assert_eq!(content, "test content");
1217    }
1218
1219    #[test]
1220    fn test_open_file_at_truncates_existing() {
1221        use std::io::Write;
1222
1223        let temp_dir = TempDir::new().unwrap();
1224        let file_path = temp_dir.path().join("existing.txt");
1225        fs::write(&file_path, "old content that is longer").unwrap();
1226
1227        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1228        let mut file = dir_fd.open_file_at(OsStr::new("existing.txt")).unwrap();
1229        file.write_all(b"new").unwrap();
1230        drop(file);
1231
1232        let content = fs::read_to_string(&file_path).unwrap();
1233        assert_eq!(content, "new");
1234    }
1235
1236    #[test]
1237    fn test_create_dir_all_safe_creates_nested_dirs() {
1238        let temp_dir = TempDir::new().unwrap();
1239        let nested_path = temp_dir.path().join("a/b/c");
1240
1241        let dir_fd = create_dir_all_safe(&nested_path, 0o755).unwrap();
1242        assert!(dir_fd.as_raw_fd() >= 0);
1243        assert!(nested_path.is_dir());
1244    }
1245
1246    #[test]
1247    fn test_create_dir_all_safe_existing_path() {
1248        let temp_dir = TempDir::new().unwrap();
1249        let existing_path = temp_dir.path().join("existing");
1250        fs::create_dir(&existing_path).unwrap();
1251
1252        let dir_fd = create_dir_all_safe(&existing_path, 0o755).unwrap();
1253        assert!(dir_fd.as_raw_fd() >= 0);
1254    }
1255
1256    #[test]
1257    fn test_create_dir_all_safe_follows_symlink() {
1258        let temp_dir = TempDir::new().unwrap();
1259        let target_dir = temp_dir.path().join("target");
1260        fs::create_dir(&target_dir).unwrap();
1261
1262        // Create a symlink pointing to an existing directory
1263        let symlink_path = temp_dir.path().join("link");
1264        symlink(&target_dir, &symlink_path).unwrap();
1265        assert!(symlink_path.is_symlink());
1266
1267        // create_dir_all_safe should follow the symlink (GNU coreutils behavior)
1268        let dir_fd = create_dir_all_safe(&symlink_path, 0o755).unwrap();
1269        assert!(dir_fd.as_raw_fd() >= 0);
1270
1271        // Verify the symlink is preserved (not replaced with a real directory)
1272        assert!(symlink_path.is_symlink());
1273        assert!(symlink_path.is_dir()); // still resolves to a directory via the symlink
1274    }
1275
1276    #[test]
1277    fn test_create_dir_all_safe_fails_on_file() {
1278        let temp_dir = TempDir::new().unwrap();
1279        let file_path = temp_dir.path().join("file");
1280        fs::write(&file_path, "content").unwrap();
1281
1282        let result = create_dir_all_safe(&file_path, 0o755);
1283        assert!(result.is_err());
1284    }
1285
1286    #[test]
1287    fn test_create_dir_all_safe_nested_symlink_in_path() {
1288        let temp_dir = TempDir::new().unwrap();
1289
1290        // Create: parent/link -> target
1291        // Then create: parent/link/subdir
1292        let parent = temp_dir.path().join("parent");
1293        let target = temp_dir.path().join("target");
1294        fs::create_dir(&parent).unwrap();
1295        fs::create_dir(&target).unwrap();
1296
1297        let symlink_in_path = parent.join("link");
1298        symlink(&target, &symlink_in_path).unwrap();
1299
1300        // Try to create parent/link/subdir - the symlink should be followed (GNU behavior)
1301        let nested_path = symlink_in_path.join("subdir");
1302        let dir_fd = create_dir_all_safe(&nested_path, 0o755).unwrap();
1303        assert!(dir_fd.as_raw_fd() >= 0);
1304
1305        // The symlink should be preserved, not replaced
1306        assert!(symlink_in_path.is_symlink());
1307        assert!(symlink_in_path.is_dir()); // resolves via symlink
1308
1309        // subdir should have been created inside the real target directory
1310        assert!(target.join("subdir").exists());
1311    }
1312
1313    #[test]
1314    fn test_open_subdir_nofollow_fails_on_symlink() {
1315        let temp_dir = TempDir::new().unwrap();
1316        let target = temp_dir.path().join("target");
1317        fs::create_dir(&target).unwrap();
1318
1319        let link = temp_dir.path().join("link");
1320        symlink(&target, &link).unwrap();
1321
1322        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1323
1324        // With follow_symlinks=true, should succeed
1325        let result_follow = dir_fd.open_subdir(OsStr::new("link"), SymlinkBehavior::Follow);
1326        assert!(result_follow.is_ok());
1327
1328        // With follow_symlinks=false, should fail (ELOOP or ENOTDIR)
1329        let result_nofollow = dir_fd.open_subdir(OsStr::new("link"), SymlinkBehavior::NoFollow);
1330        assert!(result_nofollow.is_err());
1331    }
1332
1333    /// Verify that chmod_at with NoFollow does not change the symlink target's mode.
1334    /// This test demonstrates that the TOCTOU race in recursive chmod is closed:
1335    /// chmod on a symlink entry should not affect the target file.
1336    #[test]
1337    fn test_chmod_at_nofollow_preserves_target_mode() {
1338        let temp_dir = TempDir::new().unwrap();
1339
1340        // Create a sentinel file outside the traversal directory
1341        let sentinel = temp_dir.path().join("sentinel");
1342        fs::write(&sentinel, "victim").unwrap();
1343        let sentinel_mode = fs::symlink_metadata(&sentinel).unwrap().mode();
1344
1345        // Create a subdirectory with a symlink pointing to the sentinel
1346        let subdir = temp_dir.path().join("subdir");
1347        fs::create_dir(&subdir).unwrap();
1348        let link = subdir.join("link");
1349        symlink(&sentinel, &link).unwrap();
1350
1351        // Open the subdirectory and chmod the symlink entry with NoFollow
1352        let dir_fd = DirFd::open(&subdir, SymlinkBehavior::Follow).unwrap();
1353        let result = dir_fd.chmod_at(OsStr::new("link"), 0o777, SymlinkBehavior::NoFollow);
1354
1355        // On Linux 6.6+ (fchmodat2), the chmod should succeed without affecting the target.
1356        // On older kernels, fchmodat with AT_SYMLINK_NOFOLLOW returns EOPNOTSUPP/ENOTSUP,
1357        // which is acceptable — the important thing is the target is NOT modified.
1358        if let Ok(()) = result {
1359            // fchmodat2 succeeded: verify sentinel mode is unchanged
1360            let new_sentinel_mode = fs::symlink_metadata(&sentinel).unwrap().mode();
1361            assert_eq!(
1362                new_sentinel_mode, sentinel_mode,
1363                "sentinel mode should not change when chmod'ing symlink with NoFollow"
1364            );
1365        }
1366        // If result is Err (EOPNOTSUPP on old kernels), the target is also unchanged,
1367        // which is the correct behavior — no silent modification.
1368    }
1369
1370    #[test]
1371    fn test_open_nofollow_fails_on_symlink() {
1372        let temp_dir = TempDir::new().unwrap();
1373        let target = temp_dir.path().join("target");
1374        fs::create_dir(&target).unwrap();
1375
1376        let link = temp_dir.path().join("link");
1377        symlink(&target, &link).unwrap();
1378
1379        // With follow_symlinks=true, should succeed
1380        let result_follow = DirFd::open(&link, SymlinkBehavior::Follow);
1381        assert!(result_follow.is_ok());
1382
1383        // With follow_symlinks=false, should fail
1384        let result_nofollow = DirFd::open(&link, SymlinkBehavior::NoFollow);
1385        assert!(result_nofollow.is_err());
1386    }
1387}