Skip to content

Commit f4adf86

Browse files
committed
feat(rm): Implement --one-file-system and --preserved-root=all
This comment enhances the safety of recursive deletion in `rm` by introducing two key features to prevent accidental data loss. - `--one-file-system`: When specified, `rm` will not traverse into directories that are on a different file system from the one on which the traversal began, this prevents `rm -r` from accidentally deleting data on mounted volumes. - `--preserve-root=all`: The `--preserve-root` option is enhanced to accept an optional argument, `all`. When set to `all`, `rm` will refuse to remove any directory that is a mount point. The default behavior (`--preserve-root` without an argument) remains, protecting only the root directory (`/`).
1 parent 5b261bc commit f4adf86

File tree

6 files changed

+270
-19
lines changed

6 files changed

+270
-19
lines changed

src/uu/df/src/filesystem.rs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,19 +121,15 @@ where
121121
impl Filesystem {
122122
// TODO: resolve uuid in `mount_info.dev_name` if exists
123123
pub(crate) fn new(mount_info: MountInfo, file: Option<OsString>) -> Option<Self> {
124+
#[cfg(unix)]
124125
let _stat_path = if mount_info.mount_dir.is_empty() {
125-
#[cfg(unix)]
126-
{
127-
mount_info.dev_name.clone().into()
128-
}
129-
#[cfg(windows)]
130-
{
131-
// On windows, we expect the volume id
132-
mount_info.dev_id.clone().into()
133-
}
126+
mount_info.dev_name.clone().into()
134127
} else {
135128
mount_info.mount_dir.clone()
136129
};
130+
#[cfg(windows)]
131+
let _stat_path = mount_info.dev_id.clone(); // On windows, we expect the volume id
132+
137133
#[cfg(unix)]
138134
let usage = FsUsage::new(statfs(&_stat_path).ok()?);
139135
#[cfg(windows)]

src/uu/rm/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ path = "src/rm.rs"
2020
[dependencies]
2121
thiserror = { workspace = true }
2222
clap = { workspace = true }
23-
uucore = { workspace = true, features = ["fs", "parser", "safe-traversal"] }
23+
uucore = { workspace = true, features = ["fs", "fsext", "parser", "safe-traversal"] }
2424
fluent = { workspace = true }
2525
indicatif = { workspace = true }
2626

src/uu/rm/src/rm.rs

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use std::path::{Path, PathBuf};
2121
use thiserror::Error;
2222
use uucore::display::Quotable;
2323
use uucore::error::{FromIo, UError, UResult};
24+
use uucore::fsext::{MountInfo, read_fs_list};
2425
use uucore::parser::shortcut_value_parser::ShortcutValueParser;
2526
use uucore::translate;
2627
use uucore::{format_usage, os_str_as_bytes, prompt_yes, show_error};
@@ -126,6 +127,13 @@ impl From<&str> for InteractiveMode {
126127
}
127128
}
128129

130+
#[derive(PartialEq)]
131+
pub enum PreserveRoot {
132+
Default,
133+
YesAll,
134+
No,
135+
}
136+
129137
/// Options for the `rm` command
130138
///
131139
/// All options are public so that the options can be programmatically
@@ -152,7 +160,7 @@ pub struct Options {
152160
/// `--one-file-system`
153161
pub one_fs: bool,
154162
/// `--preserve-root`/`--no-preserve-root`
155-
pub preserve_root: bool,
163+
pub preserve_root: PreserveRoot,
156164
/// `-r`, `--recursive`
157165
pub recursive: bool,
158166
/// `-d`, `--dir`
@@ -173,7 +181,7 @@ impl Default for Options {
173181
force: false,
174182
interactive: InteractiveMode::PromptProtected,
175183
one_fs: false,
176-
preserve_root: true,
184+
preserve_root: PreserveRoot::Default,
177185
recursive: false,
178186
dir: false,
179187
verbose: false,
@@ -242,7 +250,18 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
242250
}
243251
},
244252
one_fs: matches.get_flag(OPT_ONE_FILE_SYSTEM),
245-
preserve_root: !matches.get_flag(OPT_NO_PRESERVE_ROOT),
253+
preserve_root: if matches.get_flag(OPT_NO_PRESERVE_ROOT) {
254+
PreserveRoot::No
255+
} else {
256+
match matches
257+
.get_one::<String>(OPT_PRESERVE_ROOT)
258+
.unwrap()
259+
.as_str()
260+
{
261+
"all" => PreserveRoot::YesAll,
262+
_ => PreserveRoot::Default,
263+
}
264+
},
246265
recursive: matches.get_flag(OPT_RECURSIVE),
247266
dir: matches.get_flag(OPT_DIR),
248267
verbose: matches.get_flag(OPT_VERBOSE),
@@ -341,7 +360,10 @@ pub fn uu_app() -> Command {
341360
Arg::new(OPT_PRESERVE_ROOT)
342361
.long(OPT_PRESERVE_ROOT)
343362
.help(translate!("rm-help-preserve-root"))
344-
.action(ArgAction::SetTrue),
363+
.value_parser(["all"])
364+
.default_value("all")
365+
.default_missing_value("all")
366+
.hide_default_value(true),
345367
)
346368
.arg(
347369
Arg::new(OPT_RECURSIVE)
@@ -460,7 +482,6 @@ fn count_files_in_directory(p: &Path) -> u64 {
460482
1 + entries_count
461483
}
462484

463-
// TODO: implement one-file-system (this may get partially implemented in walkdir)
464485
/// Remove (or unlink) the given files
465486
///
466487
/// Returns true if it has encountered an error.
@@ -596,7 +617,19 @@ fn remove_dir_recursive(
596617
return remove_file(path, options, progress_bar);
597618
}
598619

599-
// Base case 2: this is a non-empty directory, but the user
620+
// Base case 2: check if a path is on the same file system
621+
if let Err(additional_reason) = check_one_fs(path, options) {
622+
if !additional_reason.is_empty() {
623+
show_error!("{}", additional_reason);
624+
}
625+
show_error!(
626+
"skipping {}, since it's on a different device",
627+
path.quote()
628+
);
629+
return true;
630+
}
631+
632+
// Base case 3: this is a non-empty directory, but the user
600633
// doesn't want to descend into it.
601634
if options.interactive == InteractiveMode::Always
602635
&& !is_dir_empty(path)
@@ -684,9 +717,82 @@ fn remove_dir_recursive(
684717
}
685718
}
686719

720+
/// Return a reference to the best matching `MountInfo` whose `mount_dir`
721+
/// is a prefix of the canonicalized `path`.
722+
fn mount_for_path<'a>(path: &Path, mounts: &'a [MountInfo]) -> Option<&'a MountInfo> {
723+
let canonical = path.canonicalize().ok()?;
724+
let mut best: Option<(&MountInfo, usize)> = None;
725+
726+
// Each `MountInfo` has a `mount_dir` that we compare.
727+
for mi in mounts {
728+
if mi.mount_dir.is_empty() {
729+
continue;
730+
}
731+
let mount_dir = PathBuf::from(&mi.mount_dir);
732+
if canonical.starts_with(&mount_dir) {
733+
let len = mount_dir.as_os_str().len();
734+
// Pick the mount with the longest matching prefix.
735+
if best.is_none() || len > best.as_ref().unwrap().1 {
736+
best = Some((mi, len));
737+
}
738+
}
739+
}
740+
741+
best.map(|(mi, _len)| mi)
742+
}
743+
744+
/// Check if a path is on the same file system when `--one-file-system` or `--preserve-root=all` options are enabled.
745+
/// Return `OK(())` if the path is on the same file system,
746+
/// or an additional error describing why it should be skipped.
747+
fn check_one_fs(path: &Path, options: &Options) -> Result<(), String> {
748+
// If neither `--one-file-system` nor `--preserve-root=all` is active,
749+
// always proceed
750+
if !options.one_fs && options.preserve_root != PreserveRoot::YesAll {
751+
return Ok(());
752+
}
753+
754+
// Read mount information
755+
let fs_list = read_fs_list().map_err(|err| format!("cannot read mount info: {err}"))?;
756+
757+
// Canonicalize the path
758+
let child_canon = path
759+
.canonicalize()
760+
.map_err(|err| format!("cannot canonicalize {}: {err}", path.quote()))?;
761+
762+
// Get parent path, handling root case
763+
let parent_canon = child_canon.parent().ok_or("")?.to_path_buf();
764+
765+
// Find mount points for child and parent
766+
let child_mount = mount_for_path(&child_canon, &fs_list).ok_or("")?;
767+
let parent_mount = mount_for_path(&parent_canon, &fs_list).ok_or("")?;
768+
769+
// Check if child and parent are on the same device
770+
if child_mount.dev_id != parent_mount.dev_id {
771+
return Err(String::new());
772+
}
773+
774+
Ok(())
775+
}
776+
687777
fn handle_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar>) -> bool {
688778
let mut had_err = false;
689779

780+
if let Err(additional_reason) = check_one_fs(path, options) {
781+
if !additional_reason.is_empty() {
782+
show_error!("{}", additional_reason);
783+
}
784+
show_error!(
785+
"skipping {}, since it's on a different device",
786+
path.quote()
787+
);
788+
789+
if options.preserve_root == PreserveRoot::YesAll {
790+
show_error!("and --preserve-root=all is in effect");
791+
}
792+
793+
return true;
794+
}
795+
690796
let path = clean_trailing_slashes(path);
691797
if path_is_current_or_parent_directory(path) {
692798
show_error!(
@@ -697,9 +803,9 @@ fn handle_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar>
697803
}
698804

699805
let is_root = path.has_root() && path.parent().is_none();
700-
if options.recursive && (!is_root || !options.preserve_root) {
806+
if options.recursive && (!is_root || options.preserve_root == PreserveRoot::No) {
701807
had_err = remove_dir_recursive(path, options, progress_bar);
702-
} else if options.dir && (!is_root || !options.preserve_root) {
808+
} else if options.dir && (!is_root || options.preserve_root == PreserveRoot::No) {
703809
had_err = remove_dir(path, options, progress_bar).bitor(had_err);
704810
} else if options.recursive {
705811
show_error!("{}", RmError::DangerousRecursiveOperation);

src/uucore/src/lib/features/fsext.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,12 +324,18 @@ impl MountInfo {
324324
let mount_root = to_nul_terminated_wide_string(&mount_root);
325325
GetDriveTypeW(mount_root.as_ptr())
326326
};
327+
328+
let mount_dir = Path::new(&mount_root)
329+
.canonicalize()
330+
.unwrap_or_default()
331+
.into_os_string();
332+
327333
Some(Self {
328334
dev_id: volume_name,
329335
dev_name,
330336
fs_type: fs_type.unwrap_or_default(),
331337
mount_root: mount_root.into(), // TODO: We should figure out how to keep an OsString here.
332-
mount_dir: OsString::new(),
338+
mount_dir,
333339
mount_option: String::new(),
334340
remote,
335341
dummy: false,

tests/by-util/test_rm.rs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,144 @@ fn test_rm_directory_not_writable() {
11501150
assert!(!at.dir_exists("b/d")); // Should be removed
11511151
}
11521152

1153+
#[test]
1154+
#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))]
1155+
fn test_rm_one_file_system() {
1156+
let scene = TestScenario::new(util_name!());
1157+
let at = &scene.fixtures;
1158+
1159+
// Test must be run as root (or with `sudo -E`)
1160+
if scene.cmd("whoami").run().stdout_str() != "root\n" {
1161+
println!("Skipping test_rm_one_file_system: must be run as root");
1162+
return;
1163+
}
1164+
1165+
// Define paths for temporary files and directories
1166+
let img_path = "fs.img";
1167+
let mount_point = "fs";
1168+
let remove_dir = "a";
1169+
let bind_mount_point = "a/b";
1170+
1171+
at.touch(img_path);
1172+
1173+
// Create filesystem image
1174+
scene
1175+
.cmd("dd")
1176+
.args(&[
1177+
"if=/dev/zero",
1178+
&format!("of={}", img_path),
1179+
"bs=1M",
1180+
"count=50",
1181+
])
1182+
.succeeds();
1183+
1184+
// Create ext4 filesystem
1185+
scene.cmd("/sbin/mkfs.ext4").arg(img_path).succeeds();
1186+
1187+
// Prepare directory structure
1188+
at.mkdir_all(mount_point);
1189+
at.mkdir_all(bind_mount_point);
1190+
1191+
// Mount as loop device
1192+
scene
1193+
.cmd("mount")
1194+
.args(&["-o", "loop", img_path, mount_point])
1195+
.succeeds();
1196+
1197+
// Create test directory
1198+
at.mkdir_all(&format!("{}/x", mount_point));
1199+
1200+
// Create bind mount
1201+
scene
1202+
.cmd("mount")
1203+
.args(&["--bind", mount_point, bind_mount_point])
1204+
.succeeds();
1205+
1206+
// Run the test
1207+
scene
1208+
.ucmd()
1209+
.args(&["--one-file-system", "-rf", remove_dir])
1210+
.fails()
1211+
.stderr_contains(format!("rm: skipping '{}'", bind_mount_point));
1212+
1213+
// Cleanup
1214+
let _ = scene.cmd("umount").arg(bind_mount_point).run();
1215+
let _ = scene.cmd("umount").arg(mount_point).run();
1216+
let _ = scene
1217+
.cmd("rm")
1218+
.args(&["-rf", mount_point, bind_mount_point])
1219+
.run();
1220+
}
1221+
1222+
#[test]
1223+
#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))]
1224+
fn test_rm_preserve_root() {
1225+
let scene = TestScenario::new(util_name!());
1226+
let at = &scene.fixtures;
1227+
1228+
// Test must be run as root (or with `sudo -E`)
1229+
if scene.cmd("whoami").run().stdout_str() != "root\n" {
1230+
println!("Skipping test_rm_one_file_system: must be run as root");
1231+
return;
1232+
}
1233+
1234+
// Define paths for temporary files and directories
1235+
let img_path = "fs.img";
1236+
let mount_point = "fs";
1237+
let bind_mount_point = "a/b";
1238+
1239+
at.touch(img_path);
1240+
1241+
// Create filesystem image
1242+
scene
1243+
.cmd("dd")
1244+
.args(&[
1245+
"if=/dev/zero",
1246+
&format!("of={}", img_path),
1247+
"bs=1M",
1248+
"count=50",
1249+
])
1250+
.succeeds();
1251+
1252+
// Create ext4 filesystem
1253+
scene.cmd("/sbin/mkfs.ext4").arg(img_path).succeeds();
1254+
1255+
// Prepare directory structure
1256+
at.mkdir_all(mount_point);
1257+
at.mkdir_all(bind_mount_point);
1258+
1259+
// Mount as loop device
1260+
scene
1261+
.cmd("mount")
1262+
.args(&["-o", "loop", img_path, mount_point])
1263+
.succeeds();
1264+
1265+
// Create test directory
1266+
at.mkdir_all(&format!("{}/x", mount_point));
1267+
1268+
// Create bind mount
1269+
scene
1270+
.cmd("mount")
1271+
.args(&["--bind", mount_point, bind_mount_point])
1272+
.succeeds();
1273+
1274+
// Run the test
1275+
scene
1276+
.ucmd()
1277+
.args(&["--preserve-root=all", "-rf", bind_mount_point])
1278+
.fails()
1279+
.stderr_contains(format!("rm: skipping '{}'", bind_mount_point))
1280+
.stderr_contains("rm: and --preserve-root=all is in effect");
1281+
1282+
// Cleanup
1283+
let _ = scene.cmd("umount").arg(bind_mount_point).run();
1284+
let _ = scene.cmd("umount").arg(mount_point).run();
1285+
let _ = scene
1286+
.cmd("rm")
1287+
.args(&["-rf", mount_point, bind_mount_point])
1288+
.run();
1289+
}
1290+
11531291
#[test]
11541292
fn test_progress_flag_short() {
11551293
let (at, mut ucmd) = at_and_ucmd!();

0 commit comments

Comments
 (0)