du: port to use the safe io functions

This commit is contained in:
Sylvestre Ledru
2025-09-12 14:22:33 +02:00
parent 82d9371ed3
commit c890a3479a
5 changed files with 848 additions and 26 deletions
+7 -1
View File
@@ -21,7 +21,13 @@ path = "src/du.rs"
# For the --exclude & --exclude-from options
glob = { workspace = true }
clap = { workspace = true }
uucore = { workspace = true, features = ["format", "fsext", "parser", "time"] }
uucore = { workspace = true, features = [
"format",
"fsext",
"parser",
"time",
"safe-traversal",
] }
thiserror = { workspace = true }
fluent = { workspace = true }
+466 -23
View File
File diff suppressed because it is too large Load Diff
+141 -2
View File
@@ -1,7 +1,8 @@
// Safe directory traversal using openat() and related syscalls
// This module provides TOCTOU-safe filesystem operations for recursive traversal
// Only available on Linux
// spell-checker:ignore CLOEXEC RDONLY TOCTOU closedir dirp fdopendir fstatat openat
// spell-checker:ignore CLOEXEC RDONLY TOCTOU closedir dirp fdopendir fstatat openat REMOVEDIR unlinkat
// spell-checker:ignore RAII dirfd
#![cfg(target_os = "linux")]
@@ -291,7 +292,6 @@ impl FileInfo {
ino: stat.st_ino as u64,
}
}
}
/// Create FileInfo from device and inode numbers
pub fn new(dev: u64, ino: u64) -> Self {
@@ -384,6 +384,144 @@ impl Metadata {
pub fn as_raw_stat(&self) -> &libc::stat {
&self.stat
}
/// Compatibility methods to match std::fs::Metadata interface
pub fn is_dir(&self) -> bool {
self.file_type().is_directory()
}
pub fn len(&self) -> u64 {
self.size()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
// Add MetadataExt trait implementation for compatibility
#[cfg(not(windows))]
impl std::os::unix::fs::MetadataExt for Metadata {
fn dev(&self) -> u64 {
self.stat.st_dev
}
fn ino(&self) -> u64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_ino.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_ino
}
}
fn mode(&self) -> u32 {
self.stat.st_mode
}
fn nlink(&self) -> u64 {
// st_nlink is u32 on most platforms except x86_64
#[cfg(target_arch = "x86_64")]
{
self.stat.st_nlink
}
#[cfg(not(target_arch = "x86_64"))]
{
self.stat.st_nlink.into()
}
}
fn uid(&self) -> u32 {
self.stat.st_uid
}
fn gid(&self) -> u32 {
self.stat.st_gid
}
fn rdev(&self) -> u64 {
self.stat.st_rdev
}
fn size(&self) -> u64 {
self.stat.st_size as u64
}
fn atime(&self) -> i64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_atime.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_atime
}
}
fn atime_nsec(&self) -> i64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_atime_nsec.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_atime_nsec
}
}
fn mtime(&self) -> i64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_mtime.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_mtime
}
}
fn mtime_nsec(&self) -> i64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_mtime_nsec.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_mtime_nsec
}
}
fn ctime(&self) -> i64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_ctime.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_ctime
}
}
fn ctime_nsec(&self) -> i64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_ctime_nsec.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_ctime_nsec
}
}
fn blksize(&self) -> u64 {
self.stat.st_blksize as u64
}
fn blocks(&self) -> u64 {
self.stat.st_blocks as u64
}
}
#[cfg(test)]
@@ -647,3 +785,4 @@ mod tests {
);
}
}
}
+233
View File
@@ -4,6 +4,7 @@
// file that was distributed with this source code.
// spell-checker:ignore (paths) atim sublink subwords azerty azeaze xcwww azeaz amaz azea qzerty tazerty tsublink testfile1 testfile2 filelist fpath testdir testfile
// spell-checker:ignore selfref ELOOP
#[cfg(not(windows))]
use regex::Regex;
@@ -1439,3 +1440,235 @@ fn test_du_threshold_no_suggested_values() {
let result = ts.ucmd().arg("--threshold").fails();
assert!(!result.stderr_str().contains("[possible values: ]"));
}
#[test]
#[cfg(target_os = "linux")]
fn test_du_long_path_safe_traversal() {
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;
let mut deep_path = String::from("long_path_test");
at.mkdir(&deep_path);
for i in 0..15 {
let long_dir_name = format!("{}{}", "a".repeat(100), i);
deep_path = format!("{deep_path}/{long_dir_name}");
at.mkdir_all(&deep_path);
}
let test_file = format!("{deep_path}/test.txt");
at.write(&test_file, "test content");
let result = ts.ucmd().arg("-s").arg("long_path_test").succeeds();
assert!(result.stdout_str().contains("long_path_test"));
let result = ts.ucmd().arg("long_path_test").succeeds();
let lines: Vec<&str> = result.stdout_str().trim().lines().collect();
assert!(lines.len() >= 15);
}
#[test]
#[cfg(unix)]
fn test_du_very_deep_directory() {
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;
let mut current_path = String::from("x");
at.mkdir(&current_path);
for _ in 0..10 {
current_path = format!("{current_path}/x");
at.mkdir_all(&current_path);
}
at.write(&format!("{current_path}/file.txt"), "deep file");
let result = ts.ucmd().arg("-s").arg("x").succeeds();
assert!(result.stdout_str().contains('x'));
let result = ts.ucmd().arg("-a").arg("x").succeeds();
let output = result.stdout_str();
assert!(output.contains("file.txt"));
}
#[test]
#[cfg(unix)]
fn test_du_safe_traversal_with_symlinks() {
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;
let mut deep_path = String::from("symlink_test");
at.mkdir(&deep_path);
for i in 0..8 {
let dir_name = format!("{}{}", "b".repeat(50), i);
deep_path = format!("{deep_path}/{dir_name}");
at.mkdir_all(&deep_path);
}
at.write(&format!("{deep_path}/target.txt"), "target content");
at.symlink_file(&format!("{deep_path}/target.txt"), "shallow_link.txt");
let result = ts.ucmd().arg("-L").arg("shallow_link.txt").succeeds();
assert!(!result.stdout_str().is_empty());
let result = ts.ucmd().arg("shallow_link.txt").succeeds();
assert!(!result.stdout_str().is_empty());
}
#[test]
#[cfg(target_os = "linux")]
fn test_du_inaccessible_directory() {
// tested by tests/du/no-x
let ts = TestScenario::new(util_name!());
let at = ts.fixtures.clone();
at.mkdir("d");
at.mkdir("d/no-x");
at.mkdir("d/no-x/y");
at.set_mode("d/no-x", 0o600);
let result = ts.ucmd().arg("d").fails();
result.stderr_contains("du: cannot access 'd/no-x/y': Permission denied");
}
#[test]
#[cfg(unix)]
fn test_du_symlink_self_reference() {
// Test symlink that points to its own directory
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;
at.mkdir("selfref");
at.symlink_dir("selfref", "selfref/self");
let result = ts.ucmd().arg("-L").arg("selfref").succeeds();
result.stdout_contains("selfref");
// Should not show the self-referencing symlink to avoid infinite recursion
result.stdout_does_not_contain("selfref/self");
}
#[test]
#[cfg(unix)]
fn test_du_long_symlink_chain() {
// Test that very long symlink chains are handled gracefully
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;
// Create a simple structure that tests symlink depth limits
// Instead of trying to create a chain that causes ELOOP, test that reasonable chains work
at.mkdir_all("deep/level1/level2/level3/level4/level5");
at.write(
"deep/level1/level2/level3/level4/level5/file.txt",
"content",
);
at.symlink_dir("deep/level1", "link1");
at.symlink_dir("link1/level2", "link2");
at.symlink_dir("link2/level3", "link3");
let result = ts.ucmd().arg("-L").arg("link3").succeeds();
result.stdout_contains("link3");
}
#[test]
#[cfg(all(unix, not(target_os = "macos")))]
fn test_du_bind_mount_simulation() {
// Simulate bind mount scenario using hard links where possible
// Note: This test simulates what bind mounts do - making the same directory
// appear in multiple places with the same inode
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;
at.mkdir_all("mount_test/subdir");
at.write("mount_test/file1.txt", "content1");
at.write("mount_test/subdir/file2.txt", "content2");
// On systems where we can't create actual bind mounts,
// we test that cycle detection works with symlinks that would create similar cycles
at.symlink_dir("../mount_test", "mount_test/subdir/cycle_link");
let result = ts.ucmd().arg("mount_test").succeeds();
result.stdout_contains("mount_test/subdir");
result.stdout_contains("mount_test");
result.stdout_does_not_contain("mount_test/subdir/cycle_link");
}
#[test]
#[cfg(unix)]
fn test_du_symlink_depth_tracking() {
// Test that du can handle reasonable symlink chains without hitting depth limits
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;
at.mkdir_all("chain/dir1/dir2/dir3");
at.write("chain/dir1/dir2/dir3/file.txt", "content");
at.symlink_dir("chain/dir1/dir2", "shortcut");
let result = ts.ucmd().arg("-L").arg("shortcut").succeeds();
result.stdout_contains("shortcut/dir3");
result.stdout_contains("shortcut");
}
#[test]
#[cfg(target_os = "linux")]
fn test_du_long_path_from_unreadable() {
// Test the specific scenario from GNU's long-from-unreadable.sh test
// This verifies that du can handle very long paths when the current directory is unreadable
use std::env;
use std::fs;
use std::os::unix::fs::PermissionsExt;
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;
// Create a deep hierarchy similar to the GNU test
// Use a more reasonable depth for unit tests
let dir_name = "x".repeat(200);
let mut current_path = String::new();
for i in 0..20 {
if i == 0 {
current_path = dir_name.clone();
} else {
current_path = format!("{current_path}/{dir_name}");
}
at.mkdir_all(&current_path);
}
at.write(&format!("{current_path}/test.txt"), "test content");
at.mkdir("inaccessible");
let original_cwd = env::current_dir().unwrap();
let inaccessible_path = at.plus("inaccessible");
env::set_current_dir(&inaccessible_path).unwrap();
// Remove read permission from the directory
let mut perms = fs::metadata(&inaccessible_path).unwrap().permissions();
perms.set_mode(0o000);
fs::set_permissions(&inaccessible_path, perms).unwrap();
// Try to run du on the long path from the unreadable directory
let target_path = at.plus(&dir_name);
let result = ts.ucmd().arg("-s").arg(&target_path).succeeds(); // Should succeed with safe traversal
assert!(!result.stdout_str().is_empty());
let output = result.stdout_str().trim();
let parts: Vec<&str> = output.split_whitespace().collect();
assert_eq!(parts.len(), 2);
assert!(parts[0].parse::<u64>().is_ok());
assert!(parts[1].contains(&dir_name[..50])); // Check first part of the long name
env::set_current_dir(&original_cwd).unwrap();
// Restore permissions so the directory can be cleaned up
let mut perms = fs::metadata(&inaccessible_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&inaccessible_path, perms).unwrap();
}
+1
View File
@@ -4,6 +4,7 @@
# spell-checker:ignore (paths) abmon deref discrim eacces getlimits getopt ginstall inacc infloop inotify reflink ; (misc) INT_OFLOW OFLOW
# spell-checker:ignore baddecode submodules xstrtol distros ; (vars/env) SRCDIR vdir rcexp xpart dired OSTYPE ; (utils) gnproc greadlink gsed multihardlink texinfo CARGOFLAGS
# spell-checker:ignore openat TOCTOU
set -e