posix: fix handling of "permanent DST" POSIX time zone strings

See the comment in the code for explanation, but basically, this comes
from S 3.3.1 of RFC 9636:

> 3.3.1.  All-Year Daylight Saving Time
>
> DST is considered to be in effect all year if its UT offset is less
> than (i.e., west of) that of standard time, and it starts January 1
> at 00:00 and ends December 31 at 24:00 minus the difference between
> standard and daylight saving time, leaving no room for standard time
> in the calendar.  [POSIX] implies but does not explicitly state this,
> so it is spelled out here for clarity.
>
> Example: XXX3EDT4,0/0,J365/23

> This represents a time zone that is perpetually 4 hours west of UT
> and is abbreviated "EDT".  The "XXX" is ignored.

These odd time zones can occur when compiling TZif files using `zic`
with rearguard semantics. In particular, this applies for times at or
after 2087 in `Africa/Casablanca`. Rearguard semantics are used by
Jiff's `jiff-tzdb` crate, but aren't always otherwise used in standard
`/usr/share/zoneinfo` installations. As a result, this bug's effects
cannot always be consistently observed.

Fixes #386
This commit is contained in:
Andrew Gallant 2025-10-22 14:56:35 -04:00
parent 06564abdf4
commit 292f18640d
3 changed files with 154 additions and 2 deletions

View file

@ -32,6 +32,10 @@ Allow parsing just a `%s` into a `Zoned` via the `Etc/Unknown` time zone.
Bug fixes:
* [#386](https://github.com/BurntSushi/jiff/issues/386):
Fix a bug where `2087-12-31T23:00:00Z` in the `Africa/Casablanca` time zone
could not be round-tripped (because its offset was calculated incorrectly as
a result of not handling "permanent DST" POSIX time zones).
* [#407](https://github.com/BurntSushi/jiff/issues/407):
Fix a panic that occurred when parsing an empty string as a POSIX time zone.
* [#410](https://github.com/BurntSushi/jiff/issues/410):

View file

@ -242,7 +242,69 @@ impl<ABBREV: AsRef<str> + Debug> PosixTimeZone<ABBREV> {
dst.rule.start.to_datetime(year, self.std_offset.to_ioffset());
// DST time ends with respect to DST time, so offset it by the DST
// offset.
let end = dst.rule.end.to_datetime(year, dst.offset.to_ioffset());
let mut end = dst.rule.end.to_datetime(year, dst.offset.to_ioffset());
// This is a whacky special case when DST is permanent, but the math
// using to calculate the start/end datetimes ends up leaving a gap
// for standard time to appear. In which case, it's possible for a
// timestamp at the end of a calendar year to get standard time when
// it really should be DST.
//
// We detect this case by re-interpreting the end of the boundary using
// the standard offset. If we get a datetime that is in a different
// year, then it follows that standard time is actually impossible to
// occur.
//
// These weird POSIX time zones can occur as the TZ strings in
// a TZif file compiled using rearguard semantics. For example,
// `Africa/Casablanca` has:
//
// XXX-2<+01>-1,0/0,J365/23
//
// Notice here that DST is actually one hour *behind* (it is usually
// one hour *ahead*) _and_ it ends at 23:00:00 on the last day of the
// year. But if it ends at 23:00, then jumping to standard time moves
// the clocks *forward*. Which would bring us to 00:00:00 on the first
// of the next year... but that is when DST begins! Hence, DST is
// permanent.
//
// Ideally, this could just be handled by our math automatically. But
// I couldn't figure out how to make it work. In particular, in the
// above example for year 2087, we get
//
// start == 2087-01-01T00:00:00Z
// end == 2087-12-31T22:00:00Z
//
// Which leaves a two hour gap for a timestamp to get erroneously
// categorized as standard time.
//
// ... so we special case this. We could pre-compute whether a POSIX
// time zone is in permanent DST at construction time, but it's not
// obvious to me that it's worth it. Especially since this is an
// exceptionally rare case.
//
// Note that I did try to consult tzcode's (incredibly inscrutable)
// `localtime` implementation to figure out how they deal with it. At
// first, it looks like they don't have any special handling for this
// case. But looking more closely, they skip any time zone transitions
// generated by POSIX time zones whose rule spans more than 1 year:
//
// https://github.com/eggert/tz/blob/8d65db9786753f3b263087e31c59d191561d63e3/localtime.c#L1717-L1735
//
// By just ignoring them, I think it achieves the desired effect of
// permanent DST. But I'm not 100% confident in my understanding of
// the code.
if start.date.month == 1
&& start.date.day == 1
&& start.time == ITime::MIN
// NOTE: This should come last because it is potentially expensive.
&& year
!= end.saturating_add_seconds(self.std_offset.second).date.year
{
end = IDateTime {
date: IDate { year, month: 12, day: 31 },
time: ITime::MAX,
};
}
Some(DstInfo { dst, start, end })
}
@ -2536,6 +2598,18 @@ mod tests {
assert_eq!(tz.dst_info_utc(2024), Some(dst_info));
}
// See: https://github.com/BurntSushi/jiff/issues/386
#[test]
fn regression_permanent_dst() {
let tz = posix_time_zone("XXX-2<+01>-1,0/0,J365/23");
let dst_info = DstInfo {
dst: tz.dst.as_ref().unwrap(),
start: date(2087, 1, 1).at(0, 0, 0, 0),
end: date(2087, 12, 31).at(23, 59, 59, 999_999_999),
};
assert_eq!(tz.dst_info_utc(2087), Some(dst_info));
}
#[test]
fn reasonable() {
assert!(PosixTimeZone::parse(b"EST5").is_ok());

View file

@ -253,7 +253,69 @@ impl<ABBREV: AsRef<str> + Debug> PosixTimeZone<ABBREV> {
dst.rule.start.to_datetime(year, self.std_offset.to_ioffset());
// DST time ends with respect to DST time, so offset it by the DST
// offset.
let end = dst.rule.end.to_datetime(year, dst.offset.to_ioffset());
let mut end = dst.rule.end.to_datetime(year, dst.offset.to_ioffset());
// This is a whacky special case when DST is permanent, but the math
// using to calculate the start/end datetimes ends up leaving a gap
// for standard time to appear. In which case, it's possible for a
// timestamp at the end of a calendar year to get standard time when
// it really should be DST.
//
// We detect this case by re-interpreting the end of the boundary using
// the standard offset. If we get a datetime that is in a different
// year, then it follows that standard time is actually impossible to
// occur.
//
// These weird POSIX time zones can occur as the TZ strings in
// a TZif file compiled using rearguard semantics. For example,
// `Africa/Casablanca` has:
//
// XXX-2<+01>-1,0/0,J365/23
//
// Notice here that DST is actually one hour *behind* (it is usually
// one hour *ahead*) _and_ it ends at 23:00:00 on the last day of the
// year. But if it ends at 23:00, then jumping to standard time moves
// the clocks *forward*. Which would bring us to 00:00:00 on the first
// of the next year... but that is when DST begins! Hence, DST is
// permanent.
//
// Ideally, this could just be handled by our math automatically. But
// I couldn't figure out how to make it work. In particular, in the
// above example for year 2087, we get
//
// start == 2087-01-01T00:00:00Z
// end == 2087-12-31T22:00:00Z
//
// Which leaves a two hour gap for a timestamp to get erroneously
// categorized as standard time.
//
// ... so we special case this. We could pre-compute whether a POSIX
// time zone is in permanent DST at construction time, but it's not
// obvious to me that it's worth it. Especially since this is an
// exceptionally rare case.
//
// Note that I did try to consult tzcode's (incredibly inscrutable)
// `localtime` implementation to figure out how they deal with it. At
// first, it looks like they don't have any special handling for this
// case. But looking more closely, they skip any time zone transitions
// generated by POSIX time zones whose rule spans more than 1 year:
//
// https://github.com/eggert/tz/blob/8d65db9786753f3b263087e31c59d191561d63e3/localtime.c#L1717-L1735
//
// By just ignoring them, I think it achieves the desired effect of
// permanent DST. But I'm not 100% confident in my understanding of
// the code.
if start.date.month == 1
&& start.date.day == 1
&& start.time == ITime::MIN
// NOTE: This should come last because it is potentially expensive.
&& year
!= end.saturating_add_seconds(self.std_offset.second).date.year
{
end = IDateTime {
date: IDate { year, month: 12, day: 31 },
time: ITime::MAX,
};
}
Some(DstInfo { dst, start, end })
}
@ -2548,6 +2610,18 @@ mod tests {
assert_eq!(tz.dst_info_utc(2024), Some(dst_info));
}
// See: https://github.com/BurntSushi/jiff/issues/386
#[test]
fn regression_permanent_dst() {
let tz = posix_time_zone("XXX-2<+01>-1,0/0,J365/23");
let dst_info = DstInfo {
dst: tz.dst.as_ref().unwrap(),
start: date(2087, 1, 1).at(0, 0, 0, 0),
end: date(2087, 12, 31).at(23, 59, 59, 999_999_999),
};
assert_eq!(tz.dst_info_utc(2087), Some(dst_info));
}
#[test]
fn reasonable() {
assert!(PosixTimeZone::parse(b"EST5").is_ok());