From 2ae91cc57cca9aa2d4df64f883354beab13bbf43 Mon Sep 17 00:00:00 2001 From: Asifur Rahaman Meeru Date: Wed, 26 Aug 2026 22:28:40 +0600 Subject: [PATCH] date: reject a trailing timezone when the input already has one `try_parse_with_abbreviation` strips a trailing timezone abbreviation and re-parses what is left, which let it accept strings the parser had already rejected. Its guard only caught a remaining bare abbreviation such as "EST PST", so anything else carrying zone information slipped past it: date -d "Jan 23 6:00PM GMT-1 EST" # an offset and a zone date -d "023-060 MEST" # a time with an offset, and a zone date -d "@0 EST" # a timestamp cannot take a zone GNU date rejects all of these. We accepted them, and the "023-060" case quietly answered with today's date. Rather than reimplement the offset grammar here, read the answer off the parse this path already performs. parse_datetime hands back the zone the input named, or the current zone when it named none, so a mismatch says the remainder carries its own. A leading "@" is tested directly, since a timestamp cannot take a zone at all. The parser is consulted a second time only when the remainder does name a zone whose offset coincides with the current one, which the comparison cannot see on its own. The common case stays at a single parse, and that matters because -f runs this once per line. Input that carries zone information of its own, or cannot take one at all, now falls through to the standard parser and is rejected. The abbreviations this path exists for are unaffected, including the Australian ones that GNU does not support. Checked against GNU coreutils 9.11 over the conflicting forms in several timezones, and over 300 inputs GNU accepts, none of which this change rejects. Closes #13865 --- src/uu/date/src/date.rs | 39 +++++++++++++++++++++---------- tests/by-util/test_date.rs | 47 +++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 103b05d6637..1e8e3b66163 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -1039,8 +1039,12 @@ fn resolve_tz_abbreviation(word: &str) -> Option { /// (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>(date_str: S, now: &Zoned) -> Option { let s = date_str.as_ref(); @@ -1050,21 +1054,32 @@ fn try_parse_with_abbreviation>(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`). diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index bc3a249764e..6da743ee1b4 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_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: 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; @@ -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!()