Skip to content

Commit 7d844a7

Browse files
committed
rm, chmod, chown, chgrp: compare (st_dev, st_ino) for --preserve-root
The root check compared path strings ("/" or a path whose canonicalize() is "/"). A bind mount of "/" is an ordinary directory, so canonicalize() returns the mountpoint and the check never matched: `mount --bind / /mnt; rm -r /mnt` recursed into /mnt where GNU stops and errors. Compare (st_dev, st_ino) against stat("/") instead, as GNU does, via a new uucore::fs::path_is_root_dir that stats "/" once per process. This also aligns the chmod/chown/chgrp guards: perms::is_root's syntactic "looks like a directory?" pre-filter skipped a bind mount, and chmod only re-checked symlinks during descent, not bind mounts met inside the tree.
1 parent 00fcb54 commit 7d844a7

6 files changed

Lines changed: 192 additions & 62 deletions

File tree

src/uu/chmod/src/chmod.rs

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use uucore::display::Quotable;
1616
use uucore::error::{
1717
ExitCode, UError, UResult, USimpleError, UUsageError, set_exit_code, strip_errno,
1818
};
19-
use uucore::fs::{FileInformation, display_permissions_unix};
19+
use uucore::fs::{FileInformation, display_permissions_unix, path_is_root_dir};
2020
use uucore::mode;
2121
use uucore::perms::{TraverseSymlinks, configure_symlink_and_recursion};
2222

@@ -516,7 +516,14 @@ impl Chmoder {
516516
}
517517
}
518518
if self.recursive && self.preserve_root && Self::is_root(file) {
519-
return Err(ChmodError::PreserveRoot("/".into()).into());
519+
// Name the operand the user gave: with a bind mount of "/" it is
520+
// not spelled "/", so say which path it is the same as.
521+
return Err(if file.as_os_str() == "/" {
522+
ChmodError::PreserveRoot("/".into())
523+
} else {
524+
ChmodError::PreserveRootSameAs(file.into())
525+
}
526+
.into());
520527
}
521528
if self.recursive {
522529
let mut ancestors = HashSet::new();
@@ -530,19 +537,17 @@ impl Chmoder {
530537
r
531538
}
532539

540+
/// Whether `file` is `/`, by `(st_dev, st_ino)` rather than by name, so a
541+
/// bind mount of `/` cannot slip past `--preserve-root` (as GNU does).
533542
fn is_root(file: impl AsRef<Path>) -> bool {
534-
matches!(fs::canonicalize(&file), Ok(p) if p == Path::new("/"))
543+
path_is_root_dir(file, true)
535544
}
536545

537-
/// `--preserve-root` guard for the recursive descent.
538-
///
539-
/// The operand loop in [`Self::chmod`] only checks the paths named on the
540-
/// command line. With `-L`, a symlink met *inside* the tree can resolve to
541-
/// `/`, so the failsafe has to be re-checked at every descent or the
542-
/// recursion walks straight into the real root. Only symlinks are
543-
/// canonicalized, so ordinary trees pay nothing for this.
546+
/// `--preserve-root` guard re-checked at every descent: a symlink to `/`
547+
/// (under `-L`) or a bind mount of `/` met inside the tree is still `/`, so
548+
/// the operand-only check is not enough. GNU re-checks every entry too.
544549
fn descends_into_root(&self, path: &Path) -> bool {
545-
self.preserve_root && path.is_symlink() && Self::is_root(path)
550+
self.preserve_root && Self::is_root(path)
546551
}
547552

548553
// Non-safe traversal implementation for platforms without safe_traversal support
@@ -553,7 +558,8 @@ impl Chmoder {
553558
is_command_line_arg: bool,
554559
ancestors: &mut HashSet<FileInformation>,
555560
) -> UResult<()> {
556-
// Skip (and diagnose) a symlink that resolves to '/' before touching it.
561+
// Skip (and diagnose) an entry that is '/' (a symlink to it, or a bind
562+
// mount) before touching it.
557563
if self.descends_into_root(file_path) {
558564
show!(ChmodError::PreserveRootSameAs(file_path.into()));
559565
return Ok(());
@@ -630,7 +636,8 @@ impl Chmoder {
630636
is_command_line_arg: bool,
631637
ancestors: &mut HashSet<FileInformation>,
632638
) -> UResult<()> {
633-
// Skip (and diagnose) a symlink that resolves to '/' before touching it.
639+
// Skip (and diagnose) an entry that is '/' (a symlink to it, or a bind
640+
// mount) before touching it.
634641
if self.descends_into_root(file_path) {
635642
show!(ChmodError::PreserveRootSameAs(file_path.into()));
636643
return Ok(());

src/uu/rm/src/rm.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -756,20 +756,27 @@ fn remove_dir_recursive(
756756
}
757757
}
758758

759-
/// Check if a path resolves to the root directory.
759+
/// Check if a path is the root directory.
760760
/// Returns true if the path is root, false otherwise.
761761
fn is_root_path(path: &Path) -> bool {
762-
// Check simple case: literal "/" path
762+
// Check simple case: literal "/" path. Costs no syscall.
763763
if path.has_root() && path.parent().is_none() {
764764
return true;
765765
}
766766

767-
// Check if path resolves to "/" after following symlinks
767+
// Otherwise settle by (st_dev, st_ino): a bind mount of "/" is a directory
768+
// whose path never resolves to "/", so a name check misses it (symlinks too).
769+
if uucore::fs::path_is_root_dir(path, true) {
770+
return true;
771+
}
772+
773+
// Platforms without (st_dev, st_ino) keep the name-based test.
774+
#[cfg(not(unix))]
768775
if let Ok(canonical) = path.canonicalize() {
769-
canonical.has_root() && canonical.parent().is_none()
770-
} else {
771-
false
776+
return canonical.has_root() && canonical.parent().is_none();
772777
}
778+
779+
false
773780
}
774781

775782
/// Show error message for attempting to remove root.

src/uucore/src/lib/features/fs.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ use std::os::windows::ffi::{OsStrExt, OsStringExt};
2727
#[cfg(windows)]
2828
use std::os::windows::io::AsRawHandle;
2929
use std::path::{Component, MAIN_SEPARATOR, Path, PathBuf};
30+
#[cfg(unix)]
31+
use std::sync::OnceLock;
3032
#[cfg(windows)]
3133
use windows_sys::Win32::Foundation::MAX_PATH;
3234
#[cfg(windows)]
@@ -637,6 +639,36 @@ pub fn infos_refer_to_same_file(
637639
info1.is_ok() && info1.ok() == info2.ok()
638640
}
639641

642+
/// The identity of `/`, stat'd once per process (like GNU's `get_root_dev_ino`).
643+
#[cfg(unix)]
644+
fn root_file_information() -> Option<&'static FileInformation> {
645+
static ROOT: OnceLock<Option<FileInformation>> = OnceLock::new();
646+
ROOT.get_or_init(|| FileInformation::from_path(Path::new("/"), true).ok())
647+
.as_ref()
648+
}
649+
650+
/// Whether `path` *is* `/`, by `(st_dev, st_ino)` rather than by name.
651+
///
652+
/// A bind mount of `/` (`mount --bind / /mnt`) is a real directory whose path
653+
/// never resolves to `/`, so a name-based `--preserve-root` check misses it;
654+
/// GNU compares dev/ino for the same reason. `dereference` says whether a
655+
/// symlink at `path` is about to be followed (only then does a link to `/`
656+
/// count). Returns `false` if `path` or `/` cannot be stat'd, or off unix.
657+
pub fn path_is_root_dir<P: AsRef<Path>>(path: P, dereference: bool) -> bool {
658+
#[cfg(unix)]
659+
{
660+
let Some(root) = root_file_information() else {
661+
return false;
662+
};
663+
FileInformation::from_path(path, dereference).is_ok_and(|info| &info == root)
664+
}
665+
#[cfg(not(unix))]
666+
{
667+
let _ = (path, dereference);
668+
false
669+
}
670+
}
671+
640672
/// Check if two files are identical by comparing their contents.
641673
///
642674
/// Returns `Ok(true)` if both files exist, are regular files, and have identical contents.
@@ -1450,4 +1482,31 @@ mod tests {
14501482
// Non-existent file
14511483
assert!(are_files_identical(file1.path(), "non_existent_file_path").is_err());
14521484
}
1485+
1486+
#[cfg(unix)]
1487+
#[test]
1488+
fn test_path_is_root_dir() {
1489+
assert!(path_is_root_dir("/", true));
1490+
assert!(path_is_root_dir("/", false));
1491+
// Reached by a different name, still the same directory.
1492+
assert!(path_is_root_dir("/..", true));
1493+
assert!(path_is_root_dir("/tmp/..", true));
1494+
1495+
let dir = tempdir().unwrap();
1496+
assert!(!path_is_root_dir(dir.path(), true));
1497+
assert!(!path_is_root_dir(dir.path().join("nonexistent"), true));
1498+
assert!(!path_is_root_dir("", true));
1499+
}
1500+
1501+
/// A symlink to `/` counts only when the caller would follow it.
1502+
#[cfg(unix)]
1503+
#[test]
1504+
fn test_path_is_root_dir_symlink() {
1505+
let dir = tempdir().unwrap();
1506+
let link = dir.path().join("root-link");
1507+
unix::fs::symlink("/", &link).unwrap();
1508+
1509+
assert!(path_is_root_dir(&link, true));
1510+
assert!(!path_is_root_dir(&link, false));
1511+
}
14531512
}

src/uucore/src/lib/features/perms.rs

Lines changed: 23 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use walkdir::WalkDir;
2727

2828
#[cfg(target_os = "linux")]
2929
use crate::features::fs::FileInformation;
30+
use crate::features::fs::path_is_root_dir;
3031
#[cfg(target_os = "linux")]
3132
use crate::features::safe_traversal::{DirFd, SymlinkBehavior};
3233

@@ -37,7 +38,7 @@ use std::io::Result as IOResult;
3738
use std::os::unix::fs::MetadataExt;
3839

3940
use std::os::unix::ffi::OsStrExt;
40-
use std::path::{MAIN_SEPARATOR, Path};
41+
use std::path::Path;
4142

4243
#[derive(Debug, Error)]
4344
enum PermsError {
@@ -224,56 +225,37 @@ pub fn check_root(path: &Path, would_recurse_symlink: bool) -> bool {
224225

225226
/// In the context of chown and chgrp, check whether we are in a "preserve-root" scenario.
226227
///
227-
/// In particular, we want to prohibit further traversal only if:
228+
/// Prohibit further traversal only if:
228229
/// (--preserve-root and -R present) &&
229-
/// (path canonicalizes to "/") &&
230+
/// (path *is* "/" by (st_dev, st_ino), so a bind mount of "/" counts too) &&
230231
/// (
231232
/// (path is a symlink && would traverse/recurse this symlink) ||
232233
/// (path is not a symlink)
233234
/// )
234-
/// The first clause is checked by the caller, the second and third clause is checked here.
235+
/// The first clause is checked by the caller, the second and third here.
235236
/// The caller has to evaluate -P/-H/-L into 'would_recurse_symlink'.
236-
/// Recall that canonicalization resolves both relative paths (e.g. "..") and symlinks.
237237
fn is_root(path: &Path, would_traverse_symlink: bool) -> bool {
238-
// The third clause can be evaluated without any syscalls, so we do that first.
239-
// If we would_recurse_symlink, then the clause is true no matter whether the path is a symlink
240-
// or not. Otherwise, we only need to check here if the path can syntactically be a symlink:
241-
if !would_traverse_symlink {
242-
// We cannot check path.is_dir() here, as this would resolve symlinks,
243-
// which we need to avoid here.
244-
// All directory-ish paths match "*/", except ".", "..", "*/.", and "*/..".
245-
let path_bytes = path.as_os_str().as_encoded_bytes();
246-
let looks_like_dir = path_bytes == *b"."
247-
|| path_bytes == *b".."
248-
|| path_bytes.ends_with(&[MAIN_SEPARATOR as u8])
249-
|| path_bytes.ends_with(&[MAIN_SEPARATOR as u8, b'.'])
250-
|| path_bytes.ends_with(&[MAIN_SEPARATOR as u8, b'.', b'.']);
251-
252-
if !looks_like_dir {
253-
return false;
254-
}
238+
// Compare by (st_dev, st_ino), not name: a bind mount of "/" is an ordinary
239+
// directory whose path never resolves to "/", so the old syntactic "looks
240+
// like a directory?" pre-filter waved it through. `would_traverse_symlink`
241+
// says whether a symlink to "/" here would be followed (only then is it root).
242+
//
243+
// FIXME: TOCTOU bug! This stat runs at a different time than the recursion
244+
// decision it guards; GNU avoids the window by reusing fts's `struct stat`.
245+
if !path_is_root_dir(path, would_traverse_symlink) {
246+
return false;
255247
}
256248

257-
// FIXME: TOCTOU bug! canonicalize() runs at a different time than WalkDir's recursion decision.
258-
// However, we're forced to make the decision whether to warn about --preserve-root
259-
// *before* even attempting to chown the path, let alone doing the stat inside WalkDir.
260-
if let Ok(p) = path.canonicalize() {
261-
let path_buf = path.to_path_buf();
262-
if p.parent().is_none() {
263-
if path_buf.as_os_str() == "/" {
264-
show_error!("it is dangerous to operate recursively on '/'");
265-
} else {
266-
show_error!(
267-
"it is dangerous to operate recursively on {} (same as '/')",
268-
path_buf.quote()
269-
);
270-
}
271-
show_error!("use --no-preserve-root to override this failsafe");
272-
return true;
273-
}
249+
if path.as_os_str() == "/" {
250+
show_error!("it is dangerous to operate recursively on '/'");
251+
} else {
252+
show_error!(
253+
"it is dangerous to operate recursively on {} (same as '/')",
254+
path.quote()
255+
);
274256
}
275-
276-
false
257+
show_error!("use --no-preserve-root to override this failsafe");
258+
true
277259
}
278260

279261
pub fn get_metadata(file: &Path, follow: bool) -> std::io::Result<Metadata> {

tests/by-util/test_chmod.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -565,13 +565,16 @@ fn test_chmod_preserve_root() {
565565

566566
#[test]
567567
fn test_chmod_preserve_root_with_paths_that_resolve_to_root() {
568+
// Only a bare "/" is reported as such; any other spelling of the root
569+
// directory is named as the user wrote it, followed by "(same as '/')".
570+
// "//" also checks we compare the raw operand, since Path("//") == Path("/").
568571
new_ucmd!()
569572
.arg("-R")
570573
.arg("--preserve-root")
571574
.arg("755")
572-
.arg("/../")
575+
.arg("//")
573576
.fails_with_code(1)
574-
.stderr_contains("chmod: it is dangerous to operate recursively on '/'");
577+
.stderr_contains("chmod: it is dangerous to operate recursively on '//' (same as '/')");
575578
}
576579

577580
#[test]

tests/by-util/test_rm.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1511,6 +1511,78 @@ fn test_preserve_root_symlink_removal_without_trailing_slash() {
15111511
assert!(!at.symlink_exists("rootlink"));
15121512
}
15131513

1514+
/// `--preserve-root` must refuse a bind mount of `/`.
1515+
///
1516+
/// A bind mount of `/` is an ordinary directory - not a symlink, not a cycle -
1517+
/// so `canonicalize()` yields the mountpoint and every name-based check waves it
1518+
/// through. Only `(st_dev, st_ino)` identifies it, and that is what the failsafe
1519+
/// compares.
1520+
///
1521+
/// The mount is of the real root, so nothing here may be able to delete anything
1522+
/// even with the guard gone: `-i` reads stdin at EOF and so answers "no" to the
1523+
/// first "descend into directory?" prompt, before any unlink. **Never make this
1524+
/// `-rf`.** The mount itself lives in a throwaway user + mount namespace.
1525+
#[cfg(target_os = "linux")]
1526+
#[test]
1527+
fn test_preserve_root_bind_mount_of_root() {
1528+
use std::process::Command;
1529+
1530+
let ts = TestScenario::new(util_name!());
1531+
1532+
// Unprivileged user namespaces are disabled in some kernels and sandboxes.
1533+
let can_unshare = Command::new("unshare")
1534+
.args(["--user", "--map-root-user", "--mount", "true"])
1535+
.status()
1536+
.is_ok_and(|status| status.success());
1537+
if !can_unshare {
1538+
println!("TEST SKIPPED: no unprivileged mount namespace available");
1539+
return;
1540+
}
1541+
1542+
// Mount point lives in this test's own temp dir, so parallel runs cannot
1543+
// collide and it goes away with the scenario.
1544+
let mount_point = ts.fixtures.plus_as_string("rootbind");
1545+
1546+
// `mount --bind /` is refused inside a user namespace because of locked
1547+
// submounts; --make-rprivate + --rbind produces the same (st_dev, st_ino).
1548+
// The mount setup can still be blocked (e.g. `mount(2)` denied in a cross
1549+
// container even though `unshare` starts); if it fails, exit 99 so the test
1550+
// skips instead of failing for the wrong reason.
1551+
let script = format!(
1552+
"mkdir -p {mp}
1553+
{{ mount --make-rprivate / && mount --rbind / {mp}; }} \
1554+
|| {{ echo MOUNT_SETUP_FAILED >&2; exit 99; }}
1555+
exec {bin} rm -ri --preserve-root {mp} < /dev/null",
1556+
mp = shell_quote(&mount_point),
1557+
bin = shell_quote(&ts.bin_path.to_string_lossy())
1558+
);
1559+
let output = Command::new("unshare")
1560+
.args(["--user", "--map-root-user", "--mount", "sh", "-c", &script])
1561+
.env("LC_ALL", "C")
1562+
.env("LANG", "C")
1563+
.env("LANGUAGE", "C")
1564+
.output()
1565+
.expect("failed to spawn unshare");
1566+
1567+
let stderr = String::from_utf8_lossy(&output.stderr);
1568+
if output.status.code() == Some(99) || stderr.contains("MOUNT_SETUP_FAILED") {
1569+
println!("TEST SKIPPED: could not set up a bind mount in the namespace: {stderr}");
1570+
return;
1571+
}
1572+
assert!(
1573+
stderr.contains("it is dangerous to operate recursively on")
1574+
&& stderr.contains("(same as '/')"),
1575+
"--preserve-root did not refuse a bind mount of /: {stderr}"
1576+
);
1577+
assert!(!output.status.success());
1578+
}
1579+
1580+
/// Wrap `s` in single quotes for `sh -c`, escaping any single quote in it.
1581+
#[cfg(target_os = "linux")]
1582+
fn shell_quote(s: &str) -> String {
1583+
format!("'{}'", s.replace('\'', "'\\''"))
1584+
}
1585+
15141586
/// Test that literal "/" is still properly protected.
15151587
#[test]
15161588
fn test_preserve_root_literal_root() {

0 commit comments

Comments
 (0)