diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs index b063750a80..648b907b95 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()?; @@ -183,6 +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)); Ok(Self { path: full_path.to_path_buf(), @@ -195,6 +207,7 @@ impl Stat { inodes: 1, inode: file_info_option, metadata: std_metadata, + latest_time, }) } } @@ -288,6 +301,19 @@ 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, +) -> 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,6 +496,11 @@ fn safe_du( dev_id: entry_stat.st_dev as u64, }); + 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)); + // 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 { @@ -479,6 +515,7 @@ fn safe_du( // 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 @@ -491,6 +528,7 @@ fn safe_du( inodes: 1, inode: file_info, metadata: my_stat.metadata.clone(), + latest_time, } }; @@ -541,6 +579,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, @@ -550,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, @@ -702,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, @@ -711,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, @@ -875,8 +935,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 +1132,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 1dff1b4aa4..7ad2fe2732 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 { diff --git a/tests/by-util/test_du.rs b/tests/by-util/test_du.rs index 08e920fdd2..718d94cf71 100644 --- a/tests/by-util/test_du.rs +++ b/tests/by-util/test_du.rs @@ -1155,6 +1155,229 @@ 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; + let separator = std::path::MAIN_SEPARATOR; + + 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(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(); + + 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(&format!("2020-01-01 00:00\td{separator}old\n")), + "{stdout}" + ); + assert!( + stdout.contains(&format!("2023-01-01 00:00\td{separator}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; + let separator = std::path::MAIN_SEPARATOR; + + 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(&format!("2020-01-01 00:00\td{separator}old\n")), + "{stdout}" + ); + assert!( + stdout.contains(&format!( + "2023-01-01 00:00\td{separator}sub{separator}new\n" + )), + "{stdout}" + ); + assert!( + stdout.contains(&format!("2023-01-01 00:00\td{separator}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]