diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index e577d081c49..f3246505692 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz +// spell-checker:ignore strtime rsplit ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz mod format_modifiers; mod locale; @@ -280,6 +280,60 @@ fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> { Some((hours_from_midnight, day_delta)) } +/// Parse a positional argument with format `MMDDhhmm[[CC]YY][.ss]` +fn parse_positional_set_datetime(s: &str, utc: bool) -> Option { + let (base, ss) = if let Some((before, after)) = s.rsplit_once('.') { + if after.len() != 2 || !after.chars().all(|c| c.is_ascii_digit()) { + return None; + } + (before, Some(after)) + } else { + (s, None) + }; + + if !base.chars().all(|c| c.is_ascii_digit()) { + return None; + } + + let tz = if utc { + TimeZone::UTC + } else { + TimeZone::system() + }; + + let rearranged = match base.len() { + 8 => { + let year = Timestamp::now().to_zoned(tz.clone()).year(); + format!("{year}{base}") + } + 10 => { + let yy: u32 = base[8..10].parse().ok()?; + let century = if yy > 68 { 19 } else { 20 }; + format!("{century}{}{}", &base[8..], &base[..8]) + } + 12 => format!("{}{}", &base[8..], &base[..8]), + _ => return None, + }; + + let with_seconds = if let Some(seconds) = ss { + format!("{rearranged}.{seconds}") + } else { + rearranged + }; + + let parse_format = if ss.is_none() { + "%Y%m%d%H%M" + } else { + "%Y%m%d%H%M.%S" + }; + + let dt = strtime::parse(parse_format, &with_seconds) + .and_then(|parsed| parsed.to_datetime()) + .ok()?; + + tz.to_ambiguous_zoned(dt).unambiguous().ok() +} + #[uucore::main] #[allow(clippy::cognitive_complexity)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { @@ -317,25 +371,34 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } } + let utc = matches.get_flag(OPT_UNIVERSAL); + + let mut positional_set_to: Option = None; + let format = if let Some(form) = matches.get_one::(OPT_FORMAT) { - if !form.starts_with('+') { - // if an optional Format String was found but the user has not provided an input date - // GNU prints an invalid date Error - if !matches!(date_source, DateSource::Human(_)) { - return Err(USimpleError::new( - 1, - translate!("date-error-invalid-date", "date" => form), - )); - } - // If the user did provide an input date with the --date flag and the Format String is - // not starting with '+' GNU prints the missing '+' error message + if let Some(stripped) = form.strip_prefix('+') { + Format::Custom(stripped.to_string()) + } else if matches!(date_source, DateSource::Human(_)) { + // -d was given, positional must be +FORMAT return Err(USimpleError::new( 1, translate!("date-error-format-missing-plus", "arg" => form), )); + } else if matches.get_one::(OPT_SET).is_some() { + // -s flag is present: positional must be +FORMAT + return Err(USimpleError::new( + 1, + translate!("date-error-invalid-date", "date" => form), + )); + } else if let Some(zoned) = parse_positional_set_datetime(form, utc) { + positional_set_to = Some(zoned); + Format::Default + } else { + return Err(USimpleError::new( + 1, + translate!("date-error-invalid-date", "date" => form), + )); } - let form = form[1..].to_string(); - Format::Custom(form) } else if let Some(fmt) = matches .get_many::(OPT_ISO_8601) .map(|mut iter| iter.next().unwrap_or(&DATE.to_string()).as_str().into()) @@ -354,7 +417,6 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Format::Default }; - let utc = matches.get_flag(OPT_UNIVERSAL); let debug_mode = matches.get_flag(OPT_DEBUG); // Get the current time, either in the local time zone or UTC. @@ -378,6 +440,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Some(Ok(date)) => Some(date), }; + // positional_set_to and set_to are mutually exclusive: + // MMDDhhmm parsing is only attempted when -s is absent. + let set_to = positional_set_to.or(set_to); + let settings = Settings { utc, format, diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index db4637e6273..27c2fd3d671 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -2836,3 +2836,208 @@ fn test_korean_time_zone() { .succeeds() .stdout_is("2026-01-15 01:00:00 UTC\n"); } + +#[test] +fn test_positional_set_invalid_month() { + new_ucmd!() + .arg("13011200") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_invalid_day() { + new_ucmd!() + .arg("01321200") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_feb_30() { + new_ucmd!() + .arg("02301200") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_invalid_hour() { + new_ucmd!() + .arg("01012500") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_invalid_minute() { + new_ucmd!() + .arg("01010160") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_invalid_length() { + new_ucmd!() + .arg("010101600") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_non_digits() { + new_ucmd!() + .arg("010110a") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_wrong_seconds_suffix() { + new_ucmd!() + .arg("01011200.1") + .fails_with_code(1) + .stderr_contains("invalid date"); + new_ucmd!() + .arg("01012359.aa") + .fails_with_code(1) + .stderr_contains("invalid date"); + new_ucmd!() + .arg("01011200.123") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_with_extra_operand() { + new_ucmd!() + .args(&["01011200", "+%Y"]) + .fails_with_code(1) + .stderr_contains("extra operand"); +} + +#[test] +fn test_positional_set_with_d_flag_format_error() { + new_ucmd!() + .args(&["--date", "2025-01-01", "01012025"]) + .fails_with_code(1) + .stderr_contains("lacks a leading '+'"); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_12_digits() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().arg("010112002025").fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_8_digits() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().arg("01011200").fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_10_digits() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().arg("0101120025").fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_12_digits_with_seconds() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().arg("010112002025.40").fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_12_digits_with_utc_flag() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().args(&["-u", "010112002025"]).fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_century_rule_68() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().arg("0101120068").fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_century_rule_69() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().arg("0101120069").fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(target_os = "macos")] +fn test_positional_set_macos_unavailable() { + let result = new_ucmd!().arg("010112002025").fails(); + result.no_stdout(); + assert!( + result + .stderr_str() + .starts_with("date: setting the date is not supported by macOS") + ); +}