builder: apply fixed offset before relative adjustments

A fixed offset (e.g. the "UTC" keyword) was applied after relative
items, so "1970/01/01 UTC N seconds" added N seconds to a local
wall-clock that could drift across a DST boundary before being
re-anchored at the offset, yielding an instant off by the DST gap.
Anchor the offset first, then apply relative items.

Fixes uutils/coreutils#12555.
This commit is contained in:
Sylvestre Ledru
2026-06-01 20:00:00 +02:00
parent 4b412e03d2
commit b83d7a953c
2 changed files with 41 additions and 10 deletions
+13 -10
View File
@@ -189,8 +189,8 @@ impl DateTimeBuilder {
/// - b. Apply time. If time carries an explicit numeric offset, apply the
/// offset before setting time.
/// - c. Apply weekday (e.g., "next Friday" or "last Monday").
/// - d. Apply relative adjustments (e.g., "+3 days", "-2 months").
/// - e. Apply final fixed offset if present.
/// - d. Apply fixed offset if present (anchors the instant).
/// - e. Apply relative adjustments (e.g., "+3 days", "-2 months").
pub(super) fn build(self) -> Result<ParsedDateTime, error::Error> {
if let Some(date) = self.date.as_ref() {
if date.year.unwrap_or(0) > 9999 {
@@ -317,7 +317,17 @@ impl DateTimeBuilder {
dt = dt.checked_add(Span::new().try_days(delta)?)?;
}
// 4d. Apply relative adjustments.
// 4d. Apply fixed offset. This anchors the instant before relative
// adjustments so that "1970/01/01 UTC N seconds" adds N seconds to the
// fixed-offset instant rather than to a local wall-clock that may drift
// across a DST boundary (see issue uutils/coreutils#12555).
if let Some(offset) = self.offset {
let (offset, hour_adjustment) = offset.normalize();
dt = dt.checked_add(Span::new().hours(hour_adjustment))?;
dt = dt.datetime().to_zoned((&offset).try_into()?)?;
}
// 4e. Apply relative adjustments.
for rel in self.relative {
dt = match rel {
relative::Relative::Years(_) | relative::Relative::Months(_) => {
@@ -338,13 +348,6 @@ impl DateTimeBuilder {
};
}
// 4e. Apply final fixed offset.
if let Some(offset) = self.offset {
let (offset, hour_adjustment) = offset.normalize();
dt = dt.checked_add(Span::new().hours(hour_adjustment))?;
dt = dt.datetime().to_zoned((&offset).try_into()?)?;
}
Ok(dt)
}
+28
View File
@@ -285,3 +285,31 @@ fn test_multiple_month_skip(#[case] base: &str, #[case] input: &str, #[case] exp
fn test_embedded_timezone(#[case] input: &str, #[case] expected: &str) {
check_absolute(input, expected);
}
// Regression test for uutils/coreutils#12555.
// A fixed offset (e.g. the "UTC" keyword) must anchor the instant *before*
// relative adjustments are applied. Otherwise, when the base zone observes DST,
// adding a large relative offset drifts the wall-clock across a DST boundary
// and re-anchoring at the fixed offset bakes in the extra hour.
// "1970-01-01 UTC + N seconds" must equal exactly N seconds after the epoch,
// regardless of the base zone's DST rules.
#[test]
fn test_utc_keyword_plus_relative_seconds_across_dst() {
// Base in a DST-observing zone (Europe/Berlin); the value only matters as
// the default zone for interpreting the date, not the result.
let base = "2020-06-15 12:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::get("Europe/Berlin").unwrap())
.unwrap();
// A timestamp landing in summer (CEST, UTC+2) while the epoch base is in
// winter (CET, UTC+1), so the DST offset differs between base and result.
let seconds = 1_780_318_971_i64;
let input = format!("1970-01-01 UTC {seconds} seconds");
let parsed = parse_datetime::parse_datetime_at_date(base, input)
.unwrap()
.expect_in_range();
assert_eq!(parsed.timestamp().as_second(), seconds);
}