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
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, IntoRawFd, 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 flags = if symlink_behavior.should_follow() {
297            FchmodatFlags::FollowSymlink
298        } else {
299            FchmodatFlags::NoFollowSymlink
300        };
301
302        let mode = Mode::from_bits_truncate(mode as libc::mode_t);
303
304        let name_cstr =
305            CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
306
307        fchmodat(&self.fd, name_cstr.as_c_str(), mode, flags)
308            .map_err(|e| io::Error::from_raw_os_error(e as i32))?;
309
310        Ok(())
311    }
312
313    /// Change mode of this directory
314    pub fn fchmod(&self, mode: u32) -> io::Result<()> {
315        let mode = Mode::from_bits_truncate(mode as libc::mode_t);
316
317        nix::sys::stat::fchmod(&self.fd, mode)
318            .map_err(|e| io::Error::from_raw_os_error(e as i32))?;
319
320        Ok(())
321    }
322
323    /// Create a directory relative to this directory
324    pub fn mkdir_at(&self, name: &OsStr, mode: u32) -> io::Result<()> {
325        let name_cstr =
326            CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
327        let mode = Mode::from_bits_truncate(mode as libc::mode_t);
328
329        if let Err(e) = mkdirat(self.fd.as_fd(), name_cstr.as_c_str(), mode) {
330            let err = io::Error::from_raw_os_error(e as i32);
331            return Err(SafeTraversalError::OpenFailed {
332                path: name.into(),
333                source: err,
334            }
335            .into());
336        }
337        Ok(())
338    }
339
340    /// Open a file for writing relative to this directory
341    /// Creates the file if it doesn't exist, truncates if it does
342    pub fn open_file_at(&self, name: &OsStr) -> io::Result<fs::File> {
343        let name_cstr =
344            CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
345        let flags = OFlag::O_CREAT | OFlag::O_WRONLY | OFlag::O_TRUNC | OFlag::O_CLOEXEC;
346        let mode = Mode::from_bits_truncate(0o666); // Default file permissions
347
348        let fd: OwnedFd = openat(self.fd.as_fd(), name_cstr.as_c_str(), flags, mode)
349            .map_err(|e| io::Error::from_raw_os_error(e as i32))?;
350
351        // Convert OwnedFd to raw fd and create File
352        let raw_fd = fd.into_raw_fd();
353        Ok(unsafe { fs::File::from_raw_fd(raw_fd) })
354    }
355
356    /// Create a DirFd from an existing file descriptor (takes ownership)
357    pub fn from_raw_fd(fd: RawFd) -> io::Result<Self> {
358        if fd < 0 {
359            return Err(io::Error::new(
360                io::ErrorKind::InvalidInput,
361                translate!("safe-traversal-error-invalid-fd"),
362            ));
363        }
364        // SAFETY: We've verified fd >= 0, and the caller is transferring ownership
365        let owned_fd = unsafe { OwnedFd::from_raw_fd(fd) };
366        Ok(Self { fd: owned_fd })
367    }
368}
369
370/// Find the deepest existing real directory ancestor for a path.
371///
372/// Returns the existing ancestor path and a list of components that need to be created.
373/// Uses `symlink_metadata` to detect symlinks - symlinks are NOT followed and are
374/// treated as components that need to be created/replaced.
375fn find_existing_ancestor(path: &Path) -> io::Result<(PathBuf, Vec<OsString>)> {
376    let mut current = path.to_path_buf();
377    let mut components: Vec<OsString> = Vec::new();
378
379    loop {
380        // Use symlink_metadata to NOT follow symlinks
381        match fs::symlink_metadata(&current) {
382            Ok(meta) => {
383                if meta.is_dir() && !meta.file_type().is_symlink() {
384                    // Found a real directory (not a symlink to a directory)
385                    components.reverse();
386                    return Ok((current, components));
387                }
388                // It's a symlink, file, or other non-directory - treat as needing creation
389                // This ensures symlinks get replaced by open_or_create_subdir
390                if let Some(file_name) = current.file_name() {
391                    components.push(file_name.to_os_string());
392                }
393                if let Some(parent) = current.parent() {
394                    if parent.as_os_str().is_empty() {
395                        // Reached empty parent (for relative paths), use "."
396                        components.reverse();
397                        return Ok((PathBuf::from("."), components));
398                    }
399                    current = parent.to_path_buf();
400                } else {
401                    // Reached filesystem root
402                    let root = if path.is_absolute() {
403                        PathBuf::from("/")
404                    } else {
405                        PathBuf::from(".")
406                    };
407                    components.reverse();
408                    return Ok((root, components));
409                }
410            }
411            Err(e) if e.kind() == io::ErrorKind::NotFound => {
412                // Doesn't exist, record component and move up to parent
413                if let Some(file_name) = current.file_name() {
414                    components.push(file_name.to_os_string());
415                }
416                if let Some(parent) = current.parent() {
417                    if parent.as_os_str().is_empty() {
418                        // Reached empty parent (for relative paths), use "."
419                        components.reverse();
420                        return Ok((PathBuf::from("."), components));
421                    }
422                    current = parent.to_path_buf();
423                } else {
424                    // Reached filesystem root
425                    let root = if path.is_absolute() {
426                        PathBuf::from("/")
427                    } else {
428                        PathBuf::from(".")
429                    };
430                    components.reverse();
431                    return Ok((root, components));
432                }
433            }
434            Err(e) => return Err(e),
435        }
436    }
437}
438
439/// Open or create a subdirectory using fd-based operations only.
440///
441/// This is a helper function for `create_dir_all_safe` that handles a single
442/// path component. If a symlink exists where a directory should be, it is
443/// removed and replaced with a real directory.
444///
445/// # Arguments
446/// * `parent_fd` - The parent directory file descriptor
447/// * `name` - The name of the subdirectory to open or create
448/// * `mode` - The mode to use when creating a new directory
449///
450/// # Returns
451/// A DirFd for the subdirectory
452fn open_or_create_subdir(parent_fd: &DirFd, name: &OsStr, mode: u32) -> io::Result<DirFd> {
453    match parent_fd.stat_at(name, SymlinkBehavior::NoFollow) {
454        Ok(stat) => {
455            let file_type = (stat.st_mode as libc::mode_t) & libc::S_IFMT;
456            match file_type {
457                libc::S_IFDIR => parent_fd.open_subdir(name, SymlinkBehavior::NoFollow),
458                libc::S_IFLNK => {
459                    parent_fd.unlink_at(name, false)?;
460                    parent_fd.mkdir_at(name, mode)?;
461                    parent_fd.open_subdir(name, SymlinkBehavior::NoFollow)
462                }
463                _ => Err(io::Error::new(
464                    io::ErrorKind::AlreadyExists,
465                    format!(
466                        "path component exists but is not a directory: {}",
467                        name.display()
468                    ),
469                )),
470            }
471        }
472        Err(e) if e.kind() == io::ErrorKind::NotFound => {
473            parent_fd.mkdir_at(name, mode)?;
474            parent_fd.open_subdir(name, SymlinkBehavior::NoFollow)
475        }
476        Err(e) => Err(e),
477    }
478}
479
480/// Safely create all parent directories for a path using directory file descriptors.
481/// This prevents symlink race conditions by anchoring all operations to directory fds.
482///
483/// # Security
484/// This function prevents TOCTOU race conditions by:
485/// 1. Finding the deepest existing ancestor directory (path-based, but safe since it exists)
486/// 2. Opening that ancestor with a file descriptor
487/// 3. Creating all new directories using fd-based operations (mkdirat, openat with O_NOFOLLOW)
488///
489/// Once we have a fd for an existing ancestor, all subsequent operations use that fd
490/// as the anchor. If an attacker replaces a newly-created directory with a symlink,
491/// our openat with O_NOFOLLOW will fail, preventing the attack.
492///
493/// Existing symlinks in the path (like /var -> /private/var on macOS) are followed
494/// when finding the ancestor, which is safe since they already exist.
495///
496/// # Arguments
497/// * `path` - The path to create directories for
498/// * `mode` - The mode to use when creating new directories (e.g., 0o755). The actual
499///   mode will be modified by the process umask.
500///
501/// # Returns
502/// A DirFd for the final created directory, or the first existing parent if
503/// all directories already exist.
504#[cfg(unix)]
505pub fn create_dir_all_safe(path: &Path, mode: u32) -> io::Result<DirFd> {
506    let (existing_ancestor, components_to_create) = find_existing_ancestor(path)?;
507    let mut dir_fd = DirFd::open(&existing_ancestor, SymlinkBehavior::Follow)?;
508
509    for component in &components_to_create {
510        dir_fd = open_or_create_subdir(&dir_fd, component.as_os_str(), mode)?;
511    }
512
513    Ok(dir_fd)
514}
515
516impl AsRawFd for DirFd {
517    fn as_raw_fd(&self) -> RawFd {
518        self.fd.as_raw_fd()
519    }
520}
521
522impl AsFd for DirFd {
523    fn as_fd(&self) -> BorrowedFd<'_> {
524        self.fd.as_fd()
525    }
526}
527
528/// File information for tracking inodes
529#[derive(Debug, Clone, Hash, PartialEq, Eq)]
530pub struct FileInfo {
531    pub dev: u64,
532    pub ino: u64,
533}
534
535impl FileInfo {
536    pub fn from_stat(stat: &libc::stat) -> Self {
537        // Allow unnecessary cast because st_dev and st_ino have different types on different platforms
538        #[allow(clippy::unnecessary_cast)]
539        Self {
540            dev: stat.st_dev as u64,
541            ino: stat.st_ino as u64,
542        }
543    }
544
545    /// Create FileInfo from device and inode numbers
546    pub fn new(dev: u64, ino: u64) -> Self {
547        Self { dev, ino }
548    }
549
550    /// Get the device number
551    pub fn device(&self) -> u64 {
552        self.dev
553    }
554
555    /// Get the inode number
556    pub fn inode(&self) -> u64 {
557        self.ino
558    }
559}
560
561/// File type enumeration for better type safety
562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
563pub enum FileType {
564    Directory,
565    RegularFile,
566    Symlink,
567    Other,
568}
569
570impl FileType {
571    pub fn from_mode(mode: libc::mode_t) -> Self {
572        match mode & libc::S_IFMT {
573            libc::S_IFDIR => Self::Directory,
574            libc::S_IFREG => Self::RegularFile,
575            libc::S_IFLNK => Self::Symlink,
576            _ => Self::Other,
577        }
578    }
579
580    pub fn is_directory(self) -> bool {
581        matches!(self, Self::Directory)
582    }
583
584    pub fn is_regular_file(self) -> bool {
585        matches!(self, Self::RegularFile)
586    }
587
588    pub fn is_symlink(self) -> bool {
589        matches!(self, Self::Symlink)
590    }
591}
592
593/// Metadata wrapper for safer access to file information
594#[derive(Debug, Clone)]
595pub struct Metadata {
596    stat: FileStat,
597}
598
599impl Metadata {
600    pub fn from_stat(stat: FileStat) -> Self {
601        Self { stat }
602    }
603
604    pub fn file_type(&self) -> FileType {
605        FileType::from_mode(self.stat.st_mode as libc::mode_t)
606    }
607
608    pub fn file_info(&self) -> FileInfo {
609        FileInfo::from_stat(&self.stat)
610    }
611
612    // st_size type varies by platform (i64 vs u64)
613    #[allow(clippy::unnecessary_cast)]
614    pub fn size(&self) -> u64 {
615        self.stat.st_size as u64
616    }
617
618    // st_mode type varies by platform (u16 on macOS, u32 on Linux)
619    #[allow(clippy::unnecessary_cast)]
620    pub fn mode(&self) -> u32 {
621        self.stat.st_mode as u32
622    }
623
624    pub fn nlink(&self) -> u64 {
625        // st_nlink type varies by platform (u16 on FreeBSD, u32/u64 on others)
626        #[allow(clippy::unnecessary_cast)]
627        {
628            self.stat.st_nlink as u64
629        }
630    }
631
632    /// Compatibility methods to match std::fs::Metadata interface
633    pub fn is_dir(&self) -> bool {
634        self.file_type().is_directory()
635    }
636
637    pub fn len(&self) -> u64 {
638        self.size()
639    }
640
641    pub fn is_empty(&self) -> bool {
642        self.len() == 0
643    }
644}
645
646// Add MetadataExt trait implementation for compatibility
647impl std::os::unix::fs::MetadataExt for Metadata {
648    // st_dev type varies by platform (i32 on macOS, u64 on Linux)
649    #[allow(clippy::unnecessary_cast)]
650    fn dev(&self) -> u64 {
651        self.stat.st_dev as u64
652    }
653
654    fn ino(&self) -> u64 {
655        // st_ino type varies by platform (u32 on FreeBSD, u64 on Linux)
656        #[allow(clippy::unnecessary_cast)]
657        {
658            self.stat.st_ino as u64
659        }
660    }
661
662    // st_mode type varies by platform (u16 on macOS, u32 on Linux)
663    #[allow(clippy::unnecessary_cast)]
664    fn mode(&self) -> u32 {
665        self.stat.st_mode as u32
666    }
667
668    fn nlink(&self) -> u64 {
669        // st_nlink type varies by platform (u16 on FreeBSD, u32/u64 on others)
670        #[allow(clippy::unnecessary_cast)]
671        {
672            self.stat.st_nlink as u64
673        }
674    }
675
676    fn uid(&self) -> u32 {
677        self.stat.st_uid
678    }
679
680    fn gid(&self) -> u32 {
681        self.stat.st_gid
682    }
683
684    // st_rdev type varies by platform (i32 on macOS, u64 on Linux)
685    #[allow(clippy::unnecessary_cast)]
686    fn rdev(&self) -> u64 {
687        self.stat.st_rdev as u64
688    }
689
690    // st_size type varies by platform (i64 on some platforms, u64 on others)
691    #[allow(clippy::unnecessary_cast)]
692    fn size(&self) -> u64 {
693        self.stat.st_size as u64
694    }
695
696    fn atime(&self) -> i64 {
697        #[cfg(target_pointer_width = "32")]
698        {
699            self.stat.st_atime.into()
700        }
701        #[cfg(not(target_pointer_width = "32"))]
702        {
703            self.stat.st_atime
704        }
705    }
706
707    fn atime_nsec(&self) -> i64 {
708        #[cfg(target_os = "netbsd")]
709        {
710            self.stat.st_atimensec
711        }
712
713        #[cfg(not(target_os = "netbsd"))]
714        {
715            #[cfg(target_pointer_width = "32")]
716            {
717                self.stat.st_atime_nsec.into()
718            }
719            #[cfg(not(target_pointer_width = "32"))]
720            {
721                self.stat.st_atime_nsec
722            }
723        }
724    }
725
726    fn mtime(&self) -> i64 {
727        #[cfg(target_pointer_width = "32")]
728        {
729            self.stat.st_mtime.into()
730        }
731        #[cfg(not(target_pointer_width = "32"))]
732        {
733            self.stat.st_mtime
734        }
735    }
736
737    fn mtime_nsec(&self) -> i64 {
738        #[cfg(target_os = "netbsd")]
739        {
740            self.stat.st_mtimensec
741        }
742
743        #[cfg(not(target_os = "netbsd"))]
744        {
745            #[cfg(target_pointer_width = "32")]
746            {
747                self.stat.st_mtime_nsec.into()
748            }
749            #[cfg(not(target_pointer_width = "32"))]
750            {
751                self.stat.st_mtime_nsec
752            }
753        }
754    }
755
756    fn ctime(&self) -> i64 {
757        #[cfg(target_pointer_width = "32")]
758        {
759            self.stat.st_ctime.into()
760        }
761        #[cfg(not(target_pointer_width = "32"))]
762        {
763            self.stat.st_ctime
764        }
765    }
766
767    fn ctime_nsec(&self) -> i64 {
768        #[cfg(target_os = "netbsd")]
769        {
770            self.stat.st_ctimensec
771        }
772
773        #[cfg(not(target_os = "netbsd"))]
774        {
775            #[cfg(target_pointer_width = "32")]
776            {
777                self.stat.st_ctime_nsec.into()
778            }
779            #[cfg(not(target_pointer_width = "32"))]
780            {
781                self.stat.st_ctime_nsec
782            }
783        }
784    }
785
786    // st_blksize type varies by platform (i32/i64/u32/u64 depending on platform)
787    #[allow(clippy::unnecessary_cast)]
788    fn blksize(&self) -> u64 {
789        self.stat.st_blksize as u64
790    }
791
792    // st_blocks type varies by platform (i64 on some platforms, u64 on others)
793    #[allow(clippy::unnecessary_cast)]
794    fn blocks(&self) -> u64 {
795        self.stat.st_blocks as u64
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use std::fs;
803    use std::os::unix::fs::symlink;
804    use std::os::unix::io::IntoRawFd;
805    use tempfile::TempDir;
806
807    #[test]
808    fn test_dirfd_open_valid_directory() {
809        let temp_dir = TempDir::new().unwrap();
810        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
811        assert!(dir_fd.as_raw_fd() >= 0);
812    }
813
814    #[test]
815    fn test_dirfd_open_nonexistent_directory() {
816        let result = DirFd::open("/nonexistent/path".as_ref(), SymlinkBehavior::Follow);
817        assert!(result.is_err());
818        if let Err(e) = result {
819            // The error should be the underlying io::Error
820            assert!(
821                e.kind() == io::ErrorKind::NotFound || e.kind() == io::ErrorKind::PermissionDenied
822            );
823        }
824    }
825
826    #[test]
827    fn test_dirfd_open_file_not_directory() {
828        let temp_dir = TempDir::new().unwrap();
829        let file_path = temp_dir.path().join("test_file");
830        fs::write(&file_path, "test content").unwrap();
831
832        let result = DirFd::open(&file_path, SymlinkBehavior::Follow);
833        assert!(result.is_err());
834    }
835
836    #[test]
837    fn test_dirfd_open_subdir() {
838        let temp_dir = TempDir::new().unwrap();
839        let subdir = temp_dir.path().join("subdir");
840        fs::create_dir(&subdir).unwrap();
841
842        let parent_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
843        let subdir_fd = parent_fd
844            .open_subdir(OsStr::new("subdir"), SymlinkBehavior::Follow)
845            .unwrap();
846        assert!(subdir_fd.as_raw_fd() >= 0);
847    }
848
849    #[test]
850    fn test_dirfd_open_nonexistent_subdir() {
851        let temp_dir = TempDir::new().unwrap();
852        let parent_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
853
854        let result = parent_fd.open_subdir(OsStr::new("nonexistent"), SymlinkBehavior::Follow);
855        assert!(result.is_err());
856    }
857
858    #[test]
859    fn test_dirfd_stat_at() {
860        let temp_dir = TempDir::new().unwrap();
861        let file_path = temp_dir.path().join("test_file");
862        fs::write(&file_path, "test content").unwrap();
863
864        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
865        let stat = dir_fd
866            .stat_at(OsStr::new("test_file"), SymlinkBehavior::Follow)
867            .unwrap();
868
869        assert!(stat.st_size > 0);
870        assert_eq!(stat.st_mode & libc::S_IFMT, libc::S_IFREG);
871    }
872
873    #[test]
874    fn test_dirfd_stat_at_symlink() {
875        let temp_dir = TempDir::new().unwrap();
876        let target_file = temp_dir.path().join("target");
877        let symlink_file = temp_dir.path().join("link");
878
879        fs::write(&target_file, "target content").unwrap();
880        symlink(&target_file, &symlink_file).unwrap();
881
882        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
883
884        // Follow symlinks
885        let stat_follow = dir_fd
886            .stat_at(OsStr::new("link"), SymlinkBehavior::Follow)
887            .unwrap();
888        assert_eq!(stat_follow.st_mode & libc::S_IFMT, libc::S_IFREG);
889
890        // Don't follow symlinks
891        let stat_nofollow = dir_fd
892            .stat_at(OsStr::new("link"), SymlinkBehavior::NoFollow)
893            .unwrap();
894        assert_eq!(stat_nofollow.st_mode & libc::S_IFMT, libc::S_IFLNK);
895    }
896
897    #[test]
898    fn test_dirfd_fstat() {
899        let temp_dir = TempDir::new().unwrap();
900        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
901        let stat = dir_fd.fstat().unwrap();
902
903        assert_eq!(stat.st_mode & libc::S_IFMT, libc::S_IFDIR);
904    }
905
906    #[test]
907    fn test_dirfd_read_dir() {
908        let temp_dir = TempDir::new().unwrap();
909        let file1 = temp_dir.path().join("file1");
910        let file2 = temp_dir.path().join("file2");
911
912        fs::write(&file1, "content1").unwrap();
913        fs::write(&file2, "content2").unwrap();
914
915        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
916        let entries = dir_fd.read_dir().unwrap();
917
918        assert_eq!(entries.len(), 2);
919        assert!(entries.contains(&OsString::from("file1")));
920        assert!(entries.contains(&OsString::from("file2")));
921    }
922
923    #[test]
924    fn test_dirfd_unlink_at_file() {
925        let temp_dir = TempDir::new().unwrap();
926        let file_path = temp_dir.path().join("test_file");
927        fs::write(&file_path, "test content").unwrap();
928
929        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
930        dir_fd.unlink_at(OsStr::new("test_file"), false).unwrap();
931
932        assert!(!file_path.exists());
933    }
934
935    #[test]
936    fn test_dirfd_unlink_at_directory() {
937        let temp_dir = TempDir::new().unwrap();
938        let subdir = temp_dir.path().join("empty_dir");
939        fs::create_dir(&subdir).unwrap();
940
941        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
942        dir_fd.unlink_at(OsStr::new("empty_dir"), true).unwrap();
943
944        assert!(!subdir.exists());
945    }
946
947    #[test]
948    fn test_from_raw_fd() {
949        let temp_dir = TempDir::new().unwrap();
950        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
951
952        // Duplicate the fd first so we don't have ownership conflicts
953        let dup_fd = nix::unistd::dup(&dir_fd).unwrap();
954        let from_raw_fd = DirFd::from_raw_fd(dup_fd.into_raw_fd()).unwrap();
955
956        // Both should refer to the same directory
957        let stat1 = dir_fd.fstat().unwrap();
958        let stat2 = from_raw_fd.fstat().unwrap();
959        assert_eq!(stat1.st_ino, stat2.st_ino);
960        assert_eq!(stat1.st_dev, stat2.st_dev);
961    }
962
963    #[test]
964    fn test_from_raw_fd_invalid() {
965        let result = DirFd::from_raw_fd(-1);
966        assert!(result.is_err());
967    }
968
969    #[test]
970    #[allow(clippy::unnecessary_cast)]
971    fn test_file_info() {
972        let temp_dir = TempDir::new().unwrap();
973        let file_path = temp_dir.path().join("test_file");
974        fs::write(&file_path, "test content").unwrap();
975
976        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
977        let stat = dir_fd
978            .stat_at(OsStr::new("test_file"), SymlinkBehavior::Follow)
979            .unwrap();
980        let file_info = FileInfo::from_stat(&stat);
981        assert_eq!(file_info.device(), stat.st_dev as u64);
982        assert_eq!(file_info.inode(), stat.st_ino as u64);
983    }
984
985    #[test]
986    fn test_file_info_new() {
987        let file_info = FileInfo::new(123, 456);
988        assert_eq!(file_info.device(), 123);
989        assert_eq!(file_info.inode(), 456);
990    }
991
992    #[test]
993    fn test_file_type() {
994        // Test directory
995        let dir_mode = libc::S_IFDIR | 0o755;
996        let file_type = FileType::from_mode(dir_mode);
997        assert_eq!(file_type, FileType::Directory);
998        assert!(file_type.is_directory());
999        assert!(!file_type.is_regular_file());
1000        assert!(!file_type.is_symlink());
1001
1002        // Test regular file
1003        let file_mode = libc::S_IFREG | 0o644;
1004        let file_type = FileType::from_mode(file_mode);
1005        assert_eq!(file_type, FileType::RegularFile);
1006        assert!(!file_type.is_directory());
1007        assert!(file_type.is_regular_file());
1008        assert!(!file_type.is_symlink());
1009
1010        // Test symlink
1011        let link_mode = libc::S_IFLNK | 0o777;
1012        let file_type = FileType::from_mode(link_mode);
1013        assert_eq!(file_type, FileType::Symlink);
1014        assert!(!file_type.is_directory());
1015        assert!(!file_type.is_regular_file());
1016        assert!(file_type.is_symlink());
1017    }
1018
1019    #[test]
1020    #[allow(clippy::unnecessary_cast)]
1021    fn test_metadata_wrapper() {
1022        let temp_dir = TempDir::new().unwrap();
1023        let file_path = temp_dir.path().join("test_file");
1024        fs::write(&file_path, "test content with some length").unwrap();
1025
1026        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1027        let metadata = dir_fd
1028            .metadata_at(OsStr::new("test_file"), SymlinkBehavior::Follow)
1029            .unwrap();
1030
1031        assert_eq!(metadata.file_type(), FileType::RegularFile);
1032        assert!(metadata.size() > 0);
1033        assert_eq!(metadata.mode() & libc::S_IFMT as u32, libc::S_IFREG as u32);
1034        assert_eq!(metadata.nlink(), 1);
1035
1036        assert!(metadata.size() > 0);
1037    }
1038
1039    #[test]
1040    fn test_metadata_directory() {
1041        let temp_dir = TempDir::new().unwrap();
1042        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1043        let metadata = dir_fd.metadata().unwrap();
1044
1045        assert_eq!(metadata.file_type(), FileType::Directory);
1046        assert!(metadata.file_type().is_directory());
1047    }
1048
1049    #[test]
1050    fn test_path_with_null_byte() {
1051        let path_with_null = OsString::from_vec(b"test\0file".to_vec());
1052        let temp_dir = TempDir::new().unwrap();
1053        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1054
1055        let result = dir_fd.open_subdir(&path_with_null, SymlinkBehavior::Follow);
1056        assert!(result.is_err());
1057        if let Err(e) = result {
1058            // Should be InvalidInput for null byte error
1059            assert_eq!(e.kind(), io::ErrorKind::InvalidInput);
1060        }
1061    }
1062
1063    #[test]
1064    fn test_error_chain() {
1065        let result = DirFd::open(
1066            "/nonexistent/deeply/nested/path".as_ref(),
1067            SymlinkBehavior::Follow,
1068        );
1069        assert!(result.is_err());
1070
1071        if let Err(e) = result {
1072            // Test that we get the proper underlying error
1073            let io_err: io::Error = e;
1074            assert!(
1075                io_err.kind() == io::ErrorKind::NotFound
1076                    || io_err.kind() == io::ErrorKind::PermissionDenied
1077            );
1078        }
1079    }
1080
1081    #[test]
1082    fn test_mkdir_at_creates_directory() {
1083        let temp_dir = TempDir::new().unwrap();
1084        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1085
1086        dir_fd.mkdir_at(OsStr::new("new_subdir"), 0o755).unwrap();
1087
1088        assert!(temp_dir.path().join("new_subdir").is_dir());
1089    }
1090
1091    #[test]
1092    fn test_mkdir_at_fails_if_exists() {
1093        let temp_dir = TempDir::new().unwrap();
1094        let subdir = temp_dir.path().join("existing");
1095        fs::create_dir(&subdir).unwrap();
1096
1097        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1098        let result = dir_fd.mkdir_at(OsStr::new("existing"), 0o755);
1099
1100        assert!(result.is_err());
1101    }
1102
1103    #[test]
1104    fn test_open_file_at_creates_file() {
1105        let temp_dir = TempDir::new().unwrap();
1106        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1107
1108        let mut file = dir_fd.open_file_at(OsStr::new("new_file.txt")).unwrap();
1109        use std::io::Write;
1110        file.write_all(b"test content").unwrap();
1111
1112        let content = fs::read_to_string(temp_dir.path().join("new_file.txt")).unwrap();
1113        assert_eq!(content, "test content");
1114    }
1115
1116    #[test]
1117    fn test_open_file_at_truncates_existing() {
1118        let temp_dir = TempDir::new().unwrap();
1119        let file_path = temp_dir.path().join("existing.txt");
1120        fs::write(&file_path, "old content that is longer").unwrap();
1121
1122        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1123        let mut file = dir_fd.open_file_at(OsStr::new("existing.txt")).unwrap();
1124        use std::io::Write;
1125        file.write_all(b"new").unwrap();
1126        drop(file);
1127
1128        let content = fs::read_to_string(&file_path).unwrap();
1129        assert_eq!(content, "new");
1130    }
1131
1132    #[test]
1133    fn test_create_dir_all_safe_creates_nested_dirs() {
1134        let temp_dir = TempDir::new().unwrap();
1135        let nested_path = temp_dir.path().join("a/b/c");
1136
1137        let dir_fd = create_dir_all_safe(&nested_path, 0o755).unwrap();
1138        assert!(dir_fd.as_raw_fd() >= 0);
1139        assert!(nested_path.is_dir());
1140    }
1141
1142    #[test]
1143    fn test_create_dir_all_safe_existing_path() {
1144        let temp_dir = TempDir::new().unwrap();
1145        let existing_path = temp_dir.path().join("existing");
1146        fs::create_dir(&existing_path).unwrap();
1147
1148        let dir_fd = create_dir_all_safe(&existing_path, 0o755).unwrap();
1149        assert!(dir_fd.as_raw_fd() >= 0);
1150    }
1151
1152    #[test]
1153    fn test_create_dir_all_safe_replaces_symlink() {
1154        let temp_dir = TempDir::new().unwrap();
1155        let target_dir = temp_dir.path().join("target");
1156        fs::create_dir(&target_dir).unwrap();
1157
1158        // Create a symlink where we want to create a directory
1159        let symlink_path = temp_dir.path().join("link_to_replace");
1160        symlink(&target_dir, &symlink_path).unwrap();
1161        assert!(symlink_path.is_symlink());
1162
1163        // create_dir_all_safe should replace the symlink with a real directory
1164        let dir_fd = create_dir_all_safe(&symlink_path, 0o755).unwrap();
1165        assert!(dir_fd.as_raw_fd() >= 0);
1166
1167        // Verify the symlink was replaced with a real directory
1168        assert!(symlink_path.is_dir());
1169        assert!(!symlink_path.is_symlink());
1170    }
1171
1172    #[test]
1173    fn test_create_dir_all_safe_fails_on_file() {
1174        let temp_dir = TempDir::new().unwrap();
1175        let file_path = temp_dir.path().join("file");
1176        fs::write(&file_path, "content").unwrap();
1177
1178        let result = create_dir_all_safe(&file_path, 0o755);
1179        assert!(result.is_err());
1180    }
1181
1182    #[test]
1183    fn test_create_dir_all_safe_nested_symlink_in_path() {
1184        let temp_dir = TempDir::new().unwrap();
1185
1186        // Create: parent/symlink -> target
1187        // Then try to create: parent/symlink/subdir
1188        let parent = temp_dir.path().join("parent");
1189        let target = temp_dir.path().join("target");
1190        fs::create_dir(&parent).unwrap();
1191        fs::create_dir(&target).unwrap();
1192
1193        let symlink_in_path = parent.join("link");
1194        symlink(&target, &symlink_in_path).unwrap();
1195
1196        // Try to create parent/link/subdir - the symlink should be replaced
1197        let nested_path = symlink_in_path.join("subdir");
1198        let dir_fd = create_dir_all_safe(&nested_path, 0o755).unwrap();
1199        assert!(dir_fd.as_raw_fd() >= 0);
1200
1201        // The symlink should have been replaced with a real directory
1202        assert!(!symlink_in_path.is_symlink());
1203        assert!(symlink_in_path.is_dir());
1204        assert!(nested_path.is_dir());
1205
1206        // Target directory should not contain subdir (race attack prevented)
1207        assert!(!target.join("subdir").exists());
1208    }
1209
1210    #[test]
1211    fn test_open_subdir_nofollow_fails_on_symlink() {
1212        let temp_dir = TempDir::new().unwrap();
1213        let target = temp_dir.path().join("target");
1214        fs::create_dir(&target).unwrap();
1215
1216        let link = temp_dir.path().join("link");
1217        symlink(&target, &link).unwrap();
1218
1219        let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();
1220
1221        // With follow_symlinks=true, should succeed
1222        let result_follow = dir_fd.open_subdir(OsStr::new("link"), SymlinkBehavior::Follow);
1223        assert!(result_follow.is_ok());
1224
1225        // With follow_symlinks=false, should fail (ELOOP or ENOTDIR)
1226        let result_nofollow = dir_fd.open_subdir(OsStr::new("link"), SymlinkBehavior::NoFollow);
1227        assert!(result_nofollow.is_err());
1228    }
1229
1230    #[test]
1231    fn test_open_nofollow_fails_on_symlink() {
1232        let temp_dir = TempDir::new().unwrap();
1233        let target = temp_dir.path().join("target");
1234        fs::create_dir(&target).unwrap();
1235
1236        let link = temp_dir.path().join("link");
1237        symlink(&target, &link).unwrap();
1238
1239        // With follow_symlinks=true, should succeed
1240        let result_follow = DirFd::open(&link, SymlinkBehavior::Follow);
1241        assert!(result_follow.is_ok());
1242
1243        // With follow_symlinks=false, should fail
1244        let result_nofollow = DirFd::open(&link, SymlinkBehavior::NoFollow);
1245        assert!(result_nofollow.is_err());
1246    }
1247}