diff --git a/src/uu/rm/locales/en-US.ftl b/src/uu/rm/locales/en-US.ftl index a84f746f231..185041cf5df 100644 --- a/src/uu/rm/locales/en-US.ftl +++ b/src/uu/rm/locales/en-US.ftl @@ -21,8 +21,7 @@ rm-help-prompt-once = prompt once before removing more than three files, or when rm-help-interactive = prompt according to WHEN: never, once (-I), or always (-i). Without WHEN, prompts always rm-help-one-file-system = when removing a hierarchy recursively, skip any directory that is on a file - system different from that of the corresponding command line argument (NOT - IMPLEMENTED) + system different from that of the corresponding command line argument rm-help-no-preserve-root = do not treat '/' specially rm-help-preserve-root = do not remove '/' (default) rm-help-recursive = remove directories and their contents recursively @@ -42,6 +41,8 @@ rm-error-cannot-remove-is-directory = cannot remove {$file}: Is a directory rm-error-dangerous-recursive-operation = it is dangerous to operate recursively on '/' rm-error-use-no-preserve-root = use --no-preserve-root to override this failsafe rm-error-refusing-to-remove-directory = refusing to remove '.' or '..' directory: skipping '{$path}' +rm-error-skipping-directory-on-different-device = skipping '{$path}', since it's on a different device +rm-error-preserve-root-all = and --preserve-root=all is in effect rm-error-cannot-remove = cannot remove {$file} # Verbose messages diff --git a/src/uu/rm/locales/fr-FR.ftl b/src/uu/rm/locales/fr-FR.ftl index a3da4ba0b2b..5bcb0e9e430 100644 --- a/src/uu/rm/locales/fr-FR.ftl +++ b/src/uu/rm/locales/fr-FR.ftl @@ -21,8 +21,7 @@ rm-help-prompt-once = demander une fois avant de supprimer plus de trois fichier rm-help-interactive = demander selon QUAND : never, once (-I), ou always (-i). Sans QUAND, demande toujours rm-help-one-file-system = lors de la suppression récursive d'une hiérarchie, ignorer tout répertoire situé sur un - système de fichiers différent de celui de l'argument de ligne de commande correspondant (NON - IMPLÉMENTÉ) + système de fichiers différent de celui de l'argument de ligne de commande correspondant rm-help-no-preserve-root = ne pas traiter '/' spécialement rm-help-preserve-root = ne pas supprimer '/' (par défaut) rm-help-recursive = supprimer les répertoires et leur contenu récursivement @@ -42,6 +41,8 @@ rm-error-cannot-remove-is-directory = impossible de supprimer {$file} : C'est un rm-error-dangerous-recursive-operation = il est dangereux d'opérer récursivement sur '/' rm-error-use-no-preserve-root = utilisez --no-preserve-root pour outrepasser cette protection rm-error-refusing-to-remove-directory = refus de supprimer le répertoire '.' ou '..' : ignorer '{$path}' +rm-error-skipping-directory-on-different-device = ignorer '{$path}', car il se trouve sur un périphérique différent +rm-error-preserve-root-all = et --preserve-root=all est actif rm-error-cannot-remove = impossible de supprimer {$file} # Messages verbeux diff --git a/src/uu/rm/src/platform/linux.rs b/src/uu/rm/src/platform/linux.rs index 6c7d3239572..44189b737c5 100644 --- a/src/uu/rm/src/platform/linux.rs +++ b/src/uu/rm/src/platform/linux.rs @@ -10,6 +10,8 @@ use indicatif::ProgressBar; use std::ffi::OsStr; use std::fs; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; use std::path::Path; use uucore::display::Quotable; use uucore::error::FromIo; @@ -18,8 +20,8 @@ use uucore::show_error; use uucore::translate; use super::super::{ - InteractiveMode, Options, is_dir_empty, is_readable_metadata, prompt_descend, prompt_dir, - prompt_file, remove_file, show_permission_denied_error, show_removal_error, + InteractiveMode, Options, RmError, is_dir_empty, is_readable_metadata, prompt_descend, + prompt_dir, prompt_file, remove_file, show_permission_denied_error, show_removal_error, verbose_removed_directory, verbose_removed_file, }; @@ -193,6 +195,7 @@ pub fn safe_remove_dir_recursive( path: &Path, options: &Options, progress_bar: Option<&ProgressBar>, + current_dev_id: Option, ) -> bool { // Base case 1: this is a file or a symbolic link. // Use lstat to avoid race condition between check and use @@ -206,6 +209,16 @@ pub fn safe_remove_dir_recursive( } } + let check_device = options.one_fs || options.preserve_root_all; + #[cfg(unix)] + let current_dev_id = if check_device { + current_dev_id.or_else(|| path.symlink_metadata().ok().map(|m| m.dev())) + } else { + None + }; + #[cfg(not(unix))] + let current_dev_id: Option = None; + // Try to open the directory using DirFd for secure traversal let dir_fd = match DirFd::open(path) { Ok(fd) => fd, @@ -226,7 +239,12 @@ pub fn safe_remove_dir_recursive( } }; - let error = safe_remove_dir_recursive_impl(path, &dir_fd, options); + let error = safe_remove_dir_recursive_impl( + path, + &dir_fd, + options, + if check_device { current_dev_id } else { None }, + ); // After processing all children, remove the directory itself if error { @@ -256,7 +274,12 @@ pub fn safe_remove_dir_recursive( } } -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_id: Option, +) -> bool { // Read directory entries using safe traversal let entries = match dir_fd.read_dir() { Ok(entries) => entries, @@ -272,6 +295,7 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt }; let mut error = false; + let check_device = options.one_fs || options.preserve_root_all; // Process each entry for entry_name in entries { @@ -290,6 +314,23 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt let is_dir = (entry_stat.st_mode & libc::S_IFMT) == libc::S_IFDIR; if is_dir { + if check_device { + if let Some(parent_dev_id) = parent_dev_id { + if entry_stat.st_dev != parent_dev_id { + show_error!( + "{}", + RmError::SkippingDirectoryOnDifferentDevice( + entry_path.as_os_str().to_os_string() + ) + ); + if options.preserve_root_all { + show_error!("{}", RmError::PreserveRootAllInEffect); + } + error = true; + continue; + } + } + } // Ask user if they want to descend into this directory if options.interactive == InteractiveMode::Always && !is_dir_empty(&entry_path) @@ -318,7 +359,16 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt } }; - let child_error = safe_remove_dir_recursive_impl(&entry_path, &child_dir_fd, options); + let child_error = safe_remove_dir_recursive_impl( + &entry_path, + &child_dir_fd, + options, + if check_device { + Some(entry_stat.st_dev) + } else { + None + }, + ); error = error || child_error; // Ask user permission if needed for this subdirectory diff --git a/src/uu/rm/src/rm.rs b/src/uu/rm/src/rm.rs index a20a57d7f36..9a3cdc5069c 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}; @@ -45,6 +47,12 @@ enum RmError { UseNoPreserveRoot, #[error("{}", translate!("rm-error-refusing-to-remove-directory", "path" => _0.to_string_lossy()))] RefusingToRemoveDirectory(OsString), + #[error("{}", translate!("rm-error-skipping-directory-on-different-device", "path" => _0.to_string_lossy()))] + #[cfg(unix)] + SkippingDirectoryOnDifferentDevice(OsString), + #[cfg(unix)] + #[error("{}", translate!("rm-error-preserve-root-all"))] + PreserveRootAllInEffect, } impl UError for RmError {} @@ -153,6 +161,8 @@ pub struct Options { pub one_fs: bool, /// `--preserve-root`/`--no-preserve-root` pub preserve_root: bool, + /// `--preserve-root=all` + pub preserve_root_all: bool, /// `-r`, `--recursive` pub recursive: bool, /// `-d`, `--dir` @@ -174,6 +184,7 @@ impl Default for Options { interactive: InteractiveMode::PromptProtected, one_fs: false, preserve_root: true, + preserve_root_all: false, recursive: false, dir: false, verbose: false, @@ -243,6 +254,10 @@ 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_all: matches + .get_many::(OPT_PRESERVE_ROOT) + .map(|values| values.into_iter().any(|v| v == "all")) + .unwrap_or(false), recursive: matches.get_flag(OPT_RECURSIVE), dir: matches.get_flag(OPT_DIR), verbose: matches.get_flag(OPT_VERBOSE), @@ -341,7 +356,10 @@ pub fn uu_app() -> Command { Arg::new(OPT_PRESERVE_ROOT) .long(OPT_PRESERVE_ROOT) .help(translate!("rm-help-preserve-root")) - .action(ArgAction::SetTrue), + .num_args(0..=1) + .require_equals(true) + .value_parser(ShortcutValueParser::new([PossibleValue::new("all")])) + .action(ArgAction::Append), ) .arg( Arg::new(OPT_RECURSIVE) @@ -485,8 +503,20 @@ pub fn remove(files: &[&OsStr], options: &Options) -> bool { } any_files_processed = true; + + #[cfg(unix)] + let parent_dev_id = if options.one_fs || options.preserve_root_all { + file.parent() + .and_then(|p| p.symlink_metadata().ok()) + .map(|m| m.dev()) + } else { + None + }; + #[cfg(not(unix))] + let parent_dev_id = None; + if metadata.is_dir() { - handle_dir(file, options, progress_bar.as_ref()) + handle_dir(file, options, progress_bar.as_ref(), parent_dev_id) } else if is_symlink_dir(&metadata) { remove_dir(file, options, progress_bar.as_ref()) } else { @@ -585,14 +615,23 @@ fn remove_dir_recursive( path: &Path, options: &Options, progress_bar: Option<&ProgressBar>, + _parent_dev_id: Option, ) -> bool { + #[cfg(unix)] + let parent_dev_id = _parent_dev_id; + + let metadata = match path.symlink_metadata() { + Ok(metadata) => metadata, + Err(e) => return show_removal_error(e, path), + }; + // Base case 1: this is a file or a symbolic link. // // The symbolic link case is important because it could be a link to // a directory and we don't want to recurse. In particular, this // avoids an infinite recursion in the case of a link to the current // directory, like `ln -s . link`. - if !path.is_dir() || path.is_symlink() { + if !metadata.is_dir() || path.is_symlink() { return remove_file(path, options, progress_bar); } @@ -605,10 +644,34 @@ fn remove_dir_recursive( return false; } + // Base case 3: this is a directory on a different device + #[cfg(unix)] + if let Some(parent_dev_id) = parent_dev_id { + if metadata.dev() != parent_dev_id { + show_error!( + "{}", + RmError::SkippingDirectoryOnDifferentDevice(path.as_os_str().to_os_string()) + ); + if options.preserve_root_all { + show_error!("{}", RmError::PreserveRootAllInEffect); + } + return true; + } + } + + #[cfg(unix)] + let next_parent_dev_id = if options.one_fs || options.preserve_root_all { + Some(metadata.dev()) + } else { + None + }; + #[cfg(not(unix))] + let next_parent_dev_id = None; + // Use secure traversal on Linux for all recursive directory removals #[cfg(target_os = "linux")] { - safe_remove_dir_recursive(path, options, progress_bar) + safe_remove_dir_recursive(path, options, progress_bar, next_parent_dev_id) } // Fallback for non-Linux or use fs::remove_dir_all for very long paths @@ -641,8 +704,12 @@ fn remove_dir_recursive( match entry { Err(_) => error = true, Ok(entry) => { - let child_error = - remove_dir_recursive(&entry.path(), options, progress_bar); + let child_error = remove_dir_recursive( + &entry.path(), + options, + progress_bar, + next_parent_dev_id, + ); error = error || child_error; } } @@ -684,7 +751,12 @@ fn remove_dir_recursive( } } -fn handle_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar>) -> bool { +fn handle_dir( + path: &Path, + options: &Options, + progress_bar: Option<&ProgressBar>, + parent_dev_id: Option, +) -> bool { let mut had_err = false; let path = clean_trailing_slashes(path); @@ -698,7 +770,7 @@ 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) { - had_err = remove_dir_recursive(path, options, progress_bar); + had_err = remove_dir_recursive(path, options, progress_bar, parent_dev_id); } else if options.dir && (!is_root || !options.preserve_root) { had_err = remove_dir(path, options, progress_bar).bitor(had_err); } else if options.recursive { diff --git a/tests/by-util/test_rm.rs b/tests/by-util/test_rm.rs index 38230f2ad36..010e373b629 100644 --- a/tests/by-util/test_rm.rs +++ b/tests/by-util/test_rm.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore volname #![allow(clippy::stable_sort_primitive)] use std::process::Stdio; @@ -1217,3 +1218,131 @@ fn test_progress_no_output_on_error() { .stderr_contains("cannot remove") .stderr_contains("No such file or directory"); } + +#[cfg(target_os = "linux")] +#[test] +fn test_one_file_system_linux() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + let a_b = "a/b"; + let t_y = "other_partition_tmpdir/y"; + + at.mkdir_all(a_b); + at.mkdir_all(t_y); + + let root = at.as_string(); + let a_b_path = format!("{root}/{a_b}"); + let t_path = format!("{root}/other_partition_tmpdir"); + + // This test requires root privileges and mount --bind support. + let status = std::process::Command::new("mount") + .arg("--bind") + .arg(&t_path) + .arg(&a_b_path) + .status(); + + if status.is_err() || !status.unwrap().success() { + println!("Skipping test_one_file_system_linux: mount --bind failed (requires root?)"); + return; + } + + struct MountGuard { + path: String, + } + impl Drop for MountGuard { + fn drop(&mut self) { + let _ = std::process::Command::new("umount") + .arg(&self.path) + .status(); + } + } + let _guard = MountGuard { + path: a_b_path.clone(), + }; + + scene + .ucmd() + .arg("--one-file-system") + .arg("-rf") + .arg("a") + .fails() + .stderr_contains("skipping 'a/b', since it's on a different device"); + + assert!(at.dir_exists(t_y)); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_one_file_system_macos() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + let a_b = "a/b"; + let t_y = "other_partition_tmpdir/y"; + + at.mkdir_all(a_b); + at.mkdir_all(t_y); + + let root = at.as_string(); + let a_b_path = format!("{root}/{a_b}"); + let dmg_path = format!("{root}/auxiliary.dmg"); + + // Create a disk image to simulate another filesystem. + let create_status = std::process::Command::new("hdiutil") + .args([ + "create", + "-size", + "10m", + "-fs", + "HFS+", + "-volname", + "auxiliary", + &dmg_path, + ]) + .status(); + if create_status.is_err() || !create_status.unwrap().success() { + println!("Skipping test_one_file_system_macos: hdiutil create failed"); + return; + } + + let attach_status = std::process::Command::new("hdiutil") + .args(["attach", &dmg_path, "-mountpoint", &a_b_path]) + .status(); + if attach_status.is_err() || !attach_status.unwrap().success() { + println!("Skipping test_one_file_system_macos: hdiutil attach failed"); + let _ = std::fs::remove_file(&dmg_path); + return; + } + + struct MountGuard { + mount_point: String, + dmg_path: String, + } + impl Drop for MountGuard { + fn drop(&mut self) { + let _ = std::process::Command::new("hdiutil") + .args(["detach", &self.mount_point]) + .status(); + let _ = std::fs::remove_file(&self.dmg_path); + } + } + let _guard = MountGuard { + mount_point: a_b_path.clone(), + dmg_path: dmg_path.clone(), + }; + + scene + .ucmd() + .arg("--one-file-system") + .arg("-rf") + .arg("a") + .fails() + .stderr_contains("skipping 'a/b', since it's on a different device"); + + assert!(at.dir_exists(t_y)); +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +#[test] +fn test_one_file_system_unsupported() { + println!("Skipping test_one_file_system: unsupported OS"); +}