Skip to content

Commit 78e1996

Browse files
committed
rm: fix --preserve-root detection for nested symlinks with trailing slash
1 parent 1c09c36 commit 78e1996

File tree

4 files changed

+212
-5
lines changed

4 files changed

+212
-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: 139 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),
@@ -487,6 +539,25 @@ pub fn remove(files: &[&OsStr], options: &Options) -> bool {
487539
for filename in files {
488540
let file = Path::new(filename);
489541

542+
// Check if the path (potentially with trailing slash) resolves to root
543+
// This needs to happen before symlink_metadata to catch cases like "rootlink/"
544+
// where rootlink is a symlink to root.
545+
#[cfg(unix)]
546+
{
547+
// When a path has a trailing slash and points to a symlink to a directory,
548+
// we should check if it resolves to root before processing.
549+
if uucore::fs::path_ends_with_terminator(file)
550+
&& options.recursive
551+
&& options.preserve_root
552+
{
553+
if is_root_path(file, options) {
554+
show_preserve_root_error(file, options);
555+
had_err = true;
556+
continue;
557+
}
558+
}
559+
}
560+
490561
had_err = match file.symlink_metadata() {
491562
Ok(metadata) => {
492563
// Create progress bar on first successful file metadata read
@@ -673,6 +744,70 @@ fn remove_dir_recursive(
673744
}
674745
}
675746

747+
/// Check if a path resolves to the root directory by comparing device/inode.
748+
/// Returns true if the path is root, false otherwise.
749+
/// On non-Unix systems, falls back to path-based check only.
750+
#[cfg(unix)]
751+
fn is_root_path(path: &Path, options: &Options) -> bool {
752+
// First check the simple path-based case (e.g., "/")
753+
let path_looks_like_root = path.has_root() && path.parent().is_none();
754+
755+
// If preserve_root is enabled and we have cached root dev/ino,
756+
// also check if the path resolves to root via symlink or mount
757+
if options.preserve_root {
758+
if let Some(ref root_dev_ino) = options.root_dev_ino {
759+
// Use fs::metadata to get the target's dev/ino after following ALL symlinks
760+
// This should handle nested symlinks automatically
761+
if let Ok(metadata) = fs::metadata(path) {
762+
if root_dev_ino.is_root(&metadata) {
763+
return true;
764+
}
765+
}
766+
767+
// Fallback: canonicalize the path and check if it resolves to "/"
768+
// This handles cases where metadata retrieval fails but the path
769+
// still resolves to root (e.g., on MacOS in some test scenarios)
770+
if let Ok(canonical_path) = path.canonicalize() {
771+
if canonical_path.has_root() && canonical_path.parent().is_none() {
772+
return true;
773+
}
774+
}
775+
}
776+
}
777+
778+
path_looks_like_root
779+
}
780+
781+
#[cfg(not(unix))]
782+
fn is_root_path(path: &Path, _options: &Options) -> bool {
783+
path.has_root() && path.parent().is_none()
784+
}
785+
786+
/// Show appropriate error message for attempting to remove root.
787+
/// Differentiates between literal "/" and paths that resolve to root (e.g., symlinks).
788+
#[cfg(unix)]
789+
fn show_preserve_root_error(path: &Path, _options: &Options) {
790+
let path_looks_like_root = path.has_root() && path.parent().is_none();
791+
792+
if path_looks_like_root {
793+
// Path is literally "/"
794+
show_error!("{}", RmError::DangerousRecursiveOperation);
795+
} else {
796+
// Path resolves to root but isn't literally "/" (e.g., symlink to /)
797+
show_error!(
798+
"{}",
799+
RmError::DangerousRecursiveOperationSameAsRoot(path.as_os_str().to_os_string())
800+
);
801+
}
802+
show_error!("{}", RmError::UseNoPreserveRoot);
803+
}
804+
805+
#[cfg(not(unix))]
806+
fn show_preserve_root_error(_path: &Path, _options: &Options) {
807+
show_error!("{}", RmError::DangerousRecursiveOperation);
808+
show_error!("{}", RmError::UseNoPreserveRoot);
809+
}
810+
676811
fn handle_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar>) -> bool {
677812
let mut had_err = false;
678813

@@ -685,14 +820,13 @@ fn handle_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar>
685820
return true;
686821
}
687822

688-
let is_root = path.has_root() && path.parent().is_none();
823+
let is_root = is_root_path(path, options);
689824
if options.recursive && (!is_root || !options.preserve_root) {
690825
had_err = remove_dir_recursive(path, options, progress_bar);
691826
} else if options.dir && (!is_root || !options.preserve_root) {
692827
had_err = remove_dir(path, options, progress_bar).bitor(had_err);
693828
} else if options.recursive {
694-
show_error!("{}", RmError::DangerousRecursiveOperation);
695-
show_error!("{}", RmError::UseNoPreserveRoot);
829+
show_preserve_root_error(path, options);
696830
had_err = true;
697831
} else {
698832
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)