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!()