From 5df49ac4fd82b8b139294c4380595edff465570d Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Tue, 3 Feb 2026 16:10:11 -0500 Subject: [PATCH] df: fallback when proc masked (#10417) --- src/uu/df/src/df.rs | 37 +++++++++++++++++-- src/uu/df/src/filesystem.rs | 71 ++++++++++++++++++++++++++++++++++++- tests/by-util/test_df.rs | 61 +++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 4 deletions(-) diff --git a/src/uu/df/src/df.rs b/src/uu/df/src/df.rs index ff72dce34..e0133805f 100644 --- a/src/uu/df/src/df.rs +++ b/src/uu/df/src/df.rs @@ -16,7 +16,7 @@ use uucore::error::{UError, UResult, USimpleError, get_exit_code}; use uucore::fsext::{MountInfo, read_fs_list}; use uucore::parser::parse_size::ParseSizeError; use uucore::translate; -use uucore::{format_usage, show}; +use uucore::{format_usage, show, show_warning}; use clap::{Arg, ArgAction, ArgMatches, Command, parser::ValueSource}; @@ -111,6 +111,13 @@ impl Default for Options { } } +impl Options { + /// Whether -a, -l, -t, or -x options require the mount table. + fn requires_mount_table(&self) -> bool { + self.show_all_fs || self.show_local_fs || self.include.is_some() || self.exclude.is_some() + } +} + #[derive(Debug, Error)] enum OptionsError { // TODO This needs to vary based on whether `--block-size` @@ -358,14 +365,38 @@ where P: AsRef, { // The list of all mounted filesystems. - let mounts: Vec = read_fs_list()?; + let mounts_result = read_fs_list(); + + #[allow(unused_variables)] + let (mounts, use_fallback) = match mounts_result { + Ok(m) => (m, false), + Err(e) => { + if opt.requires_mount_table() { + return Err(e); + } + show_warning!( + "{}", + translate!("df-error-cannot-read-table-of-mounted-filesystems") + ); + (vec![], true) + } + }; let mut result = vec![]; // Convert each path into a `Filesystem`, which contains // both the mount information and usage information. for path in paths { - match Filesystem::from_path(&mounts, path) { + #[cfg(unix)] + let fs_result = if use_fallback { + Filesystem::from_path_direct(path) + } else { + Filesystem::from_path(&mounts, path) + }; + #[cfg(not(unix))] + let fs_result = Filesystem::from_path(&mounts, path); + + match fs_result { Ok(fs) => { if is_included(&fs.mount_info, opt) { result.push(fs); diff --git a/src/uu/df/src/filesystem.rs b/src/uu/df/src/filesystem.rs index bfd982646..858e86917 100644 --- a/src/uu/df/src/filesystem.rs +++ b/src/uu/df/src/filesystem.rs @@ -8,10 +8,17 @@ //! filesystem mounted at a particular directory. It also includes //! information on amount of space available and amount of space used. // spell-checker:ignore canonicalized +#[cfg(unix)] +use std::io; +#[cfg(unix)] +use std::path::PathBuf; use std::{ffi::OsString, path::Path}; #[cfg(unix)] -use uucore::fsext::statfs; +use std::os::unix::fs::MetadataExt; + +#[cfg(unix)] +use uucore::fsext::{FsMeta, pretty_fstype, statfs}; use uucore::fsext::{FsUsage, MountInfo}; /// Summary representation of a filesystem. @@ -61,6 +68,31 @@ fn is_over_mounted(mounts: &[MountInfo], mount: &MountInfo) -> bool { } } +/// Find mount point by walking up the directory tree until device ID changes. +#[cfg(unix)] +pub(crate) fn find_mount_point>(path: P) -> io::Result { + let mut current = path.as_ref().canonicalize()?; + let current_dev = current.metadata()?.dev(); + + loop { + let parent = match current.parent() { + Some(p) if !p.as_os_str().is_empty() => p, + _ => return Ok(current), + }; + + let parent_dev = parent.metadata()?.dev(); + if parent_dev != current_dev { + return Ok(current); + } + + if parent == current { + return Ok(current); + } + + current = parent.to_path_buf(); + } +} + /// Find the mount info that best matches a given filesystem path. /// /// This function returns the element of `mounts` on which `path` is @@ -195,6 +227,43 @@ impl Filesystem { #[cfg(not(windows))] return result.and_then(|mount_info| Self::from_mount(mounts, mount_info, Some(file))); } + + /// Fallback using statfs when mount table is unavailable. + #[cfg(unix)] + pub(crate) fn from_path_direct

(path: P) -> Result + where + P: AsRef, + { + let file = path.as_ref().as_os_str().to_owned(); + + let canonical_path = path + .as_ref() + .canonicalize() + .map_err(|_| FsError::InvalidPath)?; + + let stat_result = statfs(canonical_path.as_os_str()).map_err(|_| FsError::MountMissing)?; + let mount_dir = find_mount_point(&canonical_path).map_err(|_| FsError::MountMissing)?; + let fs_type = pretty_fstype(stat_result.fs_type()).into_owned(); + + let mount_info = MountInfo { + dev_id: String::new(), + dev_name: "-".to_string(), + fs_type, + mount_dir: mount_dir.into_os_string(), + mount_option: String::new(), + mount_root: OsString::new(), + remote: false, + dummy: false, + }; + + let usage = FsUsage::new(stat_result); + + Ok(Self { + file: Some(file), + mount_info, + usage, + }) + } } #[cfg(test)] diff --git a/tests/by-util/test_df.rs b/tests/by-util/test_df.rs index 4754acbfe..c1cf50d25 100644 --- a/tests/by-util/test_df.rs +++ b/tests/by-util/test_df.rs @@ -15,6 +15,8 @@ use std::collections::HashSet; #[cfg(not(any(target_os = "freebsd", target_os = "windows")))] use uutests::at_and_ucmd; use uutests::new_ucmd; +#[cfg(target_os = "linux")] +use uutests::util::TestScenario; #[test] fn test_invalid_arg() { @@ -1091,3 +1093,62 @@ fn test_df_hides_binfmt_misc_by_default() { } // If binfmt_misc is not mounted, skip the test silently } + +/// Run df inside a mount namespace where /proc is masked with tmpfs. +/// Returns (success, stdout, stderr). +#[cfg(target_os = "linux")] +fn run_df_with_masked_proc(args: &str) -> Option<(bool, String, String)> { + use std::process::Command; + + // Check if user namespaces are available + if !Command::new("unshare") + .args(["-rm", "true"]) + .status() + .is_ok_and(|s| s.success()) + { + return None; + } + + let df_path = TestScenario::new("df").bin_path.clone(); + let output = Command::new("unshare") + .args(["-rm", "sh", "-c"]) + .arg(format!( + "mount -t tmpfs tmpfs /proc && {} df {args}", + df_path.display() + )) + .output() + .ok()?; + + Some(( + output.status.success(), + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + )) +} + +/// Test df fallback when /proc is masked - should work with path, fail without or with filters. +#[test] +#[cfg(target_os = "linux")] +fn test_df_masked_proc_fallback() { + if let Some((ok, stdout, stderr)) = run_df_with_masked_proc(".") { + assert!(ok, "df . should succeed: {stderr}"); + assert!(stderr.contains("cannot read table of mounted file systems")); + assert!(stdout.contains("Filesystem")); + } + + if let Some((ok, _, _)) = run_df_with_masked_proc("") { + assert!(!ok, "df without args should fail when /proc is masked"); + } + + for args in ["-a .", "-l .", "-t ext4 .", "-x tmpfs ."] { + if let Some((ok, _, _)) = run_df_with_masked_proc(args) { + assert!(!ok, "df {args} should fail when /proc is masked"); + } + } + + for args in ["-i .", "-T .", "--total ."] { + if let Some((ok, _, stderr)) = run_df_with_masked_proc(args) { + assert!(ok, "df {args} should succeed: {stderr}"); + } + } +}