Skip to content

Commit da509a5

Browse files
committed
safe traversal: adjust chmod & chgrp to use it
1 parent 04428ae commit da509a5

File tree

4 files changed

+244
-2
lines changed

4 files changed

+244
-2
lines changed

src/uu/chmod/Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@ path = "src/chmod.rs"
2020
[dependencies]
2121
clap = { workspace = true }
2222
thiserror = { workspace = true }
23-
uucore = { workspace = true, features = ["entries", "fs", "mode", "perms"] }
23+
uucore = { workspace = true, features = [
24+
"entries",
25+
"fs",
26+
"mode",
27+
"perms",
28+
"safe-traversal",
29+
] }
2430
fluent = { workspace = true }
2531

2632
[[bin]]

src/uu/chmod/src/chmod.rs

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ use uucore::fs::display_permissions_unix;
1717
use uucore::libc::mode_t;
1818
use uucore::mode;
1919
use uucore::perms::{TraverseSymlinks, configure_symlink_and_recursion};
20+
21+
#[cfg(target_os = "linux")]
22+
use uucore::safe_traversal::DirFd;
2023
use uucore::{format_usage, show, show_error};
2124

2225
use uucore::translate;
@@ -322,6 +325,7 @@ impl Chmoder {
322325
r
323326
}
324327

328+
#[cfg(not(target_os = "linux"))]
325329
fn walk_dir_with_context(&self, file_path: &Path, is_command_line_arg: bool) -> UResult<()> {
326330
let mut r = self.chmod_file(file_path);
327331

@@ -352,6 +356,213 @@ impl Chmoder {
352356
r
353357
}
354358

359+
#[cfg(target_os = "linux")]
360+
fn walk_dir_with_context(&self, file_path: &Path, is_command_line_arg: bool) -> UResult<()> {
361+
let mut r = self.chmod_file(file_path);
362+
363+
// Determine whether to traverse symlinks based on context and traversal mode
364+
let should_follow_symlink = match self.traverse_symlinks {
365+
TraverseSymlinks::All => true,
366+
TraverseSymlinks::First => is_command_line_arg, // Only follow symlinks that are command line args
367+
TraverseSymlinks::None => false,
368+
};
369+
370+
// If the path is a directory (or we should follow symlinks), recurse into it using safe traversal
371+
if (!file_path.is_symlink() || should_follow_symlink) && file_path.is_dir() {
372+
match DirFd::open(file_path) {
373+
Ok(dir_fd) => {
374+
r = self
375+
.safe_traverse_dir(&dir_fd, file_path, is_command_line_arg)
376+
.and(r);
377+
}
378+
Err(err) => {
379+
// Handle permission denied errors with proper file path context
380+
if err.kind() == std::io::ErrorKind::PermissionDenied {
381+
r = r.and(Err(ChmodError::PermissionDenied(
382+
file_path.to_string_lossy().to_string(),
383+
)
384+
.into()));
385+
} else {
386+
r = r.and(Err(err.into()));
387+
}
388+
}
389+
}
390+
}
391+
r
392+
}
393+
394+
#[cfg(target_os = "linux")]
395+
fn safe_traverse_dir(
396+
&self,
397+
dir_fd: &DirFd,
398+
dir_path: &Path,
399+
_is_command_line_arg: bool,
400+
) -> UResult<()> {
401+
let mut r = Ok(());
402+
403+
// Read directory entries
404+
let entries = match dir_fd.read_dir() {
405+
Ok(entries) => entries,
406+
Err(e) => {
407+
return Err(e.into());
408+
}
409+
};
410+
411+
for entry_name in entries {
412+
let entry_path = dir_path.join(&entry_name);
413+
414+
// Get metadata for the entry
415+
let follow = self.traverse_symlinks == TraverseSymlinks::All;
416+
417+
let meta = match dir_fd.metadata_at(&entry_name, follow) {
418+
Ok(m) => m,
419+
Err(e) => {
420+
// Handle permission denied with proper file path context
421+
if e.kind() == std::io::ErrorKind::PermissionDenied {
422+
r = r.and(Err(ChmodError::PermissionDenied(
423+
entry_path.to_string_lossy().to_string(),
424+
)
425+
.into()));
426+
} else {
427+
r = r.and(Err(e.into()));
428+
}
429+
continue;
430+
}
431+
};
432+
433+
if entry_path.is_symlink() {
434+
r = self
435+
.handle_symlink_during_safe_recursion(&entry_path, dir_fd, &entry_name)
436+
.and(r);
437+
} else {
438+
// For regular files and directories, chmod them
439+
r = self
440+
.safe_chmod_file(&entry_path, dir_fd, &entry_name, meta.mode() & 0o7777)
441+
.and(r);
442+
443+
// Recurse into subdirectories
444+
if meta.is_dir() {
445+
r = self.walk_dir_with_context(&entry_path, false).and(r);
446+
}
447+
}
448+
}
449+
r
450+
}
451+
452+
#[cfg(target_os = "linux")]
453+
fn handle_symlink_during_safe_recursion(
454+
&self,
455+
path: &Path,
456+
dir_fd: &DirFd,
457+
entry_name: &std::ffi::OsStr,
458+
) -> UResult<()> {
459+
// During recursion, determine behavior based on traversal mode
460+
match self.traverse_symlinks {
461+
TraverseSymlinks::All => {
462+
// Follow all symlinks during recursion
463+
// Check if the symlink target is a directory, but handle dangling symlinks gracefully
464+
match fs::metadata(path) {
465+
Ok(meta) if meta.is_dir() => self.walk_dir_with_context(path, false),
466+
Ok(meta) => {
467+
// It's a file symlink, chmod it using safe traversal
468+
self.safe_chmod_file(path, dir_fd, entry_name, meta.mode() & 0o7777)
469+
}
470+
Err(_) => {
471+
// Dangling symlink, chmod it without dereferencing
472+
self.chmod_file_internal(path, false)
473+
}
474+
}
475+
}
476+
TraverseSymlinks::First | TraverseSymlinks::None => {
477+
// Don't follow symlinks encountered during recursion
478+
// For these symlinks, don't dereference them even if dereference is normally true
479+
self.chmod_file_internal(path, false)
480+
}
481+
}
482+
}
483+
484+
#[cfg(target_os = "linux")]
485+
fn safe_chmod_file(
486+
&self,
487+
file_path: &Path,
488+
dir_fd: &DirFd,
489+
entry_name: &std::ffi::OsStr,
490+
current_mode: u32,
491+
) -> UResult<()> {
492+
// Determine the new permissions to apply
493+
let new_mode = match self.fmode {
494+
Some(mode) => mode,
495+
None => {
496+
let cmode_unwrapped = self.cmode.clone().unwrap();
497+
let mut new_mode = current_mode;
498+
for mode in cmode_unwrapped.split(',') {
499+
let result = if mode.chars().any(|c| c.is_ascii_digit()) {
500+
mode::parse_numeric(new_mode, mode, file_path.is_dir())
501+
} else {
502+
mode::parse_symbolic(new_mode, mode, mode::get_umask(), file_path.is_dir())
503+
};
504+
505+
match result {
506+
Ok(parsed_mode) => {
507+
new_mode = parsed_mode;
508+
}
509+
Err(f) => {
510+
return if self.quiet {
511+
Err(ExitCode::new(1))
512+
} else {
513+
Err(USimpleError::new(1, f))
514+
};
515+
}
516+
}
517+
}
518+
new_mode
519+
}
520+
};
521+
522+
// Use safe traversal to change the mode
523+
let follow_symlinks = self.dereference;
524+
if let Err(_e) = dir_fd.chmod_at(entry_name, new_mode, follow_symlinks) {
525+
if self.verbose {
526+
println!(
527+
"failed to change mode of {} to {:o}",
528+
file_path.quote(),
529+
new_mode
530+
);
531+
}
532+
return Err(
533+
ChmodError::PermissionDenied(file_path.to_string_lossy().to_string()).into(),
534+
);
535+
}
536+
537+
// Report the change if verbose or changes mode
538+
if self.verbose || self.changes {
539+
let current_permissions = display_permissions_unix(current_mode, false);
540+
let new_permissions = display_permissions_unix(new_mode, false);
541+
if new_mode != current_mode {
542+
if self.changes || self.verbose {
543+
println!(
544+
"mode of {} changed from {} ({:04o}) to {} ({:04o})",
545+
file_path.quote(),
546+
current_permissions,
547+
current_mode,
548+
new_permissions,
549+
new_mode
550+
);
551+
}
552+
} else if self.verbose {
553+
println!(
554+
"mode of {} retained as {} ({:04o})",
555+
file_path.quote(),
556+
current_permissions,
557+
current_mode
558+
);
559+
}
560+
}
561+
562+
Ok(())
563+
}
564+
565+
#[cfg(not(target_os = "linux"))]
355566
fn handle_symlink_during_recursion(&self, path: &Path) -> UResult<()> {
356567
// During recursion, determine behavior based on traversal mode
357568
match self.traverse_symlinks {

tests/by-util/test_chgrp.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,3 +615,28 @@ fn test_chgrp_non_utf8_paths() {
615615

616616
ucmd.arg(current_gid.to_string()).arg(&filename).succeeds();
617617
}
618+
619+
#[test]
620+
fn test_chgrp_recursive_on_file() {
621+
// Test for regression where `chgrp -R` on a regular file would fail
622+
// with "Not a directory" error. This should succeed since there's nothing
623+
// to recurse into, similar to GNU chgrp behavior.
624+
// equivalent of tests/chgrp/recurse in GNU coreutils
625+
use std::os::unix::fs::MetadataExt;
626+
let (at, mut ucmd) = at_and_ucmd!();
627+
628+
at.touch("regular_file");
629+
630+
let current_gid = getegid();
631+
632+
ucmd.arg("-R")
633+
.arg(current_gid.to_string())
634+
.arg("regular_file")
635+
.succeeds()
636+
.no_stderr();
637+
638+
assert_eq!(
639+
at.plus("regular_file").metadata().unwrap().gid(),
640+
current_gid
641+
);
642+
}

tests/by-util/test_chmod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ fn test_chmod_recursive() {
401401
.arg("z")
402402
.umask(0)
403403
.fails()
404-
.stderr_is("chmod: Permission denied\n");
404+
.stderr_is(err_msg);
405405

406406
assert_eq!(at.metadata("z/y").permissions().mode(), 0o100444);
407407
assert_eq!(at.metadata("a/a").permissions().mode(), 0o100444);

0 commit comments

Comments
 (0)