diff --git a/Cargo.lock b/Cargo.lock index 1004312abb4..d3c7502a75f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2757,6 +2757,7 @@ dependencies = [ "chrono", "clap", "glob", + "regex", "thiserror 2.0.12", "uucore", "windows-sys 0.59.0", diff --git a/src/uu/du/Cargo.toml b/src/uu/du/Cargo.toml index 5b0d3f5e8ea..46587f02a24 100644 --- a/src/uu/du/Cargo.toml +++ b/src/uu/du/Cargo.toml @@ -22,6 +22,7 @@ chrono = { workspace = true } # For the --exclude & --exclude-from options glob = { workspace = true } clap = { workspace = true } +regex = { workspace = true } uucore = { workspace = true, features = ["format", "parser"] } thiserror = { workspace = true } diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs index c1838d1db59..da70c5193e2 100644 --- a/src/uu/du/src/du.rs +++ b/src/uu/du/src/du.rs @@ -6,6 +6,8 @@ use chrono::{DateTime, Local}; use clap::{Arg, ArgAction, ArgMatches, Command, builder::PossibleValue}; use glob::Pattern; +use regex::Regex; +use std::borrow::Cow; use std::collections::HashSet; use std::env; #[cfg(not(windows))] @@ -20,7 +22,7 @@ use std::os::windows::fs::MetadataExt; use std::os::windows::io::AsRawHandle; use std::path::{Path, PathBuf}; use std::str::FromStr; -use std::sync::mpsc; +use std::sync::{LazyLock, mpsc}; use std::thread; use std::time::{Duration, UNIX_EPOCH}; use thiserror::Error; @@ -74,6 +76,13 @@ const ABOUT: &str = help_about!("du.md"); const AFTER_HELP: &str = help_section!("after help", "du.md"); const USAGE: &str = help_usage!("du.md"); +static TIME_STYLE_REGEX: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?(?:^|[^%])(?:%%)*)%(?[^YCyqmbBhdeaAwuUWGgVjDxFvHkIlPpMSfRTXrZzc+stn%.369:]|[-_0][^YCyqmdewuUWGgVjHkIlMSfs%]|\.(?:[^369f]|$)|\.[369](?:[^f]|$)|[369](?:[^f]|$)|:{1,2}(?:[^z:]|$)|:{3}[^z]|$)", // cspell:disable-line + ) + .unwrap() +}); + struct TraversalOptions { all: bool, separate_dirs: bool, @@ -708,7 +717,12 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }; let time_format = if time.is_some() { - parse_time_style(matches.get_one::("time-style").map(|s| s.as_str()))?.to_string() + parse_time_style( + matches + .get_one::(options::TIME_STYLE) + .map(|s| s.as_str()), + )? + .to_string() } else { "%Y-%m-%d %H:%M".to_string() }; @@ -801,15 +815,38 @@ fn get_time_secs(time: Time, stat: &Stat) -> Result { } } -fn parse_time_style(s: Option<&str>) -> UResult<&str> { - match s { +fn parse_time_style(s: Option<&str>) -> UResult> { + let s = match s { Some(s) => match s { "full-iso" => Ok("%Y-%m-%d %H:%M:%S.%f %z"), "long-iso" => Ok("%Y-%m-%d %H:%M"), "iso" => Ok("%Y-%m-%d"), - _ => Err(DuError::InvalidTimeStyleArg(s.into()).into()), + _ => match s.strip_prefix('+') { + Some(s) => Ok(s), + _ => Err(DuError::InvalidTimeStyleArg(s.into()).into()), + }, }, None => Ok("%Y-%m-%d %H:%M"), + }; + match s { + Ok(s) => { + let mut s = s.to_owned(); + let mut start = 0; + while start < s.len() { + // FIXME: this should use let chains once they're stabilized + // See https://github.com/rust-lang/rust/issues/53667 + if let Some(cap) = TIME_STYLE_REGEX.captures(&s[start..]) { + let mat = cap.name("before").unwrap(); + let percent_idx = mat.end(); + s.replace_range(percent_idx + start..=percent_idx + start, "%%"); + start = percent_idx + 2; + } else { + break; + } + } + Ok(Cow::Owned(s)) + } + Err(e) => Err(e), } } diff --git a/tests/by-util/test_du.rs b/tests/by-util/test_du.rs index d651bfcabcb..3ec57b265dd 100644 --- a/tests/by-util/test_du.rs +++ b/tests/by-util/test_du.rs @@ -7,6 +7,7 @@ #[cfg(not(windows))] use regex::Regex; +use rstest::rstest; use uutests::at_and_ucmd; use uutests::new_ucmd; #[cfg(not(target_os = "windows"))] @@ -1169,6 +1170,53 @@ fn test_invalid_time_style() { .stdout_does_not_contain("du: invalid argument 'banana' for 'time style'"); } +#[rstest] +#[case::full_iso("+%Y-%m-%d %H:%M:%S.%f %z")] +#[case::long_iso("+%Y-%m-%d %H:%M")] +#[case::iso("+%Y-%m-%d")] +#[case::seconds("+%S")] +fn test_valid_time_style(#[case] input: &str) { + new_ucmd!() + .args(&["--time", "--time-style", input]) + .succeeds(); +} + +#[test] +fn test_time_style_escaping() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.touch("date_test"); + + // printable characters (a little overkill, but better than missing something) + for c in '!'..='~' { + let f = format!("+%{c}"); + ts.ucmd() + .arg("--time") + .arg("--time-style") + .arg(f) + .arg("date_test") + .succeeds(); + for prefix in ".:#0-_".chars() { + let f = format!("+%{prefix}{c}"); + ts.ucmd() + .arg("--time") + .arg("--time-style") + .arg(f) + .arg("date_test") + .succeeds(); + } + } + + let f = "+%%%"; + ts.ucmd() + .arg("--time") + .arg("--time-style") + .arg(f) + .arg("date_test") + .succeeds(); +} + #[test] fn test_human_size() { use std::fs::File;