From 6af316ce882b3878c0c66723a3150269e2007f18 Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:55:00 +0530 Subject: [PATCH] fix(timezone): read full posix rules in TZ="..." Only the STDOFFSET part of a posix rule was parsed. A rule with a DST part (TZ="CET-1CEST,M3.5.0,M10.5.0/3", TZ="EST5EDT") made the whole input invalid, and a quoted name (TZ="<+03>-3") fell through to the IANA lookup and silently became UTC. Keep the STDOFFSET parser for rules that end there, and hand anything else that is not an IANA name to jiff's posix parser. A DST name without a rule gets the US rules, as glibc does. A rule that starts like a posix rule but is broken further on stays an error. --- src/items/timezone.rs | 60 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/src/items/timezone.rs b/src/items/timezone.rs index 0414ee8..8057b40 100644 --- a/src/items/timezone.rs +++ b/src/items/timezone.rs @@ -16,7 +16,7 @@ use jiff::tz::{Offset, TimeZone}; use winnow::{ - combinator::{alt, delimited, opt, preceded, repeat}, + combinator::{alt, delimited, opt, peek, preceded, repeat, terminated}, stream::AsChar, token::{one_of, take_while}, ModalResult, Parser, @@ -25,13 +25,18 @@ use winnow::{ use super::primitive::{dec_uint, plus_or_minus}; pub(super) fn parse(input: &mut &str) -> ModalResult { - delimited("TZ=\"", preceded(opt(':'), alt((posix, iana))), '"').parse_next(input) + delimited( + "TZ=\"", + preceded(opt(':'), alt((terminated(posix, peek('"')), iana))), + '"', + ) + .parse_next(input) } /// Parse a posix (proleptic) timezone string (e.g., "UTC7", "JST-9"). /// -/// TODO: This implementation is incomplete. It currently only parses the -/// `STDOFFSET` part of the format. +/// This only parses the `STDOFFSET` part of the format. A rule with a `DST` +/// part or a quoted `<...>` name is left to [`iana`], which hands it to jiff. /// /// From the GNU docs: /// @@ -59,7 +64,10 @@ fn posix(input: &mut &str) -> ModalResult { } /// Parse an IANA (geographical) timezone string (e.g., "Europe/Paris"). If the -/// string is not a valid IANA timezone name, the UTC timezone is returned. +/// string is not a valid IANA timezone name, it is read as a full posix rule +/// (e.g., "CET-1CEST,M3.5.0,M10.5.0/3", "<+03>-3"). If that fails too, the UTC +/// timezone is returned, unless the string starts like a posix rule (e.g., +/// "EST5EDT,"), which is an error. /// /// Compatibility notes: /// @@ -90,7 +98,14 @@ fn iana(input: &mut &str) -> ModalResult { )), ) .map(|parts: Vec| parts.concat()) - .map(|s| TimeZone::get(&s).unwrap_or(TimeZone::UTC)) + .verify_map(|s| { + TimeZone::get(&s) + .or_else(|_| TimeZone::posix(&s)) + // Like glibc, use the US rules for a DST name without a rule (e.g., "EST5EDT") + .or_else(|_| TimeZone::posix(&format!("{s},M3.2.0,M11.1.0"))) + .ok() + .or_else(|| posix(&mut s.as_str()).is_err().then_some(TimeZone::UTC)) + }) .parse_next(input) } @@ -126,6 +141,7 @@ fn posix_offset(input: &mut &str) -> ModalResult { #[cfg(test)] mod tests { use super::*; + use jiff::Timestamp; #[test] fn tz_rule() { @@ -188,11 +204,37 @@ mod tests { ); } + // posix with a dst part or a quoted name: (input, offset in July, offset in January) + let july: Timestamp = "2026-07-01T12:00:00Z".parse().unwrap(); + let january: Timestamp = "2026-01-01T12:00:00Z".parse().unwrap(); + for (input, summer, winter) in [ + (r#"TZ="EST5EDT,M3.2.0,M11.1.0""#, -4 * 3600, -5 * 3600), + (r#"TZ="EST5EDT""#, -4 * 3600, -5 * 3600), + (r#"TZ="XYZ+5ABC""#, -4 * 3600, -5 * 3600), + (r#"TZ="CET-1CEST,M3.5.0,M10.5.0/3""#, 2 * 3600, 3600), + (r#"TZ="NZST-12NZDT,M9.5.0,M4.1.0/3""#, 12 * 3600, 13 * 3600), + (r#"TZ="<+03>-3""#, 3 * 3600, 3 * 3600), + (r#"TZ="<-05>5""#, -5 * 3600, -5 * 3600), + ( + r#"TZ="<+0530>-5:30""#, + 5 * 3600 + 30 * 60, + 5 * 3600 + 30 * 60, + ), + ] { + let mut s = input; + let tz = parse(&mut s).unwrap(); + assert_eq!(tz.to_offset(july).seconds(), summer, "{input}"); + assert_eq!(tz.to_offset(january).seconds(), winter, "{input}"); + } + // invalid for input in [ - r#"UTC"#, // missing "TZ=" - r#"tz="UTC""#, // lowercase "tz" - r#"TZ=UTC"#, // missing quotes + r#"UTC"#, // missing "TZ=" + r#"tz="UTC""#, // lowercase "tz" + r#"TZ=UTC"#, // missing quotes + r#"TZ="EST5EDT,""#, // empty dst rule + r#"TZ="EST5EDT,M3.2.0""#, // dst rule without an end + r#"TZ="CET-1CEST,M3.5.0,M99""#, // bad dst rule ] { let mut s = input; assert!(parse(&mut s).is_err(), "{input}");