Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 51 additions & 41 deletions src/uu/chmod/src/chmod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<OsString>, 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
}
Expand Down
46 changes: 46 additions & 0 deletions src/uucore/src/lib/features/safe_traversal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,24 @@ impl DirFd {
Ok(Self { fd })
}

/// Duplicate this directory descriptor
pub fn try_clone(&self) -> io::Result<Self> {
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<Self>
where
I: IntoIterator<Item = &'a OsStr>,
{
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<FileStat> {
let name_cstr =
Expand Down Expand Up @@ -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();
Expand Down
53 changes: 52 additions & 1 deletion tests/by-util/test_chmod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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(&current);
for _ in 0..depth {
current.push("d");
at.mkdir(&current);
}

// 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
Expand Down
Loading