mirror of
https://github.com/uutils/coreutils
synced 2024-12-12 22:32:53 +00:00
rmdir: match GNU
- Implement all of GNU's fiddly little details - Don't assume Linux for error codes - Accept badly-encoded filenames - Report errors after the fact instead of checking ahead of time - General cleanup rmdir now passes GNU's tests.
This commit is contained in:
parent
1c05183083
commit
afb460f4ca
5 changed files with 325 additions and 135 deletions
|
@ -107,14 +107,18 @@ whoami
|
|||
|
||||
# * vars/errno
|
||||
errno
|
||||
EACCES
|
||||
EBADF
|
||||
EBUSY
|
||||
EEXIST
|
||||
EINVAL
|
||||
ENODATA
|
||||
ENOENT
|
||||
ENOSYS
|
||||
ENOTEMPTY
|
||||
EOPNOTSUPP
|
||||
EPERM
|
||||
EROFS
|
||||
|
||||
# * vars/fcntl
|
||||
F_GETFL
|
||||
|
|
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -2802,6 +2802,7 @@ name = "uu_rmdir"
|
|||
version = "0.0.7"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"libc",
|
||||
"uucore",
|
||||
"uucore_procs",
|
||||
]
|
||||
|
|
|
@ -18,6 +18,7 @@ path = "src/rmdir.rs"
|
|||
clap = { version = "2.33", features = ["wrap_help"] }
|
||||
uucore = { version=">=0.0.9", package="uucore", path="../../uucore" }
|
||||
uucore_procs = { version=">=0.0.6", package="uucore_procs", path="../../uucore_procs" }
|
||||
libc = "0.2.42"
|
||||
|
||||
[[bin]]
|
||||
name = "rmdir"
|
||||
|
|
|
@ -11,8 +11,11 @@
|
|||
extern crate uucore;
|
||||
|
||||
use clap::{crate_version, App, Arg};
|
||||
use std::fs;
|
||||
use std::fs::{read_dir, remove_dir};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use uucore::error::{set_exit_code, strip_errno, UResult};
|
||||
use uucore::util_name;
|
||||
|
||||
static ABOUT: &str = "Remove the DIRECTORY(ies), if they are empty.";
|
||||
static OPT_IGNORE_FAIL_NON_EMPTY: &str = "ignore-fail-on-non-empty";
|
||||
|
@ -21,35 +24,158 @@ static OPT_VERBOSE: &str = "verbose";
|
|||
|
||||
static ARG_DIRS: &str = "dirs";
|
||||
|
||||
#[cfg(unix)]
|
||||
static ENOTDIR: i32 = 20;
|
||||
#[cfg(windows)]
|
||||
static ENOTDIR: i32 = 267;
|
||||
|
||||
fn usage() -> String {
|
||||
format!("{0} [OPTION]... DIRECTORY...", uucore::execution_phrase())
|
||||
}
|
||||
|
||||
pub fn uumain(args: impl uucore::Args) -> i32 {
|
||||
#[uucore_procs::gen_uumain]
|
||||
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
|
||||
let usage = usage();
|
||||
|
||||
let matches = uu_app().usage(&usage[..]).get_matches_from(args);
|
||||
|
||||
let dirs: Vec<String> = matches
|
||||
.values_of(ARG_DIRS)
|
||||
.map(|v| v.map(ToString::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
let opts = Opts {
|
||||
ignore: matches.is_present(OPT_IGNORE_FAIL_NON_EMPTY),
|
||||
parents: matches.is_present(OPT_PARENTS),
|
||||
verbose: matches.is_present(OPT_VERBOSE),
|
||||
};
|
||||
|
||||
let ignore = matches.is_present(OPT_IGNORE_FAIL_NON_EMPTY);
|
||||
let parents = matches.is_present(OPT_PARENTS);
|
||||
let verbose = matches.is_present(OPT_VERBOSE);
|
||||
for path in matches
|
||||
.values_of_os(ARG_DIRS)
|
||||
.unwrap_or_default()
|
||||
.map(Path::new)
|
||||
{
|
||||
if let Err(error) = remove(path, opts) {
|
||||
let Error { error, path } = error;
|
||||
|
||||
match remove(dirs, ignore, parents, verbose) {
|
||||
Ok(()) => ( /* pass */ ),
|
||||
Err(e) => return e,
|
||||
if opts.ignore && dir_not_empty(&error, path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
0
|
||||
set_exit_code(1);
|
||||
|
||||
// If `foo` is a symlink to a directory then `rmdir foo/` may give
|
||||
// a "not a directory" error. This is confusing as `rm foo/` says
|
||||
// "is a directory".
|
||||
// This differs from system to system. Some don't give an error.
|
||||
// Windows simply allows calling RemoveDirectory on symlinks so we
|
||||
// don't need to worry about it here.
|
||||
// GNU rmdir seems to print "Symbolic link not followed" if:
|
||||
// - It has a trailing slash
|
||||
// - It's a symlink
|
||||
// - It either points to a directory or dangles
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
fn is_symlink(path: &Path) -> io::Result<bool> {
|
||||
Ok(path.symlink_metadata()?.file_type().is_symlink())
|
||||
}
|
||||
|
||||
fn points_to_directory(path: &Path) -> io::Result<bool> {
|
||||
Ok(path.metadata()?.file_type().is_dir())
|
||||
}
|
||||
|
||||
let path = path.as_os_str().as_bytes();
|
||||
if error.raw_os_error() == Some(libc::ENOTDIR) && path.ends_with(b"/") {
|
||||
// Strip the trailing slash or .symlink_metadata() will follow the symlink
|
||||
let path: &Path = OsStr::from_bytes(&path[..path.len() - 1]).as_ref();
|
||||
if is_symlink(path).unwrap_or(false)
|
||||
&& points_to_directory(path).unwrap_or(true)
|
||||
{
|
||||
show_error!(
|
||||
"failed to remove '{}/': Symbolic link not followed",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
show_error!(
|
||||
"failed to remove '{}': {}",
|
||||
path.display(),
|
||||
strip_errno(&error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Error<'a> {
|
||||
error: io::Error,
|
||||
path: &'a Path,
|
||||
}
|
||||
|
||||
fn remove(mut path: &Path, opts: Opts) -> Result<(), Error<'_>> {
|
||||
remove_single(path, opts)?;
|
||||
if opts.parents {
|
||||
while let Some(new) = path.parent() {
|
||||
path = new;
|
||||
if path.as_os_str() == "" {
|
||||
break;
|
||||
}
|
||||
remove_single(path, opts)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_single(path: &Path, opts: Opts) -> Result<(), Error<'_>> {
|
||||
if opts.verbose {
|
||||
println!("{}: removing directory, '{}'", util_name(), path.display());
|
||||
}
|
||||
remove_dir(path).map_err(|error| Error { error, path })
|
||||
}
|
||||
|
||||
// POSIX: https://pubs.opengroup.org/onlinepubs/009696799/functions/rmdir.html
|
||||
#[cfg(not(windows))]
|
||||
const NOT_EMPTY_CODES: &[i32] = &[libc::ENOTEMPTY, libc::EEXIST];
|
||||
|
||||
// 145 is ERROR_DIR_NOT_EMPTY, determined experimentally.
|
||||
#[cfg(windows)]
|
||||
const NOT_EMPTY_CODES: &[i32] = &[145];
|
||||
|
||||
// Other error codes you might get for directories that could be found and are
|
||||
// not empty.
|
||||
// This is a subset of the error codes listed in rmdir(2) from the Linux man-pages
|
||||
// project. Maybe other systems have additional codes that apply?
|
||||
#[cfg(not(windows))]
|
||||
const PERHAPS_EMPTY_CODES: &[i32] = &[libc::EACCES, libc::EBUSY, libc::EPERM, libc::EROFS];
|
||||
|
||||
// Probably incomplete, I can't find a list of possible errors for
|
||||
// RemoveDirectory anywhere.
|
||||
#[cfg(windows)]
|
||||
const PERHAPS_EMPTY_CODES: &[i32] = &[
|
||||
5, // ERROR_ACCESS_DENIED, found experimentally.
|
||||
];
|
||||
|
||||
fn dir_not_empty(error: &io::Error, path: &Path) -> bool {
|
||||
if let Some(code) = error.raw_os_error() {
|
||||
if NOT_EMPTY_CODES.contains(&code) {
|
||||
return true;
|
||||
}
|
||||
// If --ignore-fail-on-non-empty is used then we want to ignore all errors
|
||||
// for non-empty directories, even if the error was e.g. because there's
|
||||
// no permission. So we do an additional check.
|
||||
if PERHAPS_EMPTY_CODES.contains(&code) {
|
||||
if let Ok(mut iterator) = read_dir(path) {
|
||||
if iterator.next().is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Opts {
|
||||
ignore: bool,
|
||||
parents: bool,
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
pub fn uu_app() -> App<'static, 'static> {
|
||||
|
@ -84,57 +210,3 @@ pub fn uu_app() -> App<'static, 'static> {
|
|||
.required(true),
|
||||
)
|
||||
}
|
||||
|
||||
fn remove(dirs: Vec<String>, ignore: bool, parents: bool, verbose: bool) -> Result<(), i32> {
|
||||
let mut r = Ok(());
|
||||
|
||||
for dir in &dirs {
|
||||
let path = Path::new(&dir[..]);
|
||||
r = remove_dir(path, ignore, verbose).and(r);
|
||||
if parents {
|
||||
let mut p = path;
|
||||
while let Some(new_p) = p.parent() {
|
||||
p = new_p;
|
||||
match p.as_os_str().to_str() {
|
||||
None => break,
|
||||
Some(s) => match s {
|
||||
"" | "." | "/" => break,
|
||||
_ => (),
|
||||
},
|
||||
};
|
||||
r = remove_dir(p, ignore, verbose).and(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r
|
||||
}
|
||||
|
||||
fn remove_dir(path: &Path, ignore: bool, verbose: bool) -> Result<(), i32> {
|
||||
let mut read_dir = fs::read_dir(path).map_err(|e| {
|
||||
if e.raw_os_error() == Some(ENOTDIR) {
|
||||
show_error!("failed to remove '{}': Not a directory", path.display());
|
||||
} else {
|
||||
show_error!("reading directory '{}': {}", path.display(), e);
|
||||
}
|
||||
1
|
||||
})?;
|
||||
|
||||
let mut r = Ok(());
|
||||
|
||||
if read_dir.next().is_none() {
|
||||
match fs::remove_dir(path) {
|
||||
Err(e) => {
|
||||
show_error!("removing directory '{}': {}", path.display(), e);
|
||||
r = Err(1);
|
||||
}
|
||||
Ok(_) if verbose => println!("removing directory, '{}'", path.display()),
|
||||
_ => (),
|
||||
}
|
||||
} else if !ignore {
|
||||
show_error!("failed to remove '{}': Directory not empty", path.display());
|
||||
r = Err(1);
|
||||
}
|
||||
|
||||
r
|
||||
}
|
||||
|
|
|
@ -1,126 +1,238 @@
|
|||
use crate::common::util::*;
|
||||
|
||||
const DIR: &str = "dir";
|
||||
const DIR_FILE: &str = "dir/file";
|
||||
const NESTED_DIR: &str = "dir/ect/ory";
|
||||
const NESTED_DIR_FILE: &str = "dir/ect/ory/file";
|
||||
|
||||
#[cfg(windows)]
|
||||
const NOT_FOUND: &str = "The system cannot find the file specified.";
|
||||
#[cfg(not(windows))]
|
||||
const NOT_FOUND: &str = "No such file or directory";
|
||||
|
||||
#[cfg(windows)]
|
||||
const NOT_EMPTY: &str = "The directory is not empty.";
|
||||
#[cfg(not(windows))]
|
||||
const NOT_EMPTY: &str = "Directory not empty";
|
||||
|
||||
#[cfg(windows)]
|
||||
const NOT_A_DIRECTORY: &str = "The directory name is invalid.";
|
||||
#[cfg(not(windows))]
|
||||
const NOT_A_DIRECTORY: &str = "Not a directory";
|
||||
|
||||
#[test]
|
||||
fn test_rmdir_empty_directory_no_parents() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
let dir = "test_rmdir_empty_no_parents";
|
||||
|
||||
at.mkdir(dir);
|
||||
assert!(at.dir_exists(dir));
|
||||
at.mkdir(DIR);
|
||||
|
||||
ucmd.arg(dir).succeeds().no_stderr();
|
||||
ucmd.arg(DIR).succeeds().no_stderr();
|
||||
|
||||
assert!(!at.dir_exists(dir));
|
||||
assert!(!at.dir_exists(DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rmdir_empty_directory_with_parents() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
let dir = "test_rmdir_empty/with/parents";
|
||||
|
||||
at.mkdir_all(dir);
|
||||
assert!(at.dir_exists(dir));
|
||||
at.mkdir_all(NESTED_DIR);
|
||||
|
||||
ucmd.arg("-p").arg(dir).succeeds().no_stderr();
|
||||
ucmd.arg("-p").arg(NESTED_DIR).succeeds().no_stderr();
|
||||
|
||||
assert!(!at.dir_exists(dir));
|
||||
assert!(!at.dir_exists(NESTED_DIR));
|
||||
assert!(!at.dir_exists(DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rmdir_nonempty_directory_no_parents() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
let dir = "test_rmdir_nonempty_no_parents";
|
||||
let file = "test_rmdir_nonempty_no_parents/foo";
|
||||
|
||||
at.mkdir(dir);
|
||||
assert!(at.dir_exists(dir));
|
||||
at.mkdir(DIR);
|
||||
at.touch(DIR_FILE);
|
||||
|
||||
at.touch(file);
|
||||
assert!(at.file_exists(file));
|
||||
ucmd.arg(DIR)
|
||||
.fails()
|
||||
.stderr_is(format!("rmdir: failed to remove 'dir': {}", NOT_EMPTY));
|
||||
|
||||
ucmd.arg(dir).fails().stderr_is(
|
||||
"rmdir: failed to remove 'test_rmdir_nonempty_no_parents': Directory not \
|
||||
empty\n",
|
||||
);
|
||||
|
||||
assert!(at.dir_exists(dir));
|
||||
assert!(at.dir_exists(DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rmdir_nonempty_directory_with_parents() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
let dir = "test_rmdir_nonempty/with/parents";
|
||||
let file = "test_rmdir_nonempty/with/parents/foo";
|
||||
|
||||
at.mkdir_all(dir);
|
||||
assert!(at.dir_exists(dir));
|
||||
at.mkdir_all(NESTED_DIR);
|
||||
at.touch(NESTED_DIR_FILE);
|
||||
|
||||
at.touch(file);
|
||||
assert!(at.file_exists(file));
|
||||
ucmd.arg("-p").arg(NESTED_DIR).fails().stderr_is(format!(
|
||||
"rmdir: failed to remove 'dir/ect/ory': {}",
|
||||
NOT_EMPTY
|
||||
));
|
||||
|
||||
ucmd.arg("-p").arg(dir).fails().stderr_is(
|
||||
"rmdir: failed to remove 'test_rmdir_nonempty/with/parents': Directory not \
|
||||
empty\nrmdir: failed to remove 'test_rmdir_nonempty/with': Directory not \
|
||||
empty\nrmdir: failed to remove 'test_rmdir_nonempty': Directory not \
|
||||
empty\n",
|
||||
);
|
||||
|
||||
assert!(at.dir_exists(dir));
|
||||
assert!(at.dir_exists(NESTED_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rmdir_ignore_nonempty_directory_no_parents() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
let dir = "test_rmdir_ignore_nonempty_no_parents";
|
||||
let file = "test_rmdir_ignore_nonempty_no_parents/foo";
|
||||
|
||||
at.mkdir(dir);
|
||||
assert!(at.dir_exists(dir));
|
||||
|
||||
at.touch(file);
|
||||
assert!(at.file_exists(file));
|
||||
at.mkdir(DIR);
|
||||
at.touch(DIR_FILE);
|
||||
|
||||
ucmd.arg("--ignore-fail-on-non-empty")
|
||||
.arg(dir)
|
||||
.arg(DIR)
|
||||
.succeeds()
|
||||
.no_stderr();
|
||||
|
||||
assert!(at.dir_exists(dir));
|
||||
assert!(at.dir_exists(DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rmdir_ignore_nonempty_directory_with_parents() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
let dir = "test_rmdir_ignore_nonempty/with/parents";
|
||||
let file = "test_rmdir_ignore_nonempty/with/parents/foo";
|
||||
|
||||
at.mkdir_all(dir);
|
||||
assert!(at.dir_exists(dir));
|
||||
|
||||
at.touch(file);
|
||||
assert!(at.file_exists(file));
|
||||
at.mkdir_all(NESTED_DIR);
|
||||
at.touch(NESTED_DIR_FILE);
|
||||
|
||||
ucmd.arg("--ignore-fail-on-non-empty")
|
||||
.arg("-p")
|
||||
.arg(dir)
|
||||
.arg(NESTED_DIR)
|
||||
.succeeds()
|
||||
.no_stderr();
|
||||
|
||||
assert!(at.dir_exists(dir));
|
||||
assert!(at.dir_exists(NESTED_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rmdir_remove_symlink_match_gnu_error() {
|
||||
fn test_rmdir_not_a_directory() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
|
||||
let file = "file";
|
||||
let fl = "fl";
|
||||
at.touch(file);
|
||||
assert!(at.file_exists(file));
|
||||
at.symlink_file(file, fl);
|
||||
assert!(at.file_exists(fl));
|
||||
at.touch("file");
|
||||
|
||||
ucmd.arg("fl/")
|
||||
ucmd.arg("--ignore-fail-on-non-empty")
|
||||
.arg("file")
|
||||
.fails()
|
||||
.stderr_is("rmdir: failed to remove 'fl/': Not a directory");
|
||||
.no_stdout()
|
||||
.stderr_is(format!(
|
||||
"rmdir: failed to remove 'file': {}",
|
||||
NOT_A_DIRECTORY
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verbose_single() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
|
||||
at.mkdir(DIR);
|
||||
|
||||
ucmd.arg("-v")
|
||||
.arg(DIR)
|
||||
.succeeds()
|
||||
.no_stderr()
|
||||
.stdout_is("rmdir: removing directory, 'dir'\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verbose_multi() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
|
||||
at.mkdir(DIR);
|
||||
|
||||
ucmd.arg("-v")
|
||||
.arg("does_not_exist")
|
||||
.arg(DIR)
|
||||
.fails()
|
||||
.stdout_is(
|
||||
"rmdir: removing directory, 'does_not_exist'\n\
|
||||
rmdir: removing directory, 'dir'\n",
|
||||
)
|
||||
.stderr_is(format!(
|
||||
"rmdir: failed to remove 'does_not_exist': {}",
|
||||
NOT_FOUND
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verbose_nested_failure() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
|
||||
at.mkdir_all(NESTED_DIR);
|
||||
at.touch("dir/ect/file");
|
||||
|
||||
ucmd.arg("-pv")
|
||||
.arg(NESTED_DIR)
|
||||
.fails()
|
||||
.stdout_is(
|
||||
"rmdir: removing directory, 'dir/ect/ory'\n\
|
||||
rmdir: removing directory, 'dir/ect'\n",
|
||||
)
|
||||
.stderr_is(format!("rmdir: failed to remove 'dir/ect': {}", NOT_EMPTY));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_rmdir_ignore_nonempty_no_permissions() {
|
||||
use std::fs;
|
||||
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
|
||||
// We make the *parent* dir read-only to prevent deleting the dir in it.
|
||||
at.mkdir_all("dir/ect/ory");
|
||||
at.touch("dir/ect/ory/file");
|
||||
let dir_ect = at.plus("dir/ect");
|
||||
let mut perms = fs::metadata(&dir_ect).unwrap().permissions();
|
||||
perms.set_readonly(true);
|
||||
fs::set_permissions(&dir_ect, perms.clone()).unwrap();
|
||||
|
||||
// rmdir should now get a permissions error that it interprets as
|
||||
// a non-empty error.
|
||||
ucmd.arg("--ignore-fail-on-non-empty")
|
||||
.arg("dir/ect/ory")
|
||||
.succeeds()
|
||||
.no_stderr();
|
||||
|
||||
assert!(at.dir_exists("dir/ect/ory"));
|
||||
|
||||
// Politely restore permissions for cleanup
|
||||
perms.set_readonly(false);
|
||||
fs::set_permissions(&dir_ect, perms).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rmdir_remove_symlink_file() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
|
||||
at.touch("file");
|
||||
at.symlink_file("file", "fl");
|
||||
|
||||
ucmd.arg("fl/").fails().stderr_is(format!(
|
||||
"rmdir: failed to remove 'fl/': {}",
|
||||
NOT_A_DIRECTORY
|
||||
));
|
||||
}
|
||||
|
||||
// This behavior is known to happen on Linux but not all Unixes
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
#[test]
|
||||
fn test_rmdir_remove_symlink_dir() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
|
||||
at.mkdir("dir");
|
||||
at.symlink_dir("dir", "dl");
|
||||
|
||||
ucmd.arg("dl/")
|
||||
.fails()
|
||||
.stderr_is("rmdir: failed to remove 'dl/': Symbolic link not followed");
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
#[test]
|
||||
fn test_rmdir_remove_symlink_dangling() {
|
||||
let (at, mut ucmd) = at_and_ucmd!();
|
||||
|
||||
at.symlink_dir("dir", "dl");
|
||||
|
||||
ucmd.arg("dl/")
|
||||
.fails()
|
||||
.stderr_is("rmdir: failed to remove 'dl/': Symbolic link not followed");
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue