From 73082797c6252bb8a85238b641c99a8ad616be33 Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Wed, 29 Jul 2026 17:13:48 +0530 Subject: [PATCH] chmod: report the real error when a target's metadata is inaccessible chmod used Path::exists() to decide whether a target exists, but exists() returns false on any metadata error, so a file whose parent directory lacks search permission was reported as "No such file or directory" instead of "Permission denied". Use try_exists() to distinguish a genuine ENOENT from a permission error, matching GNU. Fixes #9789. --- src/uu/chmod/src/chmod.rs | 78 +++++++++++++++++++++++-------------- tests/by-util/test_chmod.rs | 24 ++++++++++++ 2 files changed, 73 insertions(+), 29 deletions(-) diff --git a/src/uu/chmod/src/chmod.rs b/src/uu/chmod/src/chmod.rs index 91008168cc3..79e9dca5abe 100644 --- a/src/uu/chmod/src/chmod.rs +++ b/src/uu/chmod/src/chmod.rs @@ -370,40 +370,60 @@ impl Chmoder { for filename in files { let file = Path::new(filename); - if !file.exists() { - if file.is_symlink() { - if !self.dereference && !self.recursive { - // The file is a symlink and we should not follow it - // Don't try to change the mode of the symlink itself - continue; - } - if self.recursive && self.traverse_symlinks == TraverseSymlinks::None { - continue; - } + // `Path::exists()` returns `false` when the path's metadata cannot be + // read at all (for example a parent directory without search + // permission), which made chmod misreport an existing but + // inaccessible file as "No such file or directory". Use + // `try_exists()` so a permission error is surfaced as such, matching + // GNU (issue #9789). + match file.try_exists() { + Ok(false) => { + if file.is_symlink() { + if !self.dereference && !self.recursive { + // The file is a symlink and we should not follow it + // Don't try to change the mode of the symlink itself + continue; + } + if self.recursive && self.traverse_symlinks == TraverseSymlinks::None { + continue; + } + + if !self.quiet { + show!(ChmodError::DanglingSymlink(filename.into())); + set_exit_code(1); + } + if self.verbose { + println!( + "{}", + translate!("chmod-verbose-failed-dangling", "file" => filename.quote()) + ); + } + } else if !self.quiet { + show!(ChmodError::NoSuchFile(filename.into())); + } + // GNU exits with exit code 1 even if -q or --quiet are passed + // So we set the exit code, because it hasn't been set yet if `self.quiet` is true. + set_exit_code(1); + continue; + } + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { if !self.quiet { - show!(ChmodError::DanglingSymlink(filename.into())); - set_exit_code(1); + show!(ChmodError::PermissionDenied(filename.into())); } - - if self.verbose { - println!( - "{}", - translate!("chmod-verbose-failed-dangling", "file" => filename.quote()) - ); + set_exit_code(1); + continue; + } + // Present, or an unexpected error that the chmod attempt below + // will surface with a precise message. + Ok(true) | Err(_) => { + if !self.dereference && file.is_symlink() { + // The file is a symlink and we should not follow it + // chmod 755 --no-dereference a/link + // should not change the permissions in this case + continue; } - } else if !self.quiet { - show!(ChmodError::NoSuchFile(filename.into())); } - // GNU exits with exit code 1 even if -q or --quiet are passed - // So we set the exit code, because it hasn't been set yet if `self.quiet` is true. - set_exit_code(1); - continue; - } else if !self.dereference && file.is_symlink() { - // The file is a symlink and we should not follow it - // chmod 755 --no-dereference a/link - // should not change the permissions in this case - continue; } if self.recursive && self.preserve_root && Self::is_root(file) { return Err(ChmodError::PreserveRoot("/".into()).into()); diff --git a/tests/by-util/test_chmod.rs b/tests/by-util/test_chmod.rs index a1ac2b2b1dc..46ee9457d58 100644 --- a/tests/by-util/test_chmod.rs +++ b/tests/by-util/test_chmod.rs @@ -405,6 +405,30 @@ fn test_permission_denied() { .stderr_is("chmod: cannot access 'd/no-x/y': Permission denied\n"); } +#[test] +fn test_chmod_inaccessible_file_reports_permission_denied() { + // Non-recursive chmod of a file whose metadata cannot be read because its + // parent directory lacks search permission must report "Permission denied", + // not "No such file or directory" (issue #9789). `Path::exists()` collapses + // the permission error into `false`, so the fix relies on `try_exists()`. + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("locked"); + make_file(&at.plus_as_string("locked/file"), 0o100644); + set_permissions(at.plus_as_string("locked"), Permissions::from_mode(0o000)).unwrap(); + + scene + .ucmd() + .arg("644") + .arg("locked/file") + .fails() + .stderr_is("chmod: cannot access 'locked/file': Permission denied\n"); + + // Restore search permission so the fixture directory can be cleaned up. + set_permissions(at.plus_as_string("locked"), Permissions::from_mode(0o755)).unwrap(); +} + #[test] #[allow(clippy::unreadable_literal)] fn test_chmod_recursive_correct_exit_code() {