From 412912265bb879f1dbe41503af9ce94533771078 Mon Sep 17 00:00:00 2001 From: hlsxx Date: Sat, 4 Jul 2026 14:12:20 +0200 Subject: [PATCH 1/5] du: fix time in print stat --- src/uu/du/src/du.rs | 58 +++++++++++++++---- src/uucore/src/lib/features/safe_traversal.rs | 25 +++++++- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs index b063750a809..dfd0a665724 100644 --- a/src/uu/du/src/du.rs +++ b/src/uu/du/src/du.rs @@ -20,6 +20,7 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::mpsc; use std::thread; +use std::time::SystemTime; use thiserror::Error; use uucore::display::{Quotable, print_verbatim}; use uucore::error::{FromIo, UError, UResult, USimpleError, set_exit_code}; @@ -82,6 +83,7 @@ struct TraversalOptions { count_links: bool, verbose: bool, excludes: Vec, + time_field: Option, } struct StatPrinter { @@ -125,6 +127,7 @@ struct Stat { inodes: u64, inode: Option, metadata: Metadata, + latest_time: Option, } impl Stat { @@ -153,6 +156,9 @@ impl Stat { let file_info = get_file_info(path, &metadata); let blocks = get_blocks(path, &metadata); + let latest_time = options + .time_field + .and_then(|field| metadata_get_time(&metadata, field)); Ok(Self { path: path.to_path_buf(), @@ -161,12 +167,17 @@ impl Stat { inodes: 1, inode: file_info, metadata, + latest_time, }) } /// Create a Stat using safe traversal methods with `DirFd` for the root directory #[cfg(all(unix, not(target_os = "redox")))] - fn new_from_dirfd(dir_fd: &DirFd, full_path: &Path) -> std::io::Result { + fn new_from_dirfd( + dir_fd: &DirFd, + full_path: &Path, + time_field: Option, + ) -> std::io::Result { // Get metadata for the directory itself using fstat let safe_metadata = dir_fd.metadata()?; @@ -184,6 +195,8 @@ impl Stat { // the root path which should be accessible let std_metadata = fs::symlink_metadata(full_path)?; + let latest_time = time_field.and_then(|field| metadata_get_time(&std_metadata, field)); // NEW + Ok(Self { path: full_path.to_path_buf(), size: if safe_metadata.is_dir() { @@ -195,6 +208,7 @@ impl Stat { inodes: 1, inode: file_info_option, metadata: std_metadata, + latest_time, }) } } @@ -288,6 +302,18 @@ fn read_block_size(s: Option<&str>) -> UResult { } } +fn time_from_raw_stat( + entry_stat: &uucore::safe_traversal::Metadata, + time_field: MetadataTimeField, +) -> Option { + match time_field { + MetadataTimeField::Modification => entry_stat.modified(), + MetadataTimeField::Access => entry_stat.accessed(), + MetadataTimeField::Change => entry_stat.changed(), + MetadataTimeField::Birth => None, + } +} + #[cfg(all(unix, not(target_os = "redox")))] // Implement safe_du on Unix (except Redox which lacks full stat support) // This is done for TOCTOU safety @@ -322,6 +348,10 @@ fn safe_du( fs::symlink_metadata("/").expect("root should be accessible") }); + let latest_time = options + .time_field + .and_then(|field| metadata_get_time(&std_metadata, field)); + Stat { path: path.to_path_buf(), size: if safe_metadata.is_dir() { @@ -333,6 +363,7 @@ fn safe_du( inodes: 1, inode: file_info_option, metadata: std_metadata, + latest_time, } } Err(e) => { @@ -360,7 +391,7 @@ fn safe_du( Err(_e) => { // Try using our new DirFd method for the root directory match DirFd::open(path, SymlinkBehavior::Follow) { - Ok(dir_fd) => match Stat::new_from_dirfd(&dir_fd, path) { + Ok(dir_fd) => match Stat::new_from_dirfd(&dir_fd, path, options.time_field) { Ok(s) => s, Err(e) => { let error = e.map_err_context( @@ -465,10 +496,12 @@ fn safe_du( dev_id: entry_stat.st_dev as u64, }); - // For safe traversal, we need to handle stats differently - // We can't use std::fs::Metadata since that requires the full path + let safe_metadata = uucore::safe_traversal::Metadata::from_stat(entry_stat); + let latest_time = options + .time_field + .and_then(|field| time_from_raw_stat(&safe_metadata, field)); + let this_stat = if is_dir { - // For directories, recurse using safe_du Stat { path: path.join(&entry_name), size: 0, @@ -476,12 +509,10 @@ fn safe_du( blocks: entry_stat.st_blocks as u64, inodes: 1, inode: file_info, - // We need a fake metadata - create one from symlink_metadata of parent - // This is a workaround since we can't get real metadata without the full path metadata: my_stat.metadata.clone(), + latest_time, } } else { - // For files Stat { path: path.join(&entry_name), #[allow(clippy::unnecessary_cast)] @@ -491,6 +522,7 @@ fn safe_du( inodes: 1, inode: file_info, metadata: my_stat.metadata.clone(), + latest_time, } }; @@ -541,6 +573,11 @@ fn safe_du( my_stat.size += this_stat.size; my_stat.blocks += this_stat.blocks; my_stat.inodes += this_stat.inodes; + my_stat.latest_time = match (my_stat.latest_time, this_stat.latest_time) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, None) => a, + (None, b) => b, + } } print_tx.send(Ok(StatPrintInfo { stat: this_stat, @@ -875,8 +912,8 @@ impl StatPrinter { fn print_stat(&self, stat: &Stat, size: u64) -> UResult<()> { print!("{}\t", self.convert_size(size)); - if let Some(md_time) = &self.time { - if let Some(time) = metadata_get_time(&stat.metadata, *md_time) { + if let Some(_md_time) = &self.time { + if let Some(time) = stat.latest_time { format_system_time( &mut stdout(), time, @@ -1072,6 +1109,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { count_links, verbose: matches.get_flag(options::VERBOSE), excludes: build_exclude_patterns(&matches)?, + time_field: time, }; let time_format = if time.is_some() { diff --git a/src/uucore/src/lib/features/safe_traversal.rs b/src/uucore/src/lib/features/safe_traversal.rs index 1dff1b4aa4e..7ad2fe27324 100644 --- a/src/uucore/src/lib/features/safe_traversal.rs +++ b/src/uucore/src/lib/features/safe_traversal.rs @@ -19,8 +19,10 @@ use std::ffi::{CString, OsStr, OsString}; use std::fs; use std::io; use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::MetadataExt; use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd}; use std::path::{Path, PathBuf}; +use std::time::SystemTime; use nix::dir::Dir; use nix::fcntl::{OFlag, openat}; @@ -742,10 +744,31 @@ impl Metadata { pub fn is_empty(&self) -> bool { self.len() == 0 } + + fn time_from_secs_nsecs(secs: i64, nsecs: i64) -> Option { + use std::time::{Duration, UNIX_EPOCH}; + if secs >= 0 { + UNIX_EPOCH.checked_add(Duration::new(secs as u64, nsecs as u32)) + } else { + UNIX_EPOCH.checked_sub(Duration::new((-secs) as u64, 0)) + } + } + + pub fn modified(&self) -> Option { + Self::time_from_secs_nsecs(self.mtime(), self.mtime_nsec()) + } + + pub fn accessed(&self) -> Option { + Self::time_from_secs_nsecs(self.atime(), self.atime_nsec()) + } + + pub fn changed(&self) -> Option { + Self::time_from_secs_nsecs(self.ctime(), self.ctime_nsec()) + } } // Add MetadataExt trait implementation for compatibility -impl std::os::unix::fs::MetadataExt for Metadata { +impl MetadataExt for Metadata { // st_dev type varies by platform (i32 on macOS, u64 on Linux) #[allow(clippy::unnecessary_cast)] fn dev(&self) -> u64 { From e28a8700a74493c01297ed1fae79bf2d154c77a5 Mon Sep 17 00:00:00 2001 From: hlsxx Date: Sat, 4 Jul 2026 19:56:27 +0200 Subject: [PATCH 2/5] du: fix exclude redox + revert old comments --- src/uu/du/src/du.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs index dfd0a665724..035285ff74a 100644 --- a/src/uu/du/src/du.rs +++ b/src/uu/du/src/du.rs @@ -194,8 +194,7 @@ impl Stat { // This is still needed for compatibility but should work since we're dealing with // the root path which should be accessible let std_metadata = fs::symlink_metadata(full_path)?; - - let latest_time = time_field.and_then(|field| metadata_get_time(&std_metadata, field)); // NEW + let latest_time = time_field.and_then(|field| metadata_get_time(&std_metadata, field)); Ok(Self { path: full_path.to_path_buf(), @@ -302,6 +301,7 @@ fn read_block_size(s: Option<&str>) -> UResult { } } +#[cfg(all(unix, not(target_os = "redox")))] fn time_from_raw_stat( entry_stat: &uucore::safe_traversal::Metadata, time_field: MetadataTimeField, @@ -501,7 +501,10 @@ fn safe_du( .time_field .and_then(|field| time_from_raw_stat(&safe_metadata, field)); + // For safe traversal, we need to handle stats differently + // We can't use std::fs::Metadata since that requires the full path let this_stat = if is_dir { + // For directories, recurse using safe_du Stat { path: path.join(&entry_name), size: 0, @@ -509,10 +512,13 @@ fn safe_du( blocks: entry_stat.st_blocks as u64, inodes: 1, inode: file_info, + // We need a fake metadata - create one from symlink_metadata of parent + // This is a workaround since we can't get real metadata without the full path metadata: my_stat.metadata.clone(), latest_time, } } else { + // For files Stat { path: path.join(&entry_name), #[allow(clippy::unnecessary_cast)] From ae30e6992e7820179b3358ebfe012de1ee453b86 Mon Sep 17 00:00:00 2001 From: hlsxx Date: Sat, 4 Jul 2026 20:46:49 +0200 Subject: [PATCH 3/5] du: add test for --time aggregation from subtree + missing latest_time --- src/uu/du/src/du.rs | 17 ++++ tests/by-util/test_du.rs | 204 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs index 035285ff74a..648b907b954 100644 --- a/src/uu/du/src/du.rs +++ b/src/uu/du/src/du.rs @@ -593,6 +593,11 @@ fn safe_du( my_stat.size += this_stat.size; my_stat.blocks += this_stat.blocks; my_stat.inodes += 1; + my_stat.latest_time = match (my_stat.latest_time, this_stat.latest_time) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, None) => a, + (None, b) => b, + }; if options.all { print_tx.send(Ok(StatPrintInfo { stat: this_stat, @@ -745,6 +750,12 @@ fn du_regular( my_stat.size += this_stat.size; my_stat.blocks += this_stat.blocks; my_stat.inodes += this_stat.inodes; + my_stat.latest_time = + match (my_stat.latest_time, this_stat.latest_time) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, None) => a, + (None, b) => b, + }; } print_tx.send(Ok(StatPrintInfo { stat: this_stat, @@ -754,6 +765,12 @@ fn du_regular( my_stat.size += this_stat.size; my_stat.blocks += this_stat.blocks; my_stat.inodes += 1; + my_stat.latest_time = + match (my_stat.latest_time, this_stat.latest_time) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, None) => a, + (None, b) => b, + }; if options.all { print_tx.send(Ok(StatPrintInfo { stat: this_stat, diff --git a/tests/by-util/test_du.rs b/tests/by-util/test_du.rs index 08e920fdd2f..e1a39d1c508 100644 --- a/tests/by-util/test_du.rs +++ b/tests/by-util/test_du.rs @@ -1155,6 +1155,210 @@ fn birth_supported() -> bool { m.created().is_ok() } +#[cfg(feature = "touch")] +#[test] +fn test_du_time_directory() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.mkdir("d"); + at.touch("d/old"); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("202001010000") + .arg(at.plus("d/old")) + .succeeds(); + + at.touch("d/new"); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("202301010000") + .arg(at.plus("d/new")) + .succeeds(); + + // Backdate dir mtime to 2019-01-01 so the aggregated max comes from a child + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("201901010000") + .arg(at.plus("d")) + .succeeds(); + + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("d/old") + .succeeds(); + result.stdout_only("0\t2020-01-01 00:00\td/old\n"); + + let result = ts.ucmd().env("TZ", "UTC").arg("--time").arg("d").succeeds(); + let stdout = result.stdout_str(); + + assert!( + stdout.contains("2023-01-01 00:00"), + "wrong time in: {stdout}" + ); + assert!(stdout.contains("\td\n"), "missing dir entry: {stdout}"); + + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("-a") + .arg("d") + .succeeds(); + let stdout = result.stdout_str(); + + assert!(stdout.contains("2020-01-01 00:00\td/old\n"), "{stdout}"); + assert!(stdout.contains("2023-01-01 00:00\td/new\n"), "{stdout}"); + assert!(stdout.contains("2023-01-01 00:00\td\n"), "{stdout}"); +} + +#[cfg(feature = "touch")] +#[test] +fn test_du_time_directory_nested() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.mkdir_all("d/sub"); + at.touch("d/old"); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("202001010000") + .arg(at.plus("d/old")) + .succeeds(); + + at.touch("d/sub/new"); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("202301010000") + .arg(at.plus("d/sub/new")) + .succeeds(); + + // Backdate dir mtime to 2019-01-01 so the aggregated max comes from a child + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("201901010000") + .arg(at.plus("d/sub")) + .succeeds(); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("201901010000") + .arg(at.plus("d")) + .succeeds(); + + // The root directory should show the max time from its subtree + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("-a") + .arg("d") + .succeeds(); + let stdout = result.stdout_str(); + + assert!(stdout.contains("2020-01-01 00:00\td/old\n"), "{stdout}"); + assert!(stdout.contains("2023-01-01 00:00\td/sub/new\n"), "{stdout}"); + assert!(stdout.contains("2023-01-01 00:00\td/sub\n"), "{stdout}"); + assert!(stdout.contains("2023-01-01 00:00\td\n"), "{stdout}"); +} + +#[cfg(feature = "touch")] +#[test] +fn test_du_time_atime() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.touch("f"); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-a") + .arg("-t") + .arg("202201010000") + .arg(at.plus("f")) + .succeeds(); + + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time=atime") + .arg("f") + .succeeds(); + result.stdout_only("0\t2022-01-01 00:00\tf\n"); +} + +#[cfg(feature = "touch")] +#[test] +fn test_du_time_unaffected_by_exclude() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.mkdir("d"); + at.touch("d/keep"); + at.touch("d/ignore"); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("202001010000") + .arg(at.plus("d/keep")) + .succeeds(); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("202301010000") + .arg(at.plus("d/ignore")) + .succeeds(); + + // Backdate dir mtime to 2019-01-01 so the aggregated max comes from a child + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("201901010000") + .arg(at.plus("d")) + .succeeds(); + + // Exclude "ignore" so the reported time for "d" should reflect only "keep"'s mtime + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("--exclude=ignore") + .arg("d") + .succeeds(); + let stdout = result.stdout_str(); + + assert!( + stdout.contains("2020-01-01 00:00"), + "wrong time in: {stdout}" + ); + assert!(stdout.contains("\td\n"), "missing dir entry: {stdout}"); +} + #[cfg(not(any(target_os = "windows", target_os = "openbsd")))] #[cfg(feature = "chmod")] #[test] From fe7fb691d37ea34cc8c77e18ba1ca0031f60d976 Mon Sep 17 00:00:00 2001 From: hlsxx Date: Sun, 5 Jul 2026 10:41:53 +0200 Subject: [PATCH 4/5] du: test fixes platform separator --- tests/by-util/test_du.rs | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/by-util/test_du.rs b/tests/by-util/test_du.rs index e1a39d1c508..3b16e277e23 100644 --- a/tests/by-util/test_du.rs +++ b/tests/by-util/test_du.rs @@ -1160,6 +1160,7 @@ fn birth_supported() -> bool { fn test_du_time_directory() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; + let separator = std::path::MAIN_SEPARATOR; at.mkdir("d"); at.touch("d/old"); @@ -1197,7 +1198,7 @@ fn test_du_time_directory() { .arg("--time") .arg("d/old") .succeeds(); - result.stdout_only("0\t2020-01-01 00:00\td/old\n"); + result.stdout_only(&format!("0\t2020-01-01 00:00\td{}old\n", separator)); let result = ts.ucmd().env("TZ", "UTC").arg("--time").arg("d").succeeds(); let stdout = result.stdout_str(); @@ -1217,8 +1218,14 @@ fn test_du_time_directory() { .succeeds(); let stdout = result.stdout_str(); - assert!(stdout.contains("2020-01-01 00:00\td/old\n"), "{stdout}"); - assert!(stdout.contains("2023-01-01 00:00\td/new\n"), "{stdout}"); + assert!( + stdout.contains(&format!("2020-01-01 00:00\td{}old\n", separator)), + "{stdout}" + ); + assert!( + stdout.contains(&format!("2023-01-01 00:00\td{}new\n", separator)), + "{stdout}" + ); assert!(stdout.contains("2023-01-01 00:00\td\n"), "{stdout}"); } @@ -1227,6 +1234,7 @@ fn test_du_time_directory() { fn test_du_time_directory_nested() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; + let separator = std::path::MAIN_SEPARATOR; at.mkdir_all("d/sub"); at.touch("d/old"); @@ -1276,9 +1284,21 @@ fn test_du_time_directory_nested() { .succeeds(); let stdout = result.stdout_str(); - assert!(stdout.contains("2020-01-01 00:00\td/old\n"), "{stdout}"); - assert!(stdout.contains("2023-01-01 00:00\td/sub/new\n"), "{stdout}"); - assert!(stdout.contains("2023-01-01 00:00\td/sub\n"), "{stdout}"); + assert!( + stdout.contains(&format!("2020-01-01 00:00\td{}old\n", separator)), + "{stdout}" + ); + assert!( + stdout.contains(&format!( + "2023-01-01 00:00\td{}sub{}new\n", + separator, separator + )), + "{stdout}" + ); + assert!( + stdout.contains(&format!("2023-01-01 00:00\td{}sub\n", separator)), + "{stdout}" + ); assert!(stdout.contains("2023-01-01 00:00\td\n"), "{stdout}"); } From 669fd2693dc60f1b319e9757e9bf9cc40b2ac02a Mon Sep 17 00:00:00 2001 From: hlsxx Date: Sun, 5 Jul 2026 12:22:24 +0200 Subject: [PATCH 5/5] du: fixed style in du_tests --- tests/by-util/test_du.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/by-util/test_du.rs b/tests/by-util/test_du.rs index 3b16e277e23..718d94cf71a 100644 --- a/tests/by-util/test_du.rs +++ b/tests/by-util/test_du.rs @@ -1198,7 +1198,7 @@ fn test_du_time_directory() { .arg("--time") .arg("d/old") .succeeds(); - result.stdout_only(&format!("0\t2020-01-01 00:00\td{}old\n", separator)); + result.stdout_only(format!("0\t2020-01-01 00:00\td{separator}old\n")); let result = ts.ucmd().env("TZ", "UTC").arg("--time").arg("d").succeeds(); let stdout = result.stdout_str(); @@ -1219,11 +1219,11 @@ fn test_du_time_directory() { let stdout = result.stdout_str(); assert!( - stdout.contains(&format!("2020-01-01 00:00\td{}old\n", separator)), + stdout.contains(&format!("2020-01-01 00:00\td{separator}old\n")), "{stdout}" ); assert!( - stdout.contains(&format!("2023-01-01 00:00\td{}new\n", separator)), + stdout.contains(&format!("2023-01-01 00:00\td{separator}new\n")), "{stdout}" ); assert!(stdout.contains("2023-01-01 00:00\td\n"), "{stdout}"); @@ -1285,18 +1285,17 @@ fn test_du_time_directory_nested() { let stdout = result.stdout_str(); assert!( - stdout.contains(&format!("2020-01-01 00:00\td{}old\n", separator)), + stdout.contains(&format!("2020-01-01 00:00\td{separator}old\n")), "{stdout}" ); assert!( stdout.contains(&format!( - "2023-01-01 00:00\td{}sub{}new\n", - separator, separator + "2023-01-01 00:00\td{separator}sub{separator}new\n" )), "{stdout}" ); assert!( - stdout.contains(&format!("2023-01-01 00:00\td{}sub\n", separator)), + stdout.contains(&format!("2023-01-01 00:00\td{separator}sub\n")), "{stdout}" ); assert!(stdout.contains("2023-01-01 00:00\td\n"), "{stdout}");