From 6ba058082ca2ad7555327b2567aebf07887c4bb6 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Mon, 1 Jun 2026 20:00:00 +0200 Subject: [PATCH] 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. --- src/items/builder.rs | 23 +++++++++++++---------- tests/date.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/items/builder.rs b/src/items/builder.rs index 3465178..bcffcd7 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -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 { 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) } diff --git a/tests/date.rs b/tests/date.rs index c57f782..1428adb 100644 --- a/tests/date.rs +++ b/tests/date.rs @@ -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::() + .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); +}