Skip to content

Commit 5e5968c

Browse files
authored
rm: fix --preserve-root detection for nested symlinks with trailing slash (#9706)
1 parent 2f0ed95 commit 5e5968c

4 files changed

Lines changed: 131 additions & 17 deletions

File tree

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: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// For the full copyright and license information, please view the LICENSE
44
// file that was distributed with this source code.
55

6-
// spell-checker:ignore (path) eacces inacc rm-r4 unlinkat fstatat
6+
// spell-checker:ignore (path) eacces inacc rm-r4 unlinkat fstatat rootlink
77

88
use clap::builder::{PossibleValue, ValueParser};
99
use clap::{Arg, ArgAction, Command, parser::ValueSource};
@@ -17,7 +17,7 @@ use std::os::unix::ffi::OsStrExt;
1717
#[cfg(unix)]
1818
use std::os::unix::fs::PermissionsExt;
1919
use std::path::MAIN_SEPARATOR;
20-
use std::path::{Path, PathBuf};
20+
use std::path::Path;
2121
use thiserror::Error;
2222
use uucore::display::Quotable;
2323
use uucore::error::{FromIo, UError, UResult};
@@ -56,7 +56,7 @@ fn verbose_removed_file(path: &Path, options: &Options) {
5656
if options.verbose {
5757
println!(
5858
"{}",
59-
translate!("rm-verbose-removed", "file" => normalize(path).quote())
59+
translate!("rm-verbose-removed", "file" => uucore::fs::normalize_path(path).quote())
6060
);
6161
}
6262
}
@@ -66,7 +66,7 @@ fn verbose_removed_directory(path: &Path, options: &Options) {
6666
if options.verbose {
6767
println!(
6868
"{}",
69-
translate!("rm-verbose-removed-directory", "file" => normalize(path).quote())
69+
translate!("rm-verbose-removed-directory", "file" => uucore::fs::normalize_path(path).quote())
7070
);
7171
}
7272
}
@@ -229,6 +229,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
229229
})
230230
};
231231

232+
let preserve_root = !matches.get_flag(OPT_NO_PRESERVE_ROOT);
233+
let recursive = matches.get_flag(OPT_RECURSIVE);
234+
232235
let options = Options {
233236
force: force_flag,
234237
interactive: {
@@ -245,8 +248,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
245248
}
246249
},
247250
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),
251+
preserve_root,
252+
recursive,
250253
dir: matches.get_flag(OPT_DIR),
251254
verbose: matches.get_flag(OPT_VERBOSE),
252255
progress: matches.get_flag(OPT_PROGRESS),
@@ -482,6 +485,19 @@ pub fn remove(files: &[&OsStr], options: &Options) -> bool {
482485
for filename in files {
483486
let file = Path::new(filename);
484487

488+
// Check if the path (potentially with trailing slash) resolves to root
489+
// This needs to happen before symlink_metadata to catch cases like "rootlink/"
490+
// where rootlink is a symlink to root.
491+
if uucore::fs::path_ends_with_terminator(file)
492+
&& options.recursive
493+
&& options.preserve_root
494+
&& is_root_path(file)
495+
{
496+
show_preserve_root_error(file);
497+
had_err = true;
498+
continue;
499+
}
500+
485501
had_err = match file.symlink_metadata() {
486502
Ok(metadata) => {
487503
// Create progress bar on first successful file metadata read
@@ -668,6 +684,40 @@ fn remove_dir_recursive(
668684
}
669685
}
670686

687+
/// Check if a path resolves to the root directory.
688+
/// Returns true if the path is root, false otherwise.
689+
fn is_root_path(path: &Path) -> bool {
690+
// Check simple case: literal "/" path
691+
if path.has_root() && path.parent().is_none() {
692+
return true;
693+
}
694+
695+
// Check if path resolves to "/" after following symlinks
696+
if let Ok(canonical) = path.canonicalize() {
697+
canonical.has_root() && canonical.parent().is_none()
698+
} else {
699+
false
700+
}
701+
}
702+
703+
/// Show error message for attempting to remove root.
704+
fn show_preserve_root_error(path: &Path) {
705+
let path_looks_like_root = path.has_root() && path.parent().is_none();
706+
707+
if path_looks_like_root {
708+
// Path is literally "/"
709+
show_error!("{}", RmError::DangerousRecursiveOperation);
710+
} else {
711+
// Path resolves to root but isn't literally "/" (e.g., symlink to /)
712+
show_error!(
713+
"{}",
714+
translate!("rm-error-dangerous-recursive-operation-same-as-root",
715+
"path" => path.display())
716+
);
717+
}
718+
show_error!("{}", RmError::UseNoPreserveRoot);
719+
}
720+
671721
fn handle_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar>) -> bool {
672722
let mut had_err = false;
673723

@@ -680,14 +730,13 @@ fn handle_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar>
680730
return true;
681731
}
682732

683-
let is_root = path.has_root() && path.parent().is_none();
733+
let is_root = is_root_path(path);
684734
if options.recursive && (!is_root || !options.preserve_root) {
685735
had_err = remove_dir_recursive(path, options, progress_bar);
686736
} else if options.dir && (!is_root || !options.preserve_root) {
687737
had_err = remove_dir(path, options, progress_bar).bitor(had_err);
688738
} else if options.recursive {
689-
show_error!("{}", RmError::DangerousRecursiveOperation);
690-
show_error!("{}", RmError::UseNoPreserveRoot);
739+
show_preserve_root_error(path);
691740
had_err = true;
692741
} else {
693742
show_error!(
@@ -935,14 +984,6 @@ fn prompt_descend(path: &Path) -> bool {
935984
prompt_yes!("descend into directory {}?", path.quote())
936985
}
937986

938-
fn normalize(path: &Path) -> PathBuf {
939-
// copied from https://github.com/rust-lang/cargo/blob/2e4cfc2b7d43328b207879228a2ca7d427d188bb/src/cargo/util/paths.rs#L65-L90
940-
// both projects are MIT https://github.com/rust-lang/cargo/blob/master/LICENSE-MIT
941-
// for std impl progress see rfc https://github.com/rust-lang/rfcs/issues/2208
942-
// TODO: replace this once that lands
943-
uucore::fs::normalize_path(path)
944-
}
945-
946987
#[cfg(not(windows))]
947988
fn is_symlink_dir(_metadata: &Metadata) -> bool {
948989
false

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)