Skip to content

Commit 82dd553

Browse files
committed
rm --preserve-root should work on symlink too
Closes: uutils#9705
1 parent 67046f5 commit 82dd553

File tree

4 files changed

+194
-5
lines changed

4 files changed

+194
-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: 121 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(all(unix, not(target_os = "redox")))]
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.quote()))]
@@ -155,6 +186,10 @@ pub struct Options {
155186
pub one_fs: bool,
156187
/// `--preserve-root`/`--no-preserve-root`
157188
pub preserve_root: bool,
189+
/// Cached device/inode for "/" when preserve_root is enabled.
190+
/// Used to detect symlinks or paths that resolve to root.
191+
#[cfg(unix)]
192+
pub root_dev_ino: Option<RootDevIno>,
158193
/// `-r`, `--recursive`
159194
pub recursive: bool,
160195
/// `-d`, `--dir`
@@ -176,6 +211,8 @@ impl Default for Options {
176211
interactive: InteractiveMode::PromptProtected,
177212
one_fs: false,
178213
preserve_root: true,
214+
#[cfg(unix)]
215+
root_dev_ino: None,
179216
recursive: false,
180217
dir: false,
181218
verbose: false,
@@ -229,6 +266,19 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
229266
})
230267
};
231268

269+
let preserve_root = !matches.get_flag(OPT_NO_PRESERVE_ROOT);
270+
let recursive = matches.get_flag(OPT_RECURSIVE);
271+
272+
// Cache the device/inode of "/" at startup when preserve_root is enabled
273+
// and we're doing recursive operations. This allows us to detect symlinks
274+
// or paths that resolve to root by comparing device/inode numbers.
275+
#[cfg(unix)]
276+
let root_dev_ino = if preserve_root && recursive {
277+
RootDevIno::new()
278+
} else {
279+
None
280+
};
281+
232282
let options = Options {
233283
force: force_flag,
234284
interactive: {
@@ -245,8 +295,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
245295
}
246296
},
247297
one_fs: matches.get_flag(OPT_ONE_FILE_SYSTEM),
248-
preserve_root: !matches.get_flag(OPT_NO_PRESERVE_ROOT),
249-
recursive: matches.get_flag(OPT_RECURSIVE),
298+
preserve_root,
299+
#[cfg(unix)]
300+
root_dev_ino,
301+
recursive,
250302
dir: matches.get_flag(OPT_DIR),
251303
verbose: matches.get_flag(OPT_VERBOSE),
252304
progress: matches.get_flag(OPT_PROGRESS),
@@ -673,6 +725,71 @@ fn remove_dir_recursive(
673725
}
674726
}
675727

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

@@ -685,14 +802,13 @@ fn handle_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar>
685802
return true;
686803
}
687804

688-
let is_root = path.has_root() && path.parent().is_none();
805+
let is_root = is_root_path(path, options);
689806
if options.recursive && (!is_root || !options.preserve_root) {
690807
had_err = remove_dir_recursive(path, options, progress_bar);
691808
} else if options.dir && (!is_root || !options.preserve_root) {
692809
had_err = remove_dir(path, options, progress_bar).bitor(had_err);
693810
} else if options.recursive {
694-
show_error!("{}", RmError::DangerousRecursiveOperation);
695-
show_error!("{}", RmError::UseNoPreserveRoot);
811+
show_preserve_root_error(path, options);
696812
had_err = true;
697813
} else {
698814
show_error!(

tests/by-util/test_rm.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//
33
// For the full copyright and license information, please view the LICENSE
44
// file that was distributed with this source code.
5+
// spell-checker:ignore rootlink
56
#![allow(clippy::stable_sort_primitive)]
67

78
use std::process::Stdio;
@@ -1290,3 +1291,73 @@ fn test_symlink_to_readonly_no_prompt() {
12901291

12911292
assert!(!at.symlink_exists("bar"));
12921293
}
1294+
1295+
/// Test that --preserve-root properly detects symlinks pointing to root.
1296+
#[cfg(unix)]
1297+
#[test]
1298+
fn test_preserve_root_symlink_to_root() {
1299+
let (at, mut ucmd) = at_and_ucmd!();
1300+
1301+
// Create a symlink pointing to the root directory
1302+
at.symlink_dir("/", "rootlink");
1303+
1304+
// Attempting to recursively delete through this symlink should fail
1305+
// because it resolves to the same device/inode as "/"
1306+
ucmd.arg("-rf")
1307+
.arg("--preserve-root")
1308+
.arg("rootlink/")
1309+
.fails()
1310+
.stderr_contains("it is dangerous to operate recursively on")
1311+
.stderr_contains("(same as '/')");
1312+
1313+
// The symlink itself should still exist (we didn't delete it)
1314+
assert!(at.symlink_exists("rootlink"));
1315+
}
1316+
1317+
/// Test that --preserve-root properly detects nested symlinks pointing to root.
1318+
#[cfg(unix)]
1319+
#[test]
1320+
fn test_preserve_root_nested_symlink_to_root() {
1321+
let (at, mut ucmd) = at_and_ucmd!();
1322+
1323+
// Create a symlink pointing to the root directory
1324+
at.symlink_dir("/", "rootlink");
1325+
// Create another symlink pointing to the first symlink
1326+
at.symlink_dir("rootlink", "rootlink2");
1327+
1328+
// Attempting to recursively delete through nested symlinks should also fail
1329+
ucmd.arg("-rf")
1330+
.arg("--preserve-root")
1331+
.arg("rootlink2/")
1332+
.fails()
1333+
.stderr_contains("it is dangerous to operate recursively on")
1334+
.stderr_contains("(same as '/')");
1335+
}
1336+
1337+
/// Test that removing the symlink itself (not the target) still works.
1338+
#[cfg(unix)]
1339+
#[test]
1340+
fn test_preserve_root_symlink_removal_without_trailing_slash() {
1341+
let (at, mut ucmd) = at_and_ucmd!();
1342+
1343+
// Create a symlink pointing to the root directory
1344+
at.symlink_dir("/", "rootlink");
1345+
1346+
// Removing the symlink itself (without trailing slash) should succeed
1347+
// because we're removing the link, not traversing through it
1348+
ucmd.arg("--preserve-root").arg("rootlink").succeeds();
1349+
1350+
assert!(!at.symlink_exists("rootlink"));
1351+
}
1352+
1353+
/// Test that literal "/" is still properly protected.
1354+
#[test]
1355+
fn test_preserve_root_literal_root() {
1356+
new_ucmd!()
1357+
.arg("-rf")
1358+
.arg("--preserve-root")
1359+
.arg("/")
1360+
.fails()
1361+
.stderr_contains("it is dangerous to operate recursively on '/'")
1362+
.stderr_contains("use --no-preserve-root to override this failsafe");
1363+
}

0 commit comments

Comments
 (0)