diff --git a/.vscode/cspell.dictionaries/workspace.wordlist.txt b/.vscode/cspell.dictionaries/workspace.wordlist.txt index 6762da55002..78bb78c480f 100644 --- a/.vscode/cspell.dictionaries/workspace.wordlist.txt +++ b/.vscode/cspell.dictionaries/workspace.wordlist.txt @@ -118,6 +118,8 @@ EBUSY EEXIST EINVAL EISDIR +EMFILE +ENFILE ENODATA ENOENT ENOSPC diff --git a/src/uu/chmod/src/chmod.rs b/src/uu/chmod/src/chmod.rs index 46c49dbc7cb..6f4a5078cd8 100644 --- a/src/uu/chmod/src/chmod.rs +++ b/src/uu/chmod/src/chmod.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) Chmoder cmode fmode fperm fref ugoa RFILE RFILE's +// spell-checker:ignore (ToDO) Chmoder cmode fmode fperm fref ugoa RFILE RFILE's fchmod #![cfg(unix)] @@ -49,6 +49,9 @@ enum ChmodError { NewPermissions(PathBuf, String, String), #[error("{}", translate!("chmod-error-changing-permissions", "file" => _0.quote(), "err" => strip_errno(_1)))] ChangingPermissions(PathBuf, std::io::Error), + #[cfg(not(target_os = "redox"))] + #[error("{}", translate!("perms-cannot-access-replaced", "file" => _0.quote()))] + Replaced(PathBuf), } impl UError for ChmodError {} @@ -658,10 +661,6 @@ impl Chmoder { return; } - if let Err(err) = self.chmod_file(file_path) { - self.report_failure(err); - } - // Determine whether to traverse symlinks based on context and traversal mode let should_follow_symlink = match self.traverse_symlinks { TraverseSymlinks::All => true, @@ -669,16 +668,82 @@ impl Chmoder { TraverseSymlinks::None => false, }; - // Recurse via safe traversal, opening under the same symlink policy the checks - // above used: the pathname is resolved again here, so under `-P` (the `-R` - // default) O_NOFOLLOW fails the open rather than redirecting the descent into a - // swapped-in symlink. `-H`/`-L` still follow, which is what they ask for. - if (!file_path.is_symlink() || should_follow_symlink) && file_path.is_dir() { - match DirFd::open(file_path, should_follow_symlink.into()) { - Ok(dir_fd) => self.safe_traverse_dir(&dir_fd, file_path, ancestors), - // A directory that does not open cannot be read. - Err(err) => self.report_unreadable_dir(file_path, err), + let descend = (!file_path.is_symlink() || should_follow_symlink) && file_path.is_dir(); + if !descend { + if let Err(err) = self.chmod_file(file_path) { + self.report_failure(err); + } + return; + } + + // Change the mode through a descriptor checked not to be "/". Under -h with -H/-L, + // or if it does not open yet, go by path as GNU does; the descent checks for "/". + let pinned = if should_follow_symlink == self.dereference { + match DirFd::open(file_path, should_follow_symlink.into()) + .and_then(|dir_fd| Ok((dir_fd.metadata()?, dir_fd))) + { + Ok(pinned) => Some(pinned), + Err(e) + if matches!( + e.raw_os_error(), + Some(uucore::libc::EACCES | uucore::libc::EPERM | uucore::libc::EMFILE) + ) => + { + None + } + // Other errors, such as the name now leading to a file or a symlink, + // leave it alone rather than change whatever is there by path. + Err(e) => { + self.report_failure(ChmodError::CannotStat(file_path.into(), e).into()); + self.report_not_accessed(file_path); + return; + } + } + } else { + None + }; + // The descriptor is closed once the mode is changed, keeping only the + // directory's identity, so the walk holds no more descriptors than without it. + let (changed, result) = match pinned { + Some((meta, dir_fd)) => { + if self.preserve_root && uucore::fs::metadata_is_root_dir(&meta) { + show!(ChmodError::PreserveRootSameAs(file_path.into())); + return; + } + let result = self.chmod_dir_fd(&dir_fd, meta.mode() & 0o7777, file_path); + (Some(meta.file_info()), result) + } + None => (None, self.chmod_file(file_path)), + }; + if let Err(err) = result { + self.report_failure(err); + } + + // Open again to descend, as GNU does, so the mode just set decides whether + // the directory can be read. + match DirFd::open(file_path, should_follow_symlink.into()) { + Ok(dir_fd) => { + if let Some(changed) = changed { + // Descend only into the directory changed above, known not to be "/". + if !dir_fd + .metadata() + .is_ok_and(|meta| meta.file_info() == changed) + { + self.report_failure(ChmodError::Replaced(file_path.into()).into()); + return; + } + } else if self.preserve_root + && dir_fd + .metadata() + .is_ok_and(|meta| uucore::fs::metadata_is_root_dir(&meta)) + { + show!(ChmodError::PreserveRootSameAs(file_path.into())); + return; + } + self.safe_traverse_dir(&dir_fd, file_path, ancestors); } + // A directory that does not open cannot be read. + Err(err) => self.report_unreadable_dir(file_path, err), } } @@ -907,18 +972,32 @@ impl Chmoder { return Ok(()); } - self.change_file(fperm, self.fmode.unwrap_or(new_mode), file)?; - - self.report_umask_conflict(file, new_mode, naively_expected_new_mode); + self.change_file(fperm, (new_mode, naively_expected_new_mode), file, |mode| { + fs::set_permissions(file, fs::Permissions::from_mode(mode)) + }) + } - Ok(()) + /// [`Self::chmod_file`] for a directory already open, which no rename can + /// re-point, whose mode bits are `fperm`. + #[cfg(not(target_os = "redox"))] + fn chmod_dir_fd(&self, dir_fd: &DirFd, fperm: u32, file: &Path) -> UResult<()> { + let new_modes = self.calculate_new_mode(fperm, true)?; + self.change_file(fperm, new_modes, file, |mode| dir_fd.fchmod(mode)) } - fn change_file(&self, fperm: u32, mode: u32, file: &Path) -> UResult<()> { + /// Apply the mode for `new_mode` to `file` with `set`, and report the outcome. + fn change_file( + &self, + fperm: u32, + (new_mode, naively_expected_new_mode): (u32, u32), + file: &Path, + set: impl FnOnce(u32) -> std::io::Result<()>, + ) -> UResult<()> { + let mode = self.fmode.unwrap_or(new_mode); // Always issue the chmod(2) call, even when the bits are unchanged: the // syscall can still fail (e.g. lacking permission on the file) and that // failure must be reported, matching GNU. - if let Err(err) = fs::set_permissions(file, fs::Permissions::from_mode(mode)) { + if let Err(err) = set(mode) { if self.verbose { println!( "failed to change mode of file {} from {fperm:04o} ({}) to {mode:04o} ({})", @@ -932,6 +1011,7 @@ impl Chmoder { // Use the helper method for consistent reporting self.report_permission_change(file, fperm, mode); + self.report_umask_conflict(file, new_mode, naively_expected_new_mode); Ok(()) } } diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index 4e4155c67d4..963c0e87313 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -636,6 +636,14 @@ pub fn path_is_root_dir>(path: P, dereference: bool) -> bool { } } +/// Whether `meta`, a stat already taken, describes `/`. Unlike [`path_is_root_dir`], +/// it does not look the path up again, which a rename could have re-pointed. +#[cfg(unix)] +pub fn metadata_is_root_dir(meta: &impl MetadataExt) -> bool { + root_file_information() + .is_some_and(|root| root.dev() == meta.dev() && root.inode() == meta.ino()) +} + /// Check if two files are identical by comparing their contents. /// /// Returns `Ok(true)` if both files exist, are regular files, and have identical contents. @@ -1642,6 +1650,9 @@ mod tests { assert!(!path_is_root_dir(dir.path(), true)); assert!(!path_is_root_dir(dir.path().join("nonexistent"), true)); assert!(!path_is_root_dir("", true)); + + assert!(metadata_is_root_dir(&fs::metadata("/").unwrap())); + assert!(!metadata_is_root_dir(&fs::metadata(dir.path()).unwrap())); } /// A symlink to `/` counts only when the caller would follow it. diff --git a/src/uucore/src/lib/features/perms.rs b/src/uucore/src/lib/features/perms.rs index 41493fb521d..0b3bad1876e 100644 --- a/src/uucore/src/lib/features/perms.rs +++ b/src/uucore/src/lib/features/perms.rs @@ -25,7 +25,7 @@ use walkdir::WalkDir; #[cfg(target_os = "linux")] use crate::features::fs::FileInformation; -use crate::features::fs::path_is_root_dir; +use crate::features::fs::{metadata_is_root_dir, path_is_root_dir}; #[cfg(target_os = "linux")] use crate::features::safe_traversal::{DirFd, FileInfo, SymlinkBehavior}; @@ -243,7 +243,11 @@ fn is_root(path: &Path, would_traverse_symlink: bool) -> bool { if !path_is_root_dir(path, would_traverse_symlink) { return false; } + report_root(path); + true +} +fn report_root(path: &Path) { if path.as_os_str() == "/" { show_error!("it is dangerous to operate recursively on '/'"); } else { @@ -253,7 +257,6 @@ fn is_root(path: &Path, would_traverse_symlink: bool) -> bool { ); } show_error!("use --no-preserve-root to override this failsafe"); - true } /// Whether `dir_fd` refers to the very object `meta` describes. @@ -305,10 +308,14 @@ impl ChownExecutor { return 1; }; + // `meta` pins the descent below, so a later swap cannot slip "/" past; the + // path lookup still catches a symlink to "/", which `meta` did not follow. if self.recursive && self.preserve_root - && is_root(path, self.traverse_symlinks != TraverseSymlinks::None) + && (path_is_root_dir(path, self.traverse_symlinks != TraverseSymlinks::None) + || metadata_is_root_dir(&meta)) { + report_root(path); // Fail-fast, do not attempt to recurse. return 1; } diff --git a/tests/by-util/test_chmod.rs b/tests/by-util/test_chmod.rs index 11f8ac65f52..76e67202601 100644 --- a/tests/by-util/test_chmod.rs +++ b/tests/by-util/test_chmod.rs @@ -3,7 +3,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 rwxr +// spell-checker:ignore (words) dirfd subdirs openat FDCWD rwxr Nofile use std::fs::{OpenOptions, Permissions, metadata, set_permissions}; use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; @@ -823,6 +823,225 @@ fn test_chmod_preserve_root_symlink_during_recursion() { ); } +#[test] +fn test_chmod_preserve_root_symlink_operand() { + // A symlink to '/' is '/' whenever followed: by default, with -H/-L, and with -P + // plus a trailing slash. `u+u` changes nothing, should the check ever let it by. + let cases: [(&[&str], &str); 4] = [ + (&["-R"], "root"), + (&["-R", "-H"], "root"), + (&["-R", "-L"], "root"), + (&["-R", "-P"], "root/"), + ]; + for (flags, operand) in cases { + let (at, mut ucmd) = at_and_ucmd!(); + at.symlink_dir("/", "root"); + ucmd.args(flags) + .args(&["--preserve-root", "u+u", operand]) + .fails_with_code(1) + .stderr_is(format!( + "chmod: it is dangerous to operate recursively on '{operand}' (same as '/')\n\ + chmod: use --no-preserve-root to override this failsafe\n" + )); + } +} + +#[test] +fn test_chmod_recursive_operand_that_does_not_open() { + // An operand that cannot be opened yet still has its mode changed, and is then + // descended into, also through a symlink operand, which -P leaves alone. + for flags in [&["-R"][..], &["-R", "-H"], &["-R", "-L"], &["-R", "-P"]] { + for operand in ["d", "link"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d"); + at.touch("d/f"); + at.set_mode("d/f", 0o644); + at.set_mode("d", 0o000); + at.relative_symlink_dir("d", "link"); + + ucmd.args(flags) + .args(&["u+rwx", operand]) + .succeeds() + .no_output(); + + let mode = |file| at.metadata(file).permissions().mode() & 0o7777; + if operand == "link" && flags.contains(&"-P") { + assert_eq!(mode("d"), 0o000, "{flags:?} {operand}"); + at.set_mode("d", 0o755); + continue; + } + assert_eq!(mode("d"), 0o700, "{flags:?} {operand}"); + assert_eq!(mode("d/f"), 0o744, "{flags:?} {operand}"); + } + } +} + +#[test] +fn test_chmod_recursive_operand_made_unreadable() { + // An operand that the mode change leaves unreadable keeps its new mode, and + // the failed descent names it. Root reads it regardless. + if rustix::process::geteuid().is_root() { + return; + } + for flags in [&["-R"][..], &["-R", "-H"], &["-R", "-L"], &["-R", "-P"]] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d"); + at.touch("d/f"); + at.set_mode("d/f", 0o644); + at.set_mode("d", 0o755); + + ucmd.args(flags) + .args(&["a-r", "d"]) + .fails_with_code(1) + .stderr_is("chmod: cannot read directory 'd': Permission denied\n"); + + assert_eq!( + at.metadata("d").permissions().mode() & 0o7777, + 0o311, + "{flags:?}" + ); + at.set_mode("d", 0o755); + assert_eq!( + at.metadata("d/f").permissions().mode() & 0o7777, + 0o644, + "{flags:?}" + ); + } +} + +#[test] +fn test_chmod_recursive_no_dereference_operand() { + // With -h, a directory operand followed by -H or -L has its mode changed by + // path rather than through a descriptor; it is still changed and descended. + for flags in [ + &["-R", "-h"][..], + &["-R", "-H", "-h"], + &["-R", "-L", "-h"], + &["-R", "-P", "-h"], + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d"); + at.touch("d/f"); + at.set_mode("d", 0o755); + at.set_mode("d/f", 0o644); + + ucmd.args(flags).args(&["700", "d"]).succeeds().no_output(); + + assert_eq!(at.metadata("d").permissions().mode() & 0o7777, 0o700); + assert_eq!(at.metadata("d/f").permissions().mode() & 0o7777, 0o700); + } +} + +/// `chmod -R` must only descend into the directory whose mode it changed. A racer +/// re-points the operand between two fixture directories and a file meanwhile. +#[test] +#[cfg(not(target_os = "redox"))] +fn test_chmod_recursive_descends_only_into_the_directory_it_changed() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + for dir in ["a", "b"] { + at.mkdir(dir); + at.touch(format!("{dir}/f")); + } + at.touch("x"); + at.relative_symlink_dir("a", "op"); + + let stop = Arc::new(AtomicBool::new(false)); + let (op, tmp) = (at.plus("op"), at.plus("op.tmp")); + let racer_stop = stop.clone(); + let racer = std::thread::spawn(move || { + for target in ["b", "x", "a", "x"].iter().cycle() { + if racer_stop.load(Ordering::Relaxed) { + break; + } + std::os::unix::fs::symlink(target, &tmp).unwrap(); + std::fs::rename(&tmp, &op).unwrap(); + } + }); + + for _ in 0..200 { + for dir in ["a", "b"] { + at.set_mode(dir, 0o755); + at.set_mode(format!("{dir}/f"), 0o644); + } + + let result = scene.ucmd().args(&["-R", "700", "op"]).run(); + + for dir in ["a", "b"] { + let mode = |file: &str| at.metadata(file).permissions().mode() & 0o7777; + assert!( + mode(&format!("{dir}/f")) == 0o644 || mode(dir) == 0o700, + "descended into {dir} without changing its mode" + ); + } + if !result.succeeded() { + // One error about the operand. Its cause varies with the timing: + // macOS can also fail a lookup through a symlink being replaced. + let stderr = result.code_is(1).stderr_str(); + assert!( + stderr.starts_with("chmod: ") + && stderr.contains("'op'") + && stderr.lines().count() == 1, + "{stderr}" + ); + } + } + + stop.store(true, Ordering::Relaxed); + racer.join().unwrap(); +} + +/// A directory operand the system refuses to open, as TCC or a sandbox does, gets +/// its mode by path, then cannot be read, as with GNU. +#[cfg(target_vendor = "apple")] +#[test] +fn test_chmod_recursive_operand_refused_by_sandbox() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkdir("dir"); + at.set_mode("dir", 0o755); + let dir = at.plus("dir").canonicalize().unwrap(); + let profile = format!("(version 1)(allow default)(deny file-read-data (literal {dir:?}))"); + + scene + .cmd("sandbox-exec") + .args(&["-p", &profile]) + .arg(&scene.bin_path) + .args(&["chmod", "-R", "700", "dir"]) + .fails_with_code(1) + .no_stdout() + .stderr_is("chmod: cannot read directory 'dir': Operation not permitted\n"); + assert_eq!(at.metadata("dir").permissions().mode() & 0o7777, 0o700); +} + +/// Each directory reached through a symlink under -L takes one descriptor +/// while the walk is below it, not more. +#[test] +#[cfg(target_os = "linux")] +fn test_chmod_recursive_dereference_one_descriptor_per_level() { + use rlimit::Resource; + + let (at, mut ucmd) = at_and_ucmd!(); + for i in 0..=10 { + at.mkdir(format!("d{i}")); + } + for i in 0..10 { + at.relative_symlink_dir(format!("../d{}", i + 1), format!("d{i}/next")); + } + at.touch("d10/f"); + at.set_mode("d10/f", 0o644); + + ucmd.args(&["-R", "-L", "700", "d0"]) + .limit(Resource::NOFILE, 20, 20) + .succeeds() + .no_output(); + + assert_eq!(at.metadata("d10/f").permissions().mode() & 0o7777, 0o700); +} + #[test] fn test_chmod_recursive_reference_does_not_follow_inner_symlink() { // A symlink met inside the tree during `chmod -R` must not have its referent