diff --git a/src/uu/chmod/src/chmod.rs b/src/uu/chmod/src/chmod.rs index d77378da453..09e56a312d1 100644 --- a/src/uu/chmod/src/chmod.rs +++ b/src/uu/chmod/src/chmod.rs @@ -475,7 +475,7 @@ impl Chmoder { if (!file_path.is_symlink() || should_follow_symlink) && file_path.is_dir() { match DirFd::open(file_path, SymlinkBehavior::Follow) { Ok(dir_fd) => { - r = self.safe_traverse_dir(&dir_fd, file_path).and(r); + r = self.safe_traverse_dir(dir_fd, file_path).and(r); } Err(err) => { // Handle permission denied errors with proper file path context @@ -491,57 +491,67 @@ impl Chmoder { } #[cfg(all(unix, not(target_os = "redox")))] - fn safe_traverse_dir(&self, dir_fd: &DirFd, dir_path: &Path) -> UResult<()> { + fn safe_traverse_dir(&self, root_fd: DirFd, root_path: &Path) -> UResult<()> { let mut r = Ok(()); - - let entries = dir_fd.read_dir()?; + // Depth-first traversal without recursive calls while keeping descriptor usage bounded. + let mut stack: Vec<(Vec, PathBuf)> = vec![(Vec::new(), root_path.to_path_buf())]; // Determine if we should follow symlinks (doesn't depend on entry_name) let should_follow_symlink = self.traverse_symlinks == TraverseSymlinks::All; - for entry_name in entries { - let entry_path = dir_path.join(&entry_name); + while let Some((relative_dir_components, dir_path)) = stack.pop() { + let dir_fd = match root_fd + .open_subdir_chain(relative_dir_components.iter().map(OsString::as_os_str)) + { + Ok(fd) => fd, + Err(err) => { + let error = if err.kind() == std::io::ErrorKind::PermissionDenied { + ChmodError::PermissionDenied(dir_path).into() + } else { + err.into() + }; + r = r.and(Err(error)); + continue; + } + }; - let dir_meta = dir_fd.metadata_at(&entry_name, should_follow_symlink.into()); - let Ok(meta) = dir_meta else { - // Handle permission denied with proper file path context - let e = dir_meta.unwrap_err(); - let error = if e.kind() == std::io::ErrorKind::PermissionDenied { - ChmodError::PermissionDenied(entry_path).into() - } else { - e.into() + let entries = dir_fd.read_dir()?; + + for entry_name in entries { + let entry_path = dir_path.join(&entry_name); + + let dir_meta = dir_fd.metadata_at(&entry_name, should_follow_symlink.into()); + let Ok(meta) = dir_meta else { + // Handle permission denied with proper file path context + let e = dir_meta.unwrap_err(); + let error = if e.kind() == std::io::ErrorKind::PermissionDenied { + ChmodError::PermissionDenied(entry_path).into() + } else { + e.into() + }; + r = r.and(Err(error)); + continue; }; - r = r.and(Err(error)); - continue; - }; - if entry_path.is_symlink() { - r = self - .handle_symlink_during_safe_recursion(&entry_path, dir_fd, &entry_name) - .and(r); - } else { - // For regular files and directories, chmod them - r = self - .safe_chmod_file(&entry_path, dir_fd, &entry_name, meta.mode() & 0o7777) - .and(r); - - // Recurse into subdirectories using the existing directory fd - if meta.is_dir() { - match dir_fd.open_subdir(&entry_name, SymlinkBehavior::Follow) { - Ok(child_dir_fd) => { - r = self.safe_traverse_dir(&child_dir_fd, &entry_path).and(r); - } - Err(err) => { - let error = if err.kind() == std::io::ErrorKind::PermissionDenied { - ChmodError::PermissionDenied(entry_path).into() - } else { - err.into() - }; - r = r.and(Err(error)); - } + if entry_path.is_symlink() { + r = self + .handle_symlink_during_safe_recursion(&entry_path, &dir_fd, &entry_name) + .and(r); + } else { + // For regular files and directories, chmod them + r = self + .safe_chmod_file(&entry_path, &dir_fd, &entry_name, meta.mode() & 0o7777) + .and(r); + + // Queue subdirectories; parent dir_fd can be dropped before processing children. + if meta.is_dir() { + let mut child_components = relative_dir_components.clone(); + child_components.push(entry_name.clone()); + stack.push((child_components, entry_path.clone())); } } } + // dir_fd is dropped here, releasing its file descriptor before the next depth step. } r } diff --git a/src/uucore/src/lib/features/safe_traversal.rs b/src/uucore/src/lib/features/safe_traversal.rs index d58d4d9fa10..060218117e0 100644 --- a/src/uucore/src/lib/features/safe_traversal.rs +++ b/src/uucore/src/lib/features/safe_traversal.rs @@ -172,6 +172,24 @@ impl DirFd { Ok(Self { fd }) } + /// Duplicate this directory descriptor + pub fn try_clone(&self) -> io::Result { + let fd = nix::unistd::dup(&self.fd).map_err(|e| io::Error::from_raw_os_error(e as i32))?; + Ok(Self { fd }) + } + + /// Open a nested subdirectory by traversing each component relative to this directory. + pub fn open_subdir_chain<'a, I>(&self, components: I) -> io::Result + where + I: IntoIterator, + { + let mut current = self.try_clone()?; + for component in components { + current = current.open_subdir(component, SymlinkBehavior::Follow)?; + } + Ok(current) + } + /// Get raw stat data for a file relative to this directory pub fn stat_at(&self, name: &OsStr, symlink_behavior: SymlinkBehavior) -> io::Result { let name_cstr = @@ -832,6 +850,34 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_dirfd_open_subdir_chain() { + let temp_dir = TempDir::new().unwrap(); + let nested = temp_dir.path().join("a").join("b"); + fs::create_dir_all(&nested).unwrap(); + + let root_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap(); + let chain = [OsStr::new("a"), OsStr::new("b")]; + let nested_fd = root_fd.open_subdir_chain(chain).unwrap(); + let stat = nested_fd.fstat().unwrap(); + + assert_eq!(stat.st_mode & libc::S_IFMT, libc::S_IFDIR); + } + + #[test] + fn test_dirfd_open_subdir_chain_empty() { + let temp_dir = TempDir::new().unwrap(); + let root_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap(); + let same_fd = root_fd + .open_subdir_chain(std::iter::empty::<&OsStr>()) + .unwrap(); + + let stat1 = root_fd.fstat().unwrap(); + let stat2 = same_fd.fstat().unwrap(); + assert_eq!(stat1.st_ino, stat2.st_ino); + assert_eq!(stat1.st_dev, stat2.st_dev); + } + #[test] fn test_dirfd_stat_at() { let temp_dir = TempDir::new().unwrap(); diff --git a/tests/by-util/test_chmod.rs b/tests/by-util/test_chmod.rs index a75324b7c67..63e2c182fb8 100644 --- a/tests/by-util/test_chmod.rs +++ b/tests/by-util/test_chmod.rs @@ -2,7 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (words) dirfd subdirs openat FDCWD +// spell-checker:ignore (words) dirfd subdirs openat FDCWD NOFILE getrlimit setrlimit rlim use std::fs::{OpenOptions, Permissions, metadata, set_permissions}; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; @@ -1408,6 +1408,57 @@ fn test_chmod_recursive_uses_dirfd_for_subdirs() { ); } +#[cfg(unix)] +#[test] +fn test_chmod_recursive_does_not_exhaust_fds() { + use rlimit::Resource; + use std::path::PathBuf; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + // Build a deep single-branch directory tree + let depth = 256; + let mut current = PathBuf::from("deep"); + at.mkdir(¤t); + for _ in 0..depth { + current.push("d"); + at.mkdir(¤t); + } + + // Constrain NOFILE only for the child process under test + scene + .ucmd() + .limit(Resource::NOFILE, 64, 64) + .arg("-R") + .arg("777") + .arg("deep") + .succeeds(); +} + +#[cfg(unix)] +#[test] +fn test_chmod_recursive_wide_tree_does_not_exhaust_fds() { + use rlimit::Resource; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("wide"); + for i in 0..256 { + at.mkdir(format!("wide/d{i}")); + } + + // Constrain NOFILE only for the child process under test + scene + .ucmd() + .limit(Resource::NOFILE, 64, 64) + .arg("-R") + .arg("777") + .arg("wide") + .succeeds(); +} + #[test] fn test_chmod_colored_output() { // Test colored help message