Skip to content

Commit fbd667b

Browse files
committed
rm: recursive implementation of -r option
Change the implementation of `rm -r` so that it is explicitly recursive so that (1) there is one code path regardless of whether `--verbose` is given and (2) it is easier to be compatible with GNU `rm`. This change eliminates a dependency on the `walkdir` crate. Fixes uutils#7033, fixes uutils#7305, fixes uutils#7307.
1 parent e0c53ce commit fbd667b

File tree

5 files changed

+153
-78
lines changed

5 files changed

+153
-78
lines changed

Cargo.lock

-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/uu/rm/Cargo.toml

-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ path = "src/rm.rs"
1818

1919
[dependencies]
2020
clap = { workspace = true }
21-
walkdir = { workspace = true }
2221
uucore = { workspace = true, features = ["fs"] }
2322

2423
[target.'cfg(unix)'.dependencies]

src/uu/rm/src/rm.rs

+119-68
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
// spell-checker:ignore (path) eacces inacc rm-r4
77

88
use clap::{builder::ValueParser, crate_version, parser::ValueSource, Arg, ArgAction, Command};
9-
use std::collections::VecDeque;
109
use std::ffi::{OsStr, OsString};
1110
use std::fs::{self, Metadata};
1211
use std::ops::BitOr;
@@ -21,7 +20,6 @@ use uucore::error::{FromIo, UResult, USimpleError, UUsageError};
2120
use uucore::{
2221
format_usage, help_about, help_section, help_usage, os_str_as_bytes, prompt_yes, show_error,
2322
};
24-
use walkdir::{DirEntry, WalkDir};
2523

2624
#[derive(Eq, PartialEq, Clone, Copy)]
2725
/// Enum, determining when the `rm` will prompt the user about the file deletion
@@ -341,6 +339,24 @@ fn is_dir_empty(path: &Path) -> bool {
341339
}
342340
}
343341

342+
/// Whether the given file or directory is readable.
343+
#[cfg(unix)]
344+
fn is_readable(path: &Path) -> bool {
345+
match std::fs::metadata(path) {
346+
Err(_) => false,
347+
Ok(metadata) => {
348+
let mode = metadata.permissions().mode();
349+
(mode & 0o400) > 0
350+
}
351+
}
352+
}
353+
354+
/// Whether the given file or directory is readable.
355+
#[cfg(not(unix))]
356+
fn is_readable(_path: &Path) -> bool {
357+
true
358+
}
359+
344360
/// Whether the given file or directory is writable.
345361
#[cfg(unix)]
346362
fn is_writable(path: &Path) -> bool {
@@ -360,7 +376,106 @@ fn is_writable(_path: &Path) -> bool {
360376
true
361377
}
362378

363-
#[allow(clippy::cognitive_complexity)]
379+
/// Recursively remove the directory tree rooted at the given path.
380+
///
381+
/// If `path` is a file or a symbolic link, just remove it. If it is a
382+
/// directory, remove all of its entries recursively and then remove the
383+
/// directory itself. In case of an error, print the error message to
384+
/// `stderr` and return `true`. If there were no errors, return `false`.
385+
fn remove_dir_recursive(path: &Path, options: &Options) -> bool {
386+
// Special case: if we cannot access the metadata because the
387+
// filename is too long, fall back to try
388+
// `std::fs::remove_dir_all()`.
389+
//
390+
// TODO This is a temporary bandage; we shouldn't need to do this
391+
// at all. Instead of using the full path like "x/y/z", which
392+
// causes a `InvalidFilename` error when trying to access the file
393+
// metadata, we should be able to use just the last part of the
394+
// path, "z", and know that it is relative to the parent, "x/y".
395+
if let Some(s) = path.to_str() {
396+
if s.len() > 1000 {
397+
match std::fs::remove_dir_all(path) {
398+
Ok(_) => return false,
399+
Err(e) => {
400+
let e = e.map_err_context(|| format!("cannot remove {}", path.quote()));
401+
show_error!("{e}");
402+
return true;
403+
}
404+
}
405+
}
406+
}
407+
408+
// Base case 1: this is a file or a symbolic link.
409+
//
410+
// The symbolic link case is important because it could be a link to
411+
// a directory and we don't want to recurse. In particular, this
412+
// avoids an infinite recursion in the case of a link to the current
413+
// directory, like `ln -s . link`.
414+
if !path.is_dir() || path.is_symlink() {
415+
return remove_file(path, options);
416+
}
417+
418+
// Base case 2: this is a non-empty directory, but the user
419+
// doesn't want to descend into it.
420+
if options.interactive == InteractiveMode::Always
421+
&& !is_dir_empty(path)
422+
&& !prompt_descend(path)
423+
{
424+
return false;
425+
}
426+
427+
// Recursive case: this is a directory.
428+
let mut error = false;
429+
match std::fs::read_dir(path) {
430+
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
431+
// This is not considered an error.
432+
}
433+
Err(_) => error = true,
434+
Ok(iter) => {
435+
for entry in iter {
436+
match entry {
437+
Err(_) => error = true,
438+
Ok(entry) => {
439+
let child_error = remove_dir_recursive(&entry.path(), options);
440+
error = error || child_error;
441+
}
442+
}
443+
}
444+
}
445+
}
446+
447+
// Ask the user whether to remove the current directory.
448+
if options.interactive == InteractiveMode::Always && !prompt_dir(path, options) {
449+
return false;
450+
}
451+
452+
// Try removing the directory itself.
453+
match std::fs::remove_dir(path) {
454+
Err(_) if !error && !is_readable(path) => {
455+
// For compatibility with GNU test case
456+
// `tests/rm/unread2.sh`, show "Permission denied" in this
457+
// case instead of "Directory not empty".
458+
show_error!("cannot remove {}: Permission denied", path.quote());
459+
error = true;
460+
}
461+
Err(e) if !error => {
462+
let e = e.map_err_context(|| format!("cannot remove {}", path.quote()));
463+
show_error!("{e}");
464+
error = true;
465+
}
466+
Err(_) => {
467+
// If there has already been at least one error when
468+
// trying to remove the children, then there is no need to
469+
// show another error message as we return from each level
470+
// of the recursion.
471+
}
472+
Ok(_) if options.verbose => println!("removed directory {}", normalize(path).quote()),
473+
Ok(_) => {}
474+
}
475+
476+
error
477+
}
478+
364479
fn handle_dir(path: &Path, options: &Options) -> bool {
365480
let mut had_err = false;
366481

@@ -375,71 +490,7 @@ fn handle_dir(path: &Path, options: &Options) -> bool {
375490

376491
let is_root = path.has_root() && path.parent().is_none();
377492
if options.recursive && (!is_root || !options.preserve_root) {
378-
if options.interactive != InteractiveMode::Always && !options.verbose {
379-
if let Err(e) = fs::remove_dir_all(path) {
380-
// GNU compatibility (rm/empty-inacc.sh)
381-
// remove_dir_all failed. maybe it is because of the permissions
382-
// but if the directory is empty, remove_dir might work.
383-
// So, let's try that before failing for real
384-
if fs::remove_dir(path).is_err() {
385-
had_err = true;
386-
if e.kind() == std::io::ErrorKind::PermissionDenied {
387-
// GNU compatibility (rm/fail-eacces.sh)
388-
// here, GNU doesn't use some kind of remove_dir_all
389-
// It will show directory+file
390-
show_error!("cannot remove {}: {}", path.quote(), "Permission denied");
391-
} else {
392-
show_error!("cannot remove {}: {}", path.quote(), e);
393-
}
394-
}
395-
}
396-
} else {
397-
let mut dirs: VecDeque<DirEntry> = VecDeque::new();
398-
// The Paths to not descend into. We need to this because WalkDir doesn't have a way, afaik, to not descend into a directory
399-
// So we have to just ignore paths as they come up if they start with a path we aren't descending into
400-
let mut not_descended: Vec<PathBuf> = Vec::new();
401-
402-
'outer: for entry in WalkDir::new(path) {
403-
match entry {
404-
Ok(entry) => {
405-
if options.interactive == InteractiveMode::Always {
406-
for not_descend in &not_descended {
407-
if entry.path().starts_with(not_descend) {
408-
// We don't need to continue the rest of code in this loop if we are in a directory we don't want to descend into
409-
continue 'outer;
410-
}
411-
}
412-
}
413-
let file_type = entry.file_type();
414-
if file_type.is_dir() {
415-
// If we are in Interactive Mode Always and the directory isn't empty we ask if we should descend else we push this directory onto dirs vector
416-
if options.interactive == InteractiveMode::Always
417-
&& !is_dir_empty(entry.path())
418-
{
419-
// If we don't descend we push this directory onto our not_descended vector else we push this directory onto dirs vector
420-
if prompt_descend(entry.path()) {
421-
dirs.push_back(entry);
422-
} else {
423-
not_descended.push(entry.path().to_path_buf());
424-
}
425-
} else {
426-
dirs.push_back(entry);
427-
}
428-
} else {
429-
had_err = remove_file(entry.path(), options).bitor(had_err);
430-
}
431-
}
432-
Err(e) => {
433-
had_err = true;
434-
show_error!("recursing in {}: {}", path.quote(), e);
435-
}
436-
}
437-
}
438-
439-
for dir in dirs.iter().rev() {
440-
had_err = remove_dir(dir.path(), options).bitor(had_err);
441-
}
442-
}
493+
had_err = remove_dir_recursive(path, options)
443494
} else if options.dir && (!is_root || !options.preserve_root) {
444495
had_err = remove_dir(path, options).bitor(had_err);
445496
} else if options.recursive {

tests/by-util/test_rm.rs

+34
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,28 @@ fn test_non_utf8() {
777777
assert!(!at.file_exists(file));
778778
}
779779

780+
#[cfg(not(windows))]
781+
#[test]
782+
fn test_only_first_error_recursive() {
783+
let (at, mut ucmd) = at_and_ucmd!();
784+
at.mkdir("a");
785+
at.mkdir("a/b");
786+
at.touch("a/b/file");
787+
// Make the inner directory not writable.
788+
at.set_mode("a/b", 0o0555);
789+
790+
// To match the behavior of GNU `rm`, print an error message for
791+
// the file in the non-writable directory, but don't print the
792+
// error messages that would have appeared when trying to remove
793+
// the directories containing the file.
794+
ucmd.args(&["-r", "-f", "a"])
795+
.fails()
796+
.stderr_only("rm: cannot remove 'a/b/file': Permission denied\n");
797+
assert!(at.file_exists("a/b/file"));
798+
assert!(at.dir_exists("a/b"));
799+
assert!(at.dir_exists("a"));
800+
}
801+
780802
#[cfg(not(windows))]
781803
#[test]
782804
fn test_unreadable_and_nonempty_dir() {
@@ -851,3 +873,15 @@ fn test_inaccessible_dir_nonempty() {
851873
assert!(at.file_exists("dir/f"));
852874
assert!(at.dir_exists("dir"));
853875
}
876+
877+
#[cfg(not(windows))]
878+
#[test]
879+
fn test_inaccessible_dir_recursive() {
880+
let (at, mut ucmd) = at_and_ucmd!();
881+
at.mkdir("a");
882+
at.mkdir("a/unreadable");
883+
at.set_mode("a/unreadable", 0o0333);
884+
ucmd.args(&["-r", "-f", "a"]).succeeds().no_output();
885+
assert!(!at.dir_exists("a/unreadable"));
886+
assert!(!at.dir_exists("a"));
887+
}

util/build-gnu.sh

-8
Original file line numberDiff line numberDiff line change
@@ -199,14 +199,6 @@ grep -rlE '/usr/local/bin/\s?/usr/local/bin' init.cfg tests/* | xargs -r sed -Ei
199199
# we should not regress our project just to match what GNU is going.
200200
# So, do some changes on the fly
201201

202-
sed -i -e "s|rm: cannot remove 'e/slink'|rm: cannot remove 'e'|g" tests/rm/fail-eacces.sh
203-
204-
sed -i -e "s|rm: cannot remove 'a/b/file'|rm: cannot remove 'a'|g" tests/rm/cycle.sh
205-
206-
sed -i -e "s|rm: cannot remove directory 'b/a/p'|rm: cannot remove 'b'|g" tests/rm/rm1.sh
207-
208-
sed -i -e "s|rm: cannot remove 'a/1'|rm: cannot remove 'a'|g" tests/rm/rm2.sh
209-
210202
sed -i -e "s|removed directory 'a/'|removed directory 'a'|g" tests/rm/v-slash.sh
211203

212204
# 'rel' doesn't exist. Our implementation is giving a better message.

0 commit comments

Comments
 (0)