Skip to content

Commit a94c698

Browse files
committed
rm --preserve-root should work on symlink too
Closes: #9705
1 parent 45f81bb commit a94c698

File tree

4 files changed

+184
-5
lines changed

4 files changed

+184
-5
lines changed

src/uu/rm/locales/en-US.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ rm-error-cannot-remove-no-such-file = cannot remove {$file}: No such file or dir
4040
rm-error-cannot-remove-permission-denied = cannot remove {$file}: Permission denied
4141
rm-error-cannot-remove-is-directory = cannot remove {$file}: Is a directory
4242
rm-error-dangerous-recursive-operation = it is dangerous to operate recursively on '/'
43+
rm-error-dangerous-recursive-operation-same-as-root = it is dangerous to operate recursively on '{$path}' (same as '/')
4344
rm-error-use-no-preserve-root = use --no-preserve-root to override this failsafe
4445
rm-error-refusing-to-remove-directory = refusing to remove '.' or '..' directory: skipping '{$path}'
4546
rm-error-cannot-remove = cannot remove {$file}

src/uu/rm/locales/fr-FR.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ rm-error-cannot-remove-no-such-file = impossible de supprimer {$file} : Aucun fi
4040
rm-error-cannot-remove-permission-denied = impossible de supprimer {$file} : Permission refusée
4141
rm-error-cannot-remove-is-directory = impossible de supprimer {$file} : C'est un répertoire
4242
rm-error-dangerous-recursive-operation = il est dangereux d'opérer récursivement sur '/'
43+
rm-error-dangerous-recursive-operation-same-as-root = il est dangereux d'opérer récursivement sur '{$path}' (identique à '/')
4344
rm-error-use-no-preserve-root = utilisez --no-preserve-root pour outrepasser cette protection
4445
rm-error-refusing-to-remove-directory = refus de supprimer le répertoire '.' ou '..' : ignorer '{$path}'
4546
rm-error-cannot-remove = impossible de supprimer {$file}

src/uu/rm/src/rm.rs

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ use std::ops::BitOr;
1515
#[cfg(unix)]
1616
use std::os::unix::ffi::OsStrExt;
1717
#[cfg(unix)]
18+
use std::os::unix::fs::MetadataExt;
19+
#[cfg(unix)]
1820
use std::os::unix::fs::PermissionsExt;
1921
use std::path::MAIN_SEPARATOR;
2022
use std::path::{Path, PathBuf};
@@ -29,6 +31,32 @@ mod platform;
2931
#[cfg(target_os = "linux")]
3032
use platform::{safe_remove_dir_recursive, safe_remove_empty_dir, safe_remove_file};
3133

34+
/// Cached device and inode numbers for the root directory.
35+
/// Used for --preserve-root to detect when a path resolves to "/".
36+
#[cfg(unix)]
37+
#[derive(Debug, Clone, Copy)]
38+
pub struct RootDevIno {
39+
pub dev: u64,
40+
pub ino: u64,
41+
}
42+
43+
#[cfg(unix)]
44+
impl RootDevIno {
45+
/// Get the device and inode numbers for "/".
46+
/// Returns None if lstat("/") fails.
47+
pub fn new() -> Option<Self> {
48+
fs::symlink_metadata("/").ok().map(|meta| Self {
49+
dev: meta.dev(),
50+
ino: meta.ino(),
51+
})
52+
}
53+
54+
/// Check if the given metadata matches the root device/inode.
55+
pub fn is_root(&self, meta: &Metadata) -> bool {
56+
meta.dev() == self.dev && meta.ino() == self.ino
57+
}
58+
}
59+
3260
#[derive(Debug, Error)]
3361
enum RmError {
3462
#[error("{}", translate!("rm-error-missing-operand", "util_name" => uucore::execution_phrase()))]
@@ -41,6 +69,9 @@ enum RmError {
4169
CannotRemoveIsDirectory(OsString),
4270
#[error("{}", translate!("rm-error-dangerous-recursive-operation"))]
4371
DangerousRecursiveOperation,
72+
#[cfg(unix)]
73+
#[error("{}", translate!("rm-error-dangerous-recursive-operation-same-as-root", "path" => _0.to_string_lossy()))]
74+
DangerousRecursiveOperationSameAsRoot(OsString),
4475
#[error("{}", translate!("rm-error-use-no-preserve-root"))]
4576
UseNoPreserveRoot,
4677
#[error("{}", translate!("rm-error-refusing-to-remove-directory", "path" => _0.to_string_lossy()))]
@@ -153,6 +184,10 @@ pub struct Options {
153184
pub one_fs: bool,
154185
/// `--preserve-root`/`--no-preserve-root`
155186
pub preserve_root: bool,
187+
/// Cached device/inode for "/" when preserve_root is enabled.
188+
/// Used to detect symlinks or paths that resolve to root.
189+
#[cfg(unix)]
190+
pub root_dev_ino: Option<RootDevIno>,
156191
/// `-r`, `--recursive`
157192
pub recursive: bool,
158193
/// `-d`, `--dir`
@@ -174,6 +209,8 @@ impl Default for Options {
174209
interactive: InteractiveMode::PromptProtected,
175210
one_fs: false,
176211
preserve_root: true,
212+
#[cfg(unix)]
213+
root_dev_ino: None,
177214
recursive: false,
178215
dir: false,
179216
verbose: false,
@@ -226,6 +263,19 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
226263
})
227264
};
228265

266+
let preserve_root = !matches.get_flag(OPT_NO_PRESERVE_ROOT);
267+
let recursive = matches.get_flag(OPT_RECURSIVE);
268+
269+
// Cache the device/inode of "/" at startup when preserve_root is enabled
270+
// and we're doing recursive operations. This allows us to detect symlinks
271+
// or paths that resolve to root by comparing device/inode numbers.
272+
#[cfg(unix)]
273+
let root_dev_ino = if preserve_root && recursive {
274+
RootDevIno::new()
275+
} else {
276+
None
277+
};
278+
229279
let options = Options {
230280
force: force_flag,
231281
interactive: {
@@ -242,8 +292,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
242292
}
243293
},
244294
one_fs: matches.get_flag(OPT_ONE_FILE_SYSTEM),
245-
preserve_root: !matches.get_flag(OPT_NO_PRESERVE_ROOT),
246-
recursive: matches.get_flag(OPT_RECURSIVE),
295+
preserve_root,
296+
#[cfg(unix)]
297+
root_dev_ino,
298+
recursive,
247299
dir: matches.get_flag(OPT_DIR),
248300
verbose: matches.get_flag(OPT_VERBOSE),
249301
progress: matches.get_flag(OPT_PROGRESS),
@@ -684,6 +736,62 @@ fn remove_dir_recursive(
684736
}
685737
}
686738

739+
/// Check if a path resolves to the root directory by comparing device/inode.
740+
/// Returns true if the path is root, false otherwise.
741+
/// On non-Unix systems, falls back to path-based check only.
742+
#[cfg(unix)]
743+
fn is_root_path(path: &Path, options: &Options) -> bool {
744+
// First check the simple path-based case (e.g., "/")
745+
let path_looks_like_root = path.has_root() && path.parent().is_none();
746+
747+
// If preserve_root is enabled and we have cached root dev/ino,
748+
// also check if the path resolves to root via symlink or mount
749+
if options.preserve_root {
750+
if let Some(ref root_dev_ino) = options.root_dev_ino {
751+
// Use symlink_metadata to get the actual target's dev/ino
752+
// after following symlinks (we need to follow the symlink to see
753+
// where it points)
754+
if let Ok(metadata) = fs::metadata(path) {
755+
if root_dev_ino.is_root(&metadata) {
756+
return true;
757+
}
758+
}
759+
}
760+
}
761+
762+
path_looks_like_root
763+
}
764+
765+
#[cfg(not(unix))]
766+
fn is_root_path(path: &Path, _options: &Options) -> bool {
767+
path.has_root() && path.parent().is_none()
768+
}
769+
770+
/// Show appropriate error message for attempting to remove root.
771+
/// Differentiates between literal "/" and paths that resolve to root (e.g., symlinks).
772+
#[cfg(unix)]
773+
fn show_preserve_root_error(path: &Path, _options: &Options) {
774+
let path_looks_like_root = path.has_root() && path.parent().is_none();
775+
776+
if path_looks_like_root {
777+
// Path is literally "/"
778+
show_error!("{}", RmError::DangerousRecursiveOperation);
779+
} else {
780+
// Path resolves to root but isn't literally "/" (e.g., symlink to /)
781+
show_error!(
782+
"{}",
783+
RmError::DangerousRecursiveOperationSameAsRoot(path.as_os_str().to_os_string())
784+
);
785+
}
786+
show_error!("{}", RmError::UseNoPreserveRoot);
787+
}
788+
789+
#[cfg(not(unix))]
790+
fn show_preserve_root_error(_path: &Path, _options: &Options) {
791+
show_error!("{}", RmError::DangerousRecursiveOperation);
792+
show_error!("{}", RmError::UseNoPreserveRoot);
793+
}
794+
687795
fn handle_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar>) -> bool {
688796
let mut had_err = false;
689797

@@ -696,14 +804,13 @@ fn handle_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar>
696804
return true;
697805
}
698806

699-
let is_root = path.has_root() && path.parent().is_none();
807+
let is_root = is_root_path(path, options);
700808
if options.recursive && (!is_root || !options.preserve_root) {
701809
had_err = remove_dir_recursive(path, options, progress_bar);
702810
} else if options.dir && (!is_root || !options.preserve_root) {
703811
had_err = remove_dir(path, options, progress_bar).bitor(had_err);
704812
} else if options.recursive {
705-
show_error!("{}", RmError::DangerousRecursiveOperation);
706-
show_error!("{}", RmError::UseNoPreserveRoot);
813+
show_preserve_root_error(path, options);
707814
had_err = true;
708815
} else {
709816
show_error!(

tests/by-util/test_rm.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,3 +1217,73 @@ fn test_progress_no_output_on_error() {
12171217
.stderr_contains("cannot remove")
12181218
.stderr_contains("No such file or directory");
12191219
}
1220+
1221+
/// Test that --preserve-root properly detects symlinks pointing to root.
1222+
#[cfg(unix)]
1223+
#[test]
1224+
fn test_preserve_root_symlink_to_root() {
1225+
let (at, mut ucmd) = at_and_ucmd!();
1226+
1227+
// Create a symlink pointing to the root directory
1228+
at.symlink_dir("/", "rootlink");
1229+
1230+
// Attempting to recursively delete through this symlink should fail
1231+
// because it resolves to the same device/inode as "/"
1232+
ucmd.arg("-rf")
1233+
.arg("--preserve-root")
1234+
.arg("rootlink/")
1235+
.fails()
1236+
.stderr_contains("it is dangerous to operate recursively on")
1237+
.stderr_contains("(same as '/')");
1238+
1239+
// The symlink itself should still exist (we didn't delete it)
1240+
assert!(at.symlink_exists("rootlink"));
1241+
}
1242+
1243+
/// Test that --preserve-root properly detects nested symlinks pointing to root.
1244+
#[cfg(unix)]
1245+
#[test]
1246+
fn test_preserve_root_nested_symlink_to_root() {
1247+
let (at, mut ucmd) = at_and_ucmd!();
1248+
1249+
// Create a symlink pointing to the root directory
1250+
at.symlink_dir("/", "rootlink");
1251+
// Create another symlink pointing to the first symlink
1252+
at.symlink_dir("rootlink", "rootlink2");
1253+
1254+
// Attempting to recursively delete through nested symlinks should also fail
1255+
ucmd.arg("-rf")
1256+
.arg("--preserve-root")
1257+
.arg("rootlink2/")
1258+
.fails()
1259+
.stderr_contains("it is dangerous to operate recursively on")
1260+
.stderr_contains("(same as '/')");
1261+
}
1262+
1263+
/// Test that removing the symlink itself (not the target) still works.
1264+
#[cfg(unix)]
1265+
#[test]
1266+
fn test_preserve_root_symlink_removal_without_trailing_slash() {
1267+
let (at, mut ucmd) = at_and_ucmd!();
1268+
1269+
// Create a symlink pointing to the root directory
1270+
at.symlink_dir("/", "rootlink");
1271+
1272+
// Removing the symlink itself (without trailing slash) should succeed
1273+
// because we're removing the link, not traversing through it
1274+
ucmd.arg("--preserve-root").arg("rootlink").succeeds();
1275+
1276+
assert!(!at.symlink_exists("rootlink"));
1277+
}
1278+
1279+
/// Test that literal "/" is still properly protected.
1280+
#[test]
1281+
fn test_preserve_root_literal_root() {
1282+
new_ucmd!()
1283+
.arg("-rf")
1284+
.arg("--preserve-root")
1285+
.arg("/")
1286+
.fails()
1287+
.stderr_contains("it is dangerous to operate recursively on '/'")
1288+
.stderr_contains("use --no-preserve-root to override this failsafe");
1289+
}

0 commit comments

Comments
 (0)