diff --git a/src/uu/df/src/filesystem.rs b/src/uu/df/src/filesystem.rs index bfd9826460a..2120a173c58 100644 --- a/src/uu/df/src/filesystem.rs +++ b/src/uu/df/src/filesystem.rs @@ -121,19 +121,16 @@ where impl Filesystem { // TODO: resolve uuid in `mount_info.dev_name` if exists pub(crate) fn new(mount_info: MountInfo, file: Option) -> Option { + #[cfg(unix)] let stat_path = if mount_info.mount_dir.is_empty() { - #[cfg(unix)] - { - mount_info.dev_name.clone().into() - } - #[cfg(windows)] - { - // On windows, we expect the volume id - mount_info.dev_id.clone().into() - } + mount_info.dev_name.clone().into() } else { mount_info.mount_dir.clone() }; + + #[cfg(windows)] + let stat_path = mount_info.dev_id.clone(); // On windows, we expect the volume id + #[cfg(unix)] let usage = FsUsage::new(statfs(&stat_path).ok()?); #[cfg(windows)] diff --git a/src/uu/rm/Cargo.toml b/src/uu/rm/Cargo.toml index cf53e032388..657a32b492e 100644 --- a/src/uu/rm/Cargo.toml +++ b/src/uu/rm/Cargo.toml @@ -18,11 +18,16 @@ workspace = true path = "src/rm.rs" [dependencies] -thiserror = { workspace = true } clap = { workspace = true } -uucore = { workspace = true, features = ["fs", "parser"] } fluent = { workspace = true } indicatif = { workspace = true } +thiserror = { workspace = true } +uucore = { workspace = true, features = [ + "fs", + "fsext", + "parser", + "safe-traversal", +] } [target.'cfg(all(unix, not(target_os = "redox")))'.dependencies] uucore = { workspace = true, features = ["safe-traversal"] } diff --git a/src/uu/rm/src/platform/unix.rs b/src/uu/rm/src/platform/unix.rs index e890ab15823..db4d2e1860b 100644 --- a/src/uu/rm/src/platform/unix.rs +++ b/src/uu/rm/src/platform/unix.rs @@ -11,6 +11,7 @@ use indicatif::ProgressBar; use std::ffi::OsStr; use std::fs; use std::io::{IsTerminal, stdin}; +use std::os::unix::fs::MetadataExt; use std::os::unix::fs::PermissionsExt; use std::path::Path; use uucore::display::Quotable; @@ -21,9 +22,9 @@ use uucore::show_error; use uucore::translate; use super::super::{ - InteractiveMode, Options, is_dir_empty, is_readable_metadata, prompt_descend, remove_file, - show_permission_denied_error, show_removal_error, verbose_removed_directory, - verbose_removed_file, + InteractiveMode, Options, PreserveRoot, is_dir_empty, is_readable_metadata, prompt_descend, + remove_file, show_one_fs_error, show_permission_denied_error, show_removal_error, + verbose_removed_directory, verbose_removed_file, }; #[inline] @@ -279,11 +280,11 @@ pub fn safe_remove_dir_recursive( ) -> bool { // Base case 1: this is a file or a symbolic link. // Use lstat to avoid race condition between check and use - let initial_mode = match fs::symlink_metadata(path) { + let (initial_mode, initial_dev) = match fs::symlink_metadata(path) { Ok(metadata) if !metadata.is_dir() => { return remove_file(path, options, progress_bar); } - Ok(metadata) => metadata.permissions().mode(), + Ok(metadata) => (metadata.permissions().mode(), metadata.dev()), Err(e) => { return show_removal_error(e, path); } @@ -309,11 +310,12 @@ pub fn safe_remove_dir_recursive( } }; - let error = safe_remove_dir_recursive_impl(path, &dir_fd, options); + let (had_command_error, has_remaining_children) = + safe_remove_dir_recursive_impl(path, &dir_fd, options, Some(initial_dev)); // After processing all children, remove the directory itself - if error { - error + if had_command_error || has_remaining_children { + had_command_error } else { // Ask user permission if needed if options.interactive == InteractiveMode::Always @@ -340,13 +342,18 @@ pub fn safe_remove_dir_recursive( if let Some(result) = safe_remove_empty_dir(path, options, progress_bar) { result } else { - remove_dir_with_special_cases(path, options, error) + remove_dir_with_special_cases(path, options, false) } } } #[cfg(not(target_os = "redox"))] -pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Options) -> bool { +pub fn safe_remove_dir_recursive_impl( + path: &Path, + dir_fd: &DirFd, + options: &Options, + parent_dev: Option, +) -> (bool, bool) { // Read directory entries using safe traversal let entries = match dir_fd.read_dir() { Ok(entries) => entries, @@ -354,14 +361,15 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt if !options.force { show_permission_denied_error(path); } - return !options.force; + return (!options.force, true); } Err(e) => { - return handle_error_with_force(e, path, options); + return (handle_error_with_force(e, path, options), true); } }; - let mut error = false; + let mut had_command_error = false; + let mut has_remaining_children = false; // Process each entry for entry_name in entries { @@ -371,7 +379,8 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt let entry_stat = match dir_fd.stat_at(&entry_name, false) { Ok(stat) => stat, Err(e) => { - error |= handle_error_with_force(e, &entry_path, options); + had_command_error |= handle_error_with_force(e, &entry_path, options); + has_remaining_children = true; continue; } }; @@ -380,11 +389,23 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt let is_dir = ((entry_stat.st_mode as libc::mode_t) & libc::S_IFMT) == libc::S_IFDIR; if is_dir { + if options.one_fs || options.preserve_root == PreserveRoot::YesAll { + if let Some(p_dev) = parent_dev { + if entry_stat.st_dev as u64 != p_dev { + show_one_fs_error(&entry_path, options); + had_command_error = true; + has_remaining_children = true; + continue; + } + } + } + // Ask user if they want to descend into this directory if options.interactive == InteractiveMode::Always && !is_dir_empty(&entry_path) && !prompt_descend(&entry_path) { + has_remaining_children = true; continue; } @@ -394,49 +415,68 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt Err(e) => { // If we can't open the subdirectory for safe traversal, // try to handle it as best we can with safe operations - if e.kind() == std::io::ErrorKind::PermissionDenied { - error |= handle_permission_denied( - dir_fd, - entry_name.as_ref(), - &entry_path, - options, - ); + let failed = if e.kind() == std::io::ErrorKind::PermissionDenied { + handle_permission_denied(dir_fd, entry_name.as_ref(), &entry_path, options) } else { - error |= handle_error_with_force(e, &entry_path, options); + handle_error_with_force(e, &entry_path, options) + }; + + if failed { + had_command_error = true; + has_remaining_children = true; } continue; } }; - let child_error = safe_remove_dir_recursive_impl(&entry_path, &child_dir_fd, options); - error |= child_error; + let (c_error, c_remains) = safe_remove_dir_recursive_impl( + &entry_path, + &child_dir_fd, + options, + Some(entry_stat.st_dev as u64), + ); + had_command_error |= c_error; + has_remaining_children |= c_remains; + + if c_error || c_remains { + continue; + } // Ask user permission if needed for this subdirectory - if !child_error - && options.interactive == InteractiveMode::Always + if options.interactive == InteractiveMode::Always && !prompt_dir_with_mode(&entry_path, entry_stat.st_mode as libc::mode_t, options) { + has_remaining_children = true; continue; } // Remove the now-empty subdirectory using safe unlinkat - if !child_error { - error |= handle_unlink(dir_fd, entry_name.as_ref(), &entry_path, true, options); - } + had_command_error |= + handle_unlink(dir_fd, entry_name.as_ref(), &entry_path, true, options); } else { // Remove file - check if user wants to remove it first if prompt_file_with_stat(&entry_path, &entry_stat, options) { - error |= handle_unlink(dir_fd, entry_name.as_ref(), &entry_path, false, options); + if handle_unlink(dir_fd, entry_name.as_ref(), &entry_path, false, options) { + had_command_error = true; + has_remaining_children = true; + } + } else { + has_remaining_children = true; } } } - error + (had_command_error, has_remaining_children) } #[cfg(target_os = "redox")] -pub fn safe_remove_dir_recursive_impl(_path: &Path, _dir_fd: &DirFd, _options: &Options) -> bool { +pub fn safe_remove_dir_recursive_impl( + _path: &Path, + _dir_fd: &DirFd, + _options: &Options, + _parent_dev: Option, +) -> (bool, bool) { // safe_traversal stat_at is not supported on Redox // This shouldn't be called on Redox, but provide a stub for compilation - true // Return error + (true, true) // Return error } diff --git a/src/uu/rm/src/rm.rs b/src/uu/rm/src/rm.rs index fa80458fa2b..48c4b27ef75 100644 --- a/src/uu/rm/src/rm.rs +++ b/src/uu/rm/src/rm.rs @@ -15,6 +15,8 @@ use std::ops::BitOr; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; #[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::MAIN_SEPARATOR; use std::path::{Path, PathBuf}; @@ -128,6 +130,13 @@ impl From<&str> for InteractiveMode { } } +#[derive(PartialEq)] +pub enum PreserveRoot { + Default, + YesAll, + No, +} + /// Options for the `rm` command /// /// All options are public so that the options can be programmatically @@ -154,7 +163,7 @@ pub struct Options { /// `--one-file-system` pub one_fs: bool, /// `--preserve-root`/`--no-preserve-root` - pub preserve_root: bool, + pub preserve_root: PreserveRoot, /// `-r`, `--recursive` pub recursive: bool, /// `-d`, `--dir` @@ -175,7 +184,7 @@ impl Default for Options { force: false, interactive: InteractiveMode::PromptProtected, one_fs: false, - preserve_root: true, + preserve_root: PreserveRoot::Default, recursive: false, dir: false, verbose: false, @@ -245,7 +254,17 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } }, one_fs: matches.get_flag(OPT_ONE_FILE_SYSTEM), - preserve_root: !matches.get_flag(OPT_NO_PRESERVE_ROOT), + preserve_root: if matches.get_flag(OPT_NO_PRESERVE_ROOT) { + PreserveRoot::No + } else { + match matches + .get_one::(OPT_PRESERVE_ROOT) + .map(|s| s.as_str()) + { + Some("all") => PreserveRoot::YesAll, + _ => PreserveRoot::Default, + } + }, recursive: matches.get_flag(OPT_RECURSIVE), dir: matches.get_flag(OPT_DIR), verbose: matches.get_flag(OPT_VERBOSE), @@ -259,7 +278,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // manually parse all args to verify --no-preserve-root did not get abbreviated (clap does // allow this) - if !options.preserve_root && !args.iter().any(|arg| arg == "--no-preserve-root") { + if options.preserve_root == PreserveRoot::No + && !args.iter().any(|arg| arg == "--no-preserve-root") + { return Err(RmError::MayNotAbbreviateNoPreserveRoot.into()); } @@ -351,7 +372,10 @@ pub fn uu_app() -> Command { Arg::new(OPT_PRESERVE_ROOT) .long(OPT_PRESERVE_ROOT) .help(translate!("rm-help-preserve-root")) - .action(ArgAction::SetTrue), + .value_parser(["all"]) + .default_value("all") + .default_missing_value("all") + .hide_default_value(true), ) .arg( Arg::new(OPT_RECURSIVE) @@ -465,7 +489,6 @@ fn count_files_in_directory(p: &Path) -> u64 { 1 + entries_count } -// TODO: implement one-file-system (this may get partially implemented in walkdir) /// Remove (or unlink) the given files /// /// Returns true if it has encountered an error. @@ -559,6 +582,17 @@ fn is_writable_metadata(_metadata: &Metadata) -> bool { true } +pub(crate) fn show_one_fs_error(path: &Path, options: &Options) { + show_error!( + "skipping {}, since it's on a different device", + path.quote() + ); + + if !options.one_fs && options.preserve_root == PreserveRoot::YesAll { + show_error!("and --preserve-root=all is in effect"); + } +} + /// Recursively remove the directory tree rooted at the given path. /// /// If `path` is a file or a symbolic link, just remove it. If it is a @@ -580,7 +614,12 @@ fn remove_dir_recursive( return remove_file(path, options, progress_bar); } - // Base case 2: this is a non-empty directory, but the user + // Base case 2: check if a path is on the same file system + if check_and_report_one_fs(path, options) { + return true; + } + + // Base case 3: this is a non-empty directory, but the user // doesn't want to descend into it. if options.interactive == InteractiveMode::Always && !is_dir_empty(path) @@ -668,9 +707,100 @@ fn remove_dir_recursive( } } +#[derive(Debug)] +enum OneFsError { + /// The path is on a different device or file system (mount point boundary). + CrossDevice, + + /// Failed to retrieve metadata for the path or its parent. + #[cfg(unix)] + StatFailed(String), +} + +/// Helper function to check fs and report errors if necessary. +/// Returns true if the operation should be skipped/returned (i.e., on error). +fn check_and_report_one_fs(path: &Path, options: &Options) -> bool { + match check_one_fs(path, options) { + Ok(()) => false, + #[cfg(unix)] + Err(OneFsError::StatFailed(msg)) => { + show_error!("{}", msg); + show_one_fs_error(path, options); + true + } + Err(OneFsError::CrossDevice) => { + show_one_fs_error(path, options); + true + } + } +} + +/// Check if a path is on the same file system when `--one-file-system` or `--preserve-root=all` options are enabled. +/// Return `OK(())` if the path is on the same file system, +/// or an additional error describing why it should be skipped. +fn check_one_fs(path: &Path, options: &Options) -> Result<(), OneFsError> { + // If neither `--one-file-system` nor `--preserve-root=all` is active, + // always proceed + if !options.one_fs && options.preserve_root != PreserveRoot::YesAll { + return Ok(()); + } + + let parent_path = match path.parent() { + Some(p) if !p.as_os_str().is_empty() => p, + _ => Path::new("."), + }; + + let is_different = { + #[cfg(unix)] + { + let child_meta = path.symlink_metadata().map_err(|err| { + OneFsError::StatFailed(format!("cannot stat {}: {}", path.quote(), err)) + })?; + + let parent_meta = parent_path.symlink_metadata().map_err(|err| { + OneFsError::StatFailed(format!("cannot stat parent of {}: {}", path.quote(), err)) + })?; + + child_meta.dev() != parent_meta.dev() + } + #[cfg(windows)] + { + fn get_drive_prefix(p: &Path) -> Option<&std::ffi::OsStr> { + use std::path::Component; + p.components().next().and_then(|c| match c { + Component::Prefix(prefix) => Some(prefix.as_os_str()), + _ => None, + }) + } + + let child_drive = get_drive_prefix(path); + let parent_drive = get_drive_prefix(parent_path); + + match (child_drive, parent_drive) { + (Some(c), Some(p)) => c != p, + _ => false, + } + } + #[cfg(not(any(unix, windows)))] + { + false + } + }; + + if is_different { + return Err(OneFsError::CrossDevice); + } + + Ok(()) +} + fn handle_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar>) -> bool { let mut had_err = false; + if check_and_report_one_fs(path, options) { + return true; + } + let path = clean_trailing_slashes(path); if path_is_current_or_parent_directory(path) { show_error!( @@ -681,9 +811,9 @@ fn handle_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar> } let is_root = path.has_root() && path.parent().is_none(); - if options.recursive && (!is_root || !options.preserve_root) { + if options.recursive && (!is_root || options.preserve_root == PreserveRoot::No) { had_err = remove_dir_recursive(path, options, progress_bar); - } else if options.dir && (!is_root || !options.preserve_root) { + } else if options.dir && (!is_root || options.preserve_root == PreserveRoot::No) { had_err = remove_dir(path, options, progress_bar).bitor(had_err); } else if options.recursive { show_error!("{}", RmError::DangerousRecursiveOperation); diff --git a/src/uucore/src/lib/features/fsext.rs b/src/uucore/src/lib/features/fsext.rs index c31b8272599..526b87a9826 100644 --- a/src/uucore/src/lib/features/fsext.rs +++ b/src/uucore/src/lib/features/fsext.rs @@ -324,12 +324,18 @@ impl MountInfo { let mount_root = to_nul_terminated_wide_string(&mount_root); GetDriveTypeW(mount_root.as_ptr()) }; + + let mount_dir = Path::new(&mount_root) + .canonicalize() + .unwrap_or_default() + .into_os_string(); + Some(Self { dev_id: volume_name, dev_name, fs_type: fs_type.unwrap_or_default(), mount_root: mount_root.into(), // TODO: We should figure out how to keep an OsString here. - mount_dir: OsString::new(), + mount_dir, mount_option: String::new(), remote, dummy: false, diff --git a/tests/by-util/test_rm.rs b/tests/by-util/test_rm.rs index e262e9612b1..253c454d3de 100644 --- a/tests/by-util/test_rm.rs +++ b/tests/by-util/test_rm.rs @@ -1188,6 +1188,144 @@ fn test_rm_directory_not_writable() { assert!(!at.dir_exists("b/d")); // Should be removed } +#[test] +#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] +fn test_rm_one_file_system() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + // Test must be run as root (or with `sudo -E`) + if scene.cmd("whoami").run().stdout_str() != "root\n" { + println!("Skipping test_rm_one_file_system: must be run as root"); + return; + } + + // Define paths for temporary files and directories + let img_path = "fs.img"; + let mount_point = "fs"; + let remove_dir = "a"; + let bind_mount_point = "a/b"; + + at.touch(img_path); + + // Create filesystem image + scene + .cmd("dd") + .args(&[ + "if=/dev/zero", + &format!("of={img_path}"), + "bs=1M", + "count=50", + ]) + .succeeds(); + + // Create ext4 filesystem + scene.cmd("/sbin/mkfs.ext4").arg(img_path).succeeds(); + + // Prepare directory structure + at.mkdir_all(mount_point); + at.mkdir_all(bind_mount_point); + + // Mount as loop device + scene + .cmd("mount") + .args(&["-o", "loop", img_path, mount_point]) + .succeeds(); + + // Create test directory + at.mkdir_all(&format!("{mount_point}/x")); + + // Create bind mount + scene + .cmd("mount") + .args(&["--bind", mount_point, bind_mount_point]) + .succeeds(); + + // Run the test + scene + .ucmd() + .args(&["--one-file-system", "-rf", remove_dir]) + .fails() + .stderr_contains(format!("rm: skipping '{bind_mount_point}'")); + + // Cleanup + let _ = scene.cmd("umount").arg(bind_mount_point).run(); + let _ = scene.cmd("umount").arg(mount_point).run(); + let _ = scene + .cmd("rm") + .args(&["-rf", mount_point, bind_mount_point]) + .run(); +} + +#[test] +#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] +fn test_rm_preserve_root() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + // Test must be run as root (or with `sudo -E`) + if scene.cmd("whoami").run().stdout_str() != "root\n" { + println!("Skipping test_rm_one_file_system: must be run as root"); + return; + } + + // Define paths for temporary files and directories + let img_path = "fs.img"; + let mount_point = "fs"; + let bind_mount_point = "a/b"; + + at.touch(img_path); + + // Create filesystem image + scene + .cmd("dd") + .args(&[ + "if=/dev/zero", + &format!("of={img_path}"), + "bs=1M", + "count=50", + ]) + .succeeds(); + + // Create ext4 filesystem + scene.cmd("/sbin/mkfs.ext4").arg(img_path).succeeds(); + + // Prepare directory structure + at.mkdir_all(mount_point); + at.mkdir_all(bind_mount_point); + + // Mount as loop device + scene + .cmd("mount") + .args(&["-o", "loop", img_path, mount_point]) + .succeeds(); + + // Create test directory + at.mkdir_all(&format!("{mount_point}/x")); + + // Create bind mount + scene + .cmd("mount") + .args(&["--bind", mount_point, bind_mount_point]) + .succeeds(); + + // Run the test + scene + .ucmd() + .args(&["--preserve-root=all", "-rf", bind_mount_point]) + .fails() + .stderr_contains(format!("rm: skipping '{bind_mount_point}'")) + .stderr_contains("rm: and --preserve-root=all is in effect"); + + // Cleanup + let _ = scene.cmd("umount").arg(bind_mount_point).run(); + let _ = scene.cmd("umount").arg(mount_point).run(); + let _ = scene + .cmd("rm") + .args(&["-rf", mount_point, bind_mount_point]) + .run(); +} + #[test] fn test_progress_flag_short() { let (at, mut ucmd) = at_and_ucmd!(); diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 70886e5e912..acebde04107 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -197,6 +197,11 @@ grep -rlE '/usr/local/bin/\s?/usr/local/bin' init.cfg tests/* | xargs -r "${SED} sed -i -e "s|removed directory 'a/'|removed directory 'a'|g" tests/rm/v-slash.sh +if test "$(grep -c 'rm: skipping ' tests/rm/one-file-system.sh)" -eq 1; then + # Do it only once. + sed -i -e "s/ >> exp/ > exp/g" -e "s|rm: and --preserve-root=all is in effect|rm: skipping 'a/b', since it's on a different device\nrm: and --preserve-root=all is in effect|g" tests/rm/one-file-system.sh +fi + # 'rel' doesn't exist. Our implementation is giving a better message. sed -i -e "s|rm: cannot remove 'rel': Permission denied|rm: cannot remove 'rel': No such file or directory|g" tests/rm/inaccessible.sh