From 300d817f6e9809667fe535a4a0a2c4b9a4bba1c8 Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Fri, 26 Dec 2025 12:24:19 -0600 Subject: [PATCH 1/6] Fix bug in POSIX time zone calculations --- zoneinfo/src/posix.rs | 64 +++++++++++++++++++++++++++++++---------- zoneinfo/src/types.rs | 15 ++++++++++ zoneinfo/tests/posix.rs | 6 ++++ zoneinfo/tests/zoneinfo | 57 ++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/zoneinfo/src/posix.rs b/zoneinfo/src/posix.rs index ff1f7f467..df8821a07 100644 --- a/zoneinfo/src/posix.rs +++ b/zoneinfo/src/posix.rs @@ -136,22 +136,51 @@ pub enum PosixDate { } impl PosixDate { - pub(crate) fn from_rule(rule: &Rule) -> Self { + pub(crate) fn from_rule(rule: &Rule) -> (Self, i64) { match rule.on_date { - DayOfMonth::Day(day) if rule.in_month == Month::Jan || rule.in_month == Month::Feb => { - PosixDate::JulianNoLeap(month_to_day(rule.in_month as u8, 1) as u16 + day as u16) - } - DayOfMonth::Day(day) => { - PosixDate::JulianLeap(month_to_day(rule.in_month as u8, 1) as u16 + day as u16) - } - DayOfMonth::Last(wd) => PosixDate::MonthWeekDay(MonthWeekDay(rule.in_month, 5, wd)), + DayOfMonth::Day(day) if rule.in_month == Month::Jan || rule.in_month == Month::Feb => ( + PosixDate::JulianNoLeap(month_to_day(rule.in_month as u8, 1) as u16 + day as u16), + 0, + ), + DayOfMonth::Day(day) => ( + PosixDate::JulianLeap(month_to_day(rule.in_month as u8, 1) as u16 + day as u16), + 0, + ), + DayOfMonth::Last(wd) => ( + PosixDate::MonthWeekDay(MonthWeekDay(rule.in_month, 5, wd)), + 0, + ), DayOfMonth::WeekDayGEThanMonthDay(week_day, day_of_month) => { - let week = 1 + (day_of_month - 1) / 7; - PosixDate::MonthWeekDay(MonthWeekDay(rule.in_month, week, week_day)) + // Handle overflow day correctly (See America/Santiago) + let zero_based_day_of_month = day_of_month - 1; + let days_overflow = zero_based_day_of_month % 7; + let mut intermediate_week_day = week_day as i8 - days_overflow as i8; + let week = 1 + zero_based_day_of_month / 7; + if intermediate_week_day < 0 { + intermediate_week_day += 7; + } + let week_day = WeekDay::from_u8(intermediate_week_day as u8); + ( + PosixDate::MonthWeekDay(MonthWeekDay(rule.in_month, week, week_day)), + days_overflow as i64 * 86_400, + ) } DayOfMonth::WeekDayLEThanMonthDay(week_day, day_of_month) => { + // Handle overflow day correctly (See America/Santiago) + let days_overflow = day_of_month as i8 % 7; + let mut intermediate_week_day = week_day as i8 - days_overflow; let week = day_of_month / 7; - PosixDate::MonthWeekDay(MonthWeekDay(rule.in_month, week, week_day)) + if intermediate_week_day < 0 { + intermediate_week_day += 7; + } + ( + PosixDate::MonthWeekDay(MonthWeekDay( + rule.in_month, + week, + WeekDay::from_u8(intermediate_week_day as u8), + )), + days_overflow as i64 * 86_400, + ) } } } @@ -165,11 +194,16 @@ pub struct PosixDateTime { impl PosixDateTime { pub(crate) fn from_rule_and_transition_info(rule: &Rule, offset: Time, savings: Time) -> Self { - let date = PosixDate::from_rule(rule); + let (date, time_overflow) = PosixDate::from_rule(rule); let time = match rule.at { - QualifiedTime::Local(time) => time, - QualifiedTime::Standard(standard_time) => standard_time.add(rule.save), - QualifiedTime::Universal(universal_time) => universal_time.add(offset).add(savings), + QualifiedTime::Local(time) => time.add(Time::from_seconds(time_overflow)), + QualifiedTime::Standard(standard_time) => standard_time + .add(rule.save) + .add(Time::from_seconds(time_overflow)), + QualifiedTime::Universal(universal_time) => universal_time + .add(offset) + .add(savings) + .add(Time::from_seconds(time_overflow)), }; Self { date, time } } diff --git a/zoneinfo/src/types.rs b/zoneinfo/src/types.rs index 8c9080bc7..32ae0d6c8 100644 --- a/zoneinfo/src/types.rs +++ b/zoneinfo/src/types.rs @@ -444,6 +444,21 @@ pub enum WeekDay { Sat, } +impl WeekDay { + pub(crate) fn from_u8(value: u8) -> Self { + match value { + 0 => Self::Sun, + 1 => Self::Mon, + 2 => Self::Tues, + 3 => Self::Wed, + 4 => Self::Thurs, + 5 => Self::Fri, + 6 => Self::Sat, + _ => unreachable!("invalid week day value"), + } + } +} + impl TryFromStr for WeekDay { type Error = ZoneInfoParseError; fn try_from_str(s: &str, ctx: &mut LineParseContext) -> Result { diff --git a/zoneinfo/tests/posix.rs b/zoneinfo/tests/posix.rs index 133060f7e..5e8fe7bf2 100644 --- a/zoneinfo/tests/posix.rs +++ b/zoneinfo/tests/posix.rs @@ -40,4 +40,10 @@ fn posix_string_test() { let moscow_posix = zic.get_posix_time_zone("Europe/Moscow").unwrap(); assert_eq!(moscow_posix.to_string(), Ok("MSK-3".into())); + + let santiago_posix = zic.get_posix_time_zone("America/Santiago").unwrap(); + assert_eq!( + santiago_posix.to_string(), + Ok("<-04>4<-03>,M9.1.6/24,M4.1.6/24".into()) + ) } diff --git a/zoneinfo/tests/zoneinfo b/zoneinfo/tests/zoneinfo index cc3f26c37..922f8eb8c 100644 --- a/zoneinfo/tests/zoneinfo +++ b/zoneinfo/tests/zoneinfo @@ -435,4 +435,61 @@ Zone Europe/Riga 1:36:34 - LMT 1880 2:00 - EET 2001 Jan 2 2:00 EU EE%sT +# America/Santiago test case +# Rule NAME FROM TO - IN ON AT SAVE LETTER/S +Rule Chile 1927 1931 - Sep 1 0:00 1:00 - +Rule Chile 1928 1932 - Apr 1 0:00 0 - +Rule Chile 1968 only - Nov 3 4:00u 1:00 - +Rule Chile 1969 only - Mar 30 3:00u 0 - +Rule Chile 1969 only - Nov 23 4:00u 1:00 - +Rule Chile 1970 only - Mar 29 3:00u 0 - +Rule Chile 1971 only - Mar 14 3:00u 0 - +Rule Chile 1970 1972 - Oct Sun>=9 4:00u 1:00 - +Rule Chile 1972 1986 - Mar Sun>=9 3:00u 0 - +Rule Chile 1973 only - Sep 30 4:00u 1:00 - +Rule Chile 1974 1987 - Oct Sun>=9 4:00u 1:00 - +Rule Chile 1987 only - Apr 12 3:00u 0 - +Rule Chile 1988 1990 - Mar Sun>=9 3:00u 0 - +Rule Chile 1988 1989 - Oct Sun>=9 4:00u 1:00 - +Rule Chile 1990 only - Sep 16 4:00u 1:00 - +Rule Chile 1991 1996 - Mar Sun>=9 3:00u 0 - +Rule Chile 1991 1997 - Oct Sun>=9 4:00u 1:00 - +Rule Chile 1997 only - Mar 30 3:00u 0 - +Rule Chile 1998 only - Mar Sun>=9 3:00u 0 - +Rule Chile 1998 only - Sep 27 4:00u 1:00 - +Rule Chile 1999 only - Apr 4 3:00u 0 - +Rule Chile 1999 2010 - Oct Sun>=9 4:00u 1:00 - +Rule Chile 2000 2007 - Mar Sun>=9 3:00u 0 - +# N.B.: the end of March 29 in Chile is March 30 in Universal time, +# which is used below in specifying the transition. +Rule Chile 2008 only - Mar 30 3:00u 0 - +Rule Chile 2009 only - Mar Sun>=9 3:00u 0 - +Rule Chile 2010 only - Apr Sun>=1 3:00u 0 - +Rule Chile 2011 only - May Sun>=2 3:00u 0 - +Rule Chile 2011 only - Aug Sun>=16 4:00u 1:00 - +Rule Chile 2012 2014 - Apr Sun>=23 3:00u 0 - +Rule Chile 2012 2014 - Sep Sun>=2 4:00u 1:00 - +Rule Chile 2016 2018 - May Sun>=9 3:00u 0 - +Rule Chile 2016 2018 - Aug Sun>=9 4:00u 1:00 - +Rule Chile 2019 max - Apr Sun>=2 3:00u 0 - +Rule Chile 2019 2021 - Sep Sun>=2 4:00u 1:00 - +Rule Chile 2022 only - Sep Sun>=9 4:00u 1:00 - +Rule Chile 2023 max - Sep Sun>=2 4:00u 1:00 - +# IATA SSIM anomalies: (1992-02) says 1992-03-14; +# (1996-09) says 1998-03-08. Ignore these. +# Zone NAME STDOFF RULES FORMAT [UNTIL] +Zone America/Santiago -4:42:45 - LMT 1890 + -4:42:45 - SMT 1910 Jan 10 # Santiago Mean Time + -5:00 - %z 1916 Jul 1 + -4:42:45 - SMT 1918 Sep 10 + -4:00 - %z 1919 Jul 1 + -4:42:45 - SMT 1927 Sep 1 + -5:00 Chile %z 1932 Sep 1 + -4:00 - %z 1942 Jun 1 + -5:00 - %z 1942 Aug 1 + -4:00 - %z 1946 Jul 14 24:00 + -4:00 1:00 %z 1946 Aug 28 24:00 # central CL + -5:00 1:00 %z 1947 Mar 31 24:00 + -5:00 - %z 1947 May 21 23:00 + -4:00 Chile %z From fe0523d61be136300098b26a7504868a6641cd25 Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Fri, 26 Dec 2025 13:46:03 -0600 Subject: [PATCH 2/6] Comment the fix a bit further --- zoneinfo/src/posix.rs | 51 +++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/zoneinfo/src/posix.rs b/zoneinfo/src/posix.rs index df8821a07..126ce46de 100644 --- a/zoneinfo/src/posix.rs +++ b/zoneinfo/src/posix.rs @@ -151,35 +151,58 @@ impl PosixDate { 0, ), DayOfMonth::WeekDayGEThanMonthDay(week_day, day_of_month) => { - // Handle overflow day correctly (See America/Santiago) + // Handle week day offset correctly (See America/Santiago; i.e. Sun>=2) + // + // To do this for the GE case, we work with a zero based day of month, + // This ensures that day_of_month being 1 aligns with Sun = 0, for + // Sun>=1 purposes. + // + // The primary purpose for this approach as noted in zic.c is to support + // America/Santiago timestamps beyond 2038. + + // Calculate the days off the target weekday. let zero_based_day_of_month = day_of_month - 1; - let days_overflow = zero_based_day_of_month % 7; - let mut intermediate_week_day = week_day as i8 - days_overflow as i8; + let days_off_week_day = zero_based_day_of_month % 7; + // N.B., this could be a negative. If we look at Sun>=2, then this becomes + // 0 - 1. + let mut adjusted_week_day = week_day as i8 - days_off_week_day as i8; + + // Calculate what week we are in. + // + // Since we are operating with a zero based day of month, we add let week = 1 + zero_based_day_of_month / 7; - if intermediate_week_day < 0 { - intermediate_week_day += 7; + + // If we have shifted beyond the month, add 7 to shift back into the first + // week. + if adjusted_week_day < 0 { + adjusted_week_day += 7; } - let week_day = WeekDay::from_u8(intermediate_week_day as u8); + let week_day = WeekDay::from_u8(adjusted_week_day as u8); + // N.B. The left of time the target weekday becomes a time overflow added + // to the minutes. ( PosixDate::MonthWeekDay(MonthWeekDay(rule.in_month, week, week_day)), - days_overflow as i64 * 86_400, + days_off_week_day as i64 * 86_400, ) } DayOfMonth::WeekDayLEThanMonthDay(week_day, day_of_month) => { - // Handle overflow day correctly (See America/Santiago) - let days_overflow = day_of_month as i8 % 7; - let mut intermediate_week_day = week_day as i8 - days_overflow; + // Handle week day offset correctly + // + // We don't worry about the last day of the month in this scenario, which + // is the upper bound as that is handled by DayOfMonth::Last + let days_off_week_day = day_of_month as i8 % 7; + let mut adjusted_week_day = week_day as i8 - days_off_week_day; let week = day_of_month / 7; - if intermediate_week_day < 0 { - intermediate_week_day += 7; + if adjusted_week_day < 0 { + adjusted_week_day += 7; } ( PosixDate::MonthWeekDay(MonthWeekDay( rule.in_month, week, - WeekDay::from_u8(intermediate_week_day as u8), + WeekDay::from_u8(adjusted_week_day as u8), )), - days_overflow as i64 * 86_400, + days_off_week_day as i64 * 86_400, ) } } From 1be8af8af358c82ee3b5ec87b1601879459087fb Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Fri, 26 Dec 2025 14:00:26 -0600 Subject: [PATCH 3/6] Update the docs a bit more --- zoneinfo/src/posix.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/zoneinfo/src/posix.rs b/zoneinfo/src/posix.rs index 126ce46de..a72a2b5c2 100644 --- a/zoneinfo/src/posix.rs +++ b/zoneinfo/src/posix.rs @@ -136,6 +136,8 @@ pub enum PosixDate { } impl PosixDate { + /// Creates a [`PosixDate`] from a provided rule. This method returns both a posix date and an + /// integer, representing the days off the target weekday in seconds. pub(crate) fn from_rule(rule: &Rule) -> (Self, i64) { match rule.on_date { DayOfMonth::Day(day) if rule.in_month == Month::Jan || rule.in_month == Month::Feb => ( @@ -211,7 +213,11 @@ impl PosixDate { #[derive(Debug, PartialEq, Clone, Copy)] pub struct PosixDateTime { + /// The designated [`PosixDate`] pub date: PosixDate, + /// The local time for a [`PosixDateTime`] at which a transition occurs. + /// + /// N.B., this can be in the range of -167..=167 pub time: Time, } From ae948a823119f095567cc88bb7810ae64f32cd3d Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Fri, 26 Dec 2025 14:01:35 -0600 Subject: [PATCH 4/6] cargo fmt --- zoneinfo/src/posix.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zoneinfo/src/posix.rs b/zoneinfo/src/posix.rs index a72a2b5c2..b1ef4c277 100644 --- a/zoneinfo/src/posix.rs +++ b/zoneinfo/src/posix.rs @@ -156,7 +156,7 @@ impl PosixDate { // Handle week day offset correctly (See America/Santiago; i.e. Sun>=2) // // To do this for the GE case, we work with a zero based day of month, - // This ensures that day_of_month being 1 aligns with Sun = 0, for + // This ensures that day_of_month being 1 aligns with Sun = 0, for // Sun>=1 purposes. // // The primary purpose for this approach as noted in zic.c is to support @@ -171,7 +171,7 @@ impl PosixDate { // Calculate what week we are in. // - // Since we are operating with a zero based day of month, we add + // Since we are operating with a zero based day of month, we add let week = 1 + zero_based_day_of_month / 7; // If we have shifted beyond the month, add 7 to shift back into the first @@ -213,7 +213,7 @@ impl PosixDate { #[derive(Debug, PartialEq, Clone, Copy)] pub struct PosixDateTime { - /// The designated [`PosixDate`] + /// The designated [`PosixDate`] pub date: PosixDate, /// The local time for a [`PosixDateTime`] at which a transition occurs. /// From f639c42c2de8e2f9af233c6919ada9ef84de465e Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Fri, 26 Dec 2025 15:27:07 -0600 Subject: [PATCH 5/6] Apply feedback --- zoneinfo/src/posix.rs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/zoneinfo/src/posix.rs b/zoneinfo/src/posix.rs index b1ef4c277..a0a33a84e 100644 --- a/zoneinfo/src/posix.rs +++ b/zoneinfo/src/posix.rs @@ -161,13 +161,17 @@ impl PosixDate { // // The primary purpose for this approach as noted in zic.c is to support // America/Santiago timestamps beyond 2038. + // + // See the below link for more info. + // + // https://github.com/eggert/tz/commit/07351e0248b5a42151e49e4506bca0363c846f8c - // Calculate the days off the target weekday. + // Calculate the difference between the day of month and the week day. let zero_based_day_of_month = day_of_month - 1; - let days_off_week_day = zero_based_day_of_month % 7; + let week_day_from_dom = zero_based_day_of_month % 7; // N.B., this could be a negative. If we look at Sun>=2, then this becomes // 0 - 1. - let mut adjusted_week_day = week_day as i8 - days_off_week_day as i8; + let mut adjusted_week_day = week_day as i8 - week_day_from_dom as i8; // Calculate what week we are in. // @@ -184,7 +188,7 @@ impl PosixDate { // to the minutes. ( PosixDate::MonthWeekDay(MonthWeekDay(rule.in_month, week, week_day)), - days_off_week_day as i64 * 86_400, + week_day_from_dom as i64 * 86_400, ) } DayOfMonth::WeekDayLEThanMonthDay(week_day, day_of_month) => { @@ -192,8 +196,8 @@ impl PosixDate { // // We don't worry about the last day of the month in this scenario, which // is the upper bound as that is handled by DayOfMonth::Last - let days_off_week_day = day_of_month as i8 % 7; - let mut adjusted_week_day = week_day as i8 - days_off_week_day; + let week_day_from_dom = day_of_month as i8 % 7; + let mut adjusted_week_day = week_day as i8 - week_day_from_dom; let week = day_of_month / 7; if adjusted_week_day < 0 { adjusted_week_day += 7; @@ -204,7 +208,7 @@ impl PosixDate { week, WeekDay::from_u8(adjusted_week_day as u8), )), - days_off_week_day as i64 * 86_400, + week_day_from_dom as i64 * 86_400, ) } } @@ -225,15 +229,14 @@ impl PosixDateTime { pub(crate) fn from_rule_and_transition_info(rule: &Rule, offset: Time, savings: Time) -> Self { let (date, time_overflow) = PosixDate::from_rule(rule); let time = match rule.at { - QualifiedTime::Local(time) => time.add(Time::from_seconds(time_overflow)), + QualifiedTime::Local(time) => time, QualifiedTime::Standard(standard_time) => standard_time - .add(rule.save) - .add(Time::from_seconds(time_overflow)), + .add(rule.save), QualifiedTime::Universal(universal_time) => universal_time .add(offset) .add(savings) - .add(Time::from_seconds(time_overflow)), }; + let time = time.add(Time::from_seconds(time_overflow)); Self { date, time } } } From 92f8c5fa86e5202eefcb5973d7f1a5273daf5e1e Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Fri, 26 Dec 2025 15:30:44 -0600 Subject: [PATCH 6/6] cargo fmt --- zoneinfo/src/posix.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/zoneinfo/src/posix.rs b/zoneinfo/src/posix.rs index a0a33a84e..5be5f634b 100644 --- a/zoneinfo/src/posix.rs +++ b/zoneinfo/src/posix.rs @@ -230,11 +230,8 @@ impl PosixDateTime { let (date, time_overflow) = PosixDate::from_rule(rule); let time = match rule.at { QualifiedTime::Local(time) => time, - QualifiedTime::Standard(standard_time) => standard_time - .add(rule.save), - QualifiedTime::Universal(universal_time) => universal_time - .add(offset) - .add(savings) + QualifiedTime::Standard(standard_time) => standard_time.add(rule.save), + QualifiedTime::Universal(universal_time) => universal_time.add(offset).add(savings), }; let time = time.add(Time::from_seconds(time_overflow)); Self { date, time }