Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions src/uu/date/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1039,8 +1039,12 @@ fn resolve_tz_abbreviation(word: &str) -> Option<TimeZone> {
/// (e.g. "10:30 EST").
///
/// If a trailing abbreviation is found and the rest of the string is a parsable
/// date, returns `Some(Zoned)`. Returns `None` if no abbreviation is detected or
/// if parsing fails, indicating that standard parsing should be attempted.
/// date that could still legally take a timezone, returns `Some(Zoned)`.
///
/// Returns `None` when no abbreviation is detected, when parsing fails, or when
/// the remainder already carries zone information or cannot take a zone at all
/// (GNU `date` rejects those). In every `None` case the caller should fall back
/// to standard parsing, which reports the error.
fn try_parse_with_abbreviation<S: AsRef<str>>(date_str: S, now: &Zoned) -> Option<Zoned> {
let s = date_str.as_ref();

Expand All @@ -1050,21 +1054,32 @@ fn try_parse_with_abbreviation<S: AsRef<str>>(date_str: S, now: &Zoned) -> Optio

let date_part = s.trim_end_matches(last_word).trim();

// Reject inputs that specify a timezone twice, e.g. "EST EST" or "EST PST":
// GNU `date` considers these invalid. If what remains after stripping the
// trailing abbreviation is itself a bare timezone abbreviation, don't rescue
// it here; let the standard parser reject the whole string.
if date_part
.split_whitespace()
.last()
.is_some_and(|w| resolve_tz_abbreviation(w).is_some())
{
// GNU rejects "@0 EST": a timestamp cannot take a timezone.
if date_part.starts_with('@') {
return None;
}

// Parse in the target timezone so "10:30 EDT" means 10:30 in EDT.
let parsed = parse_datetime::parse_datetime_at_date(now.clone(), date_part).ok()?;
let zoned = parsed.into_zoned()?.datetime().to_zoned(tz).ok()?;
let zoned = parsed.into_zoned()?;

// `parse_datetime` returns the zone the input named, or `now`'s when it named
// none, so a mismatch means `date_part` carries one of its own.
if zoned.time_zone() != now.time_zone() {
return None;
}

// That check cannot see a zone whose offset equals `now`'s ("12:00 UTC EST"
// under `-u`). Gated so the common case stays at one parse: `-f` runs this
// once per line.
let names_zone = date_part.contains('+')
|| date_part.contains(|c: char| c.is_ascii_alphabetic())
|| date_part.split_whitespace().any(|w| w.starts_with('-'));
if names_zone {
parse_datetime::parse_datetime_at_date(now.clone(), format!("{date_part} EST")).ok()?;
}

let zoned = zoned.datetime().to_zoned(tz).ok()?;

// The trailing abbreviation only describes the *input* timezone. For display,
// re-zone to the system timezone (i.e. `now`'s zone, which is UTC under `-u`).
Expand Down
47 changes: 46 additions & 1 deletion tests/by-util/test_date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes juin décembre Januar Juni Dezember enero junio diciembre gennaio giugno dicembre junho dezembro lundi dimanche Montag Sonntag Samstag sábado febr MEST KST uueuu ueuu vasárnap június január distros
// spell-checker: ignore: AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes juin décembre Januar Juni Dezember enero junio diciembre gennaio giugno dicembre junho dezembro lundi dimanche Montag Sonntag Samstag sábado febr MEST MESZ KST uueuu ueuu vasárnap június január distros
// spell-checker: ignore: uppercases

use std::cmp::Ordering;
Expand Down Expand Up @@ -101,6 +101,51 @@ fn test_large_year_default_output_boundary() {
.stderr_contains("invalid date");
}

#[test]
fn test_date_rejects_input_that_cannot_take_a_timezone() {
// A trailing timezone abbreviation must not rescue an input that already
// carries zone information, or that cannot take a zone at all. GNU date
// rejects all of these.
for input in [
"Jan 23 6:00PM GMT-1 EST", // offset plus abbreviation
"023-060 MEST", // time with offset, plus abbreviation
"2024-01-15 12:00 EST EST", // the same abbreviation twice
"@0 EST", // a timestamp cannot take a zone
"2024-01-15 12:00 UTC EST", // a named zone whose offset matches TZ
"2024-01-15 12:00 GMT EST", // likewise, spelled differently
"2024-01-15 12:00 +0000 EST", // a numeric offset matching TZ
"2024-01-15 12:00 -0500 EST", // a standalone negative offset
"UTC 2024-01-15 12:00 EST", // zone stated before the date
] {
new_ucmd!()
.env("LC_ALL", "C")
.env("TZ", "UTC0")
.args(&["-d", input])
.fails_with_code(1)
.stderr_contains("invalid date");
}
}

#[test]
fn test_date_accepts_gnu_timezone_abbreviations() {
// Abbreviations GNU date accepts, with the UTC time they map to.
for (input, expected) in [
("2024-01-15 12:00 MEZ", "11:00\n"),
("2024-01-15 12:00 MESZ", "10:00\n"),
("2024-01-15 12:00 MEST", "10:00\n"),
("2024-01-15 12:00 KST", "03:00\n"),
("2024-01-15 12:00 EST", "17:00\n"),
("2024-01-15 12:00 IST", "06:30\n"),
] {
new_ucmd!()
.env("LC_ALL", "C")
.env("TZ", "UTC0")
.args(&["-u", "-d", input, "+%H:%M"])
.succeeds()
.stdout_is(expected);
}
}

#[test]
fn test_format_option_not_to_capture_other_valid_arguments() {
new_ucmd!()
Expand Down
Loading