From 6fd40f7883429bba5b3a8e1869baa365b1e491b9 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Mon, 26 Oct 2026 11:41:28 +0100 Subject: [PATCH 1/3] Fix GH-24735: Resolve repeated local times consistently --- ext/date/lib/timelib.h | 1 + ext/date/lib/tm2unixtime.c | 89 ++++++++++++++----- ext/date/php_date.c | 4 +- .../tests/DateTime_setTime_dst_overlap.phpt | 37 ++++++++ 4 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 ext/date/tests/DateTime_setTime_dst_overlap.phpt diff --git a/ext/date/lib/timelib.h b/ext/date/lib/timelib.h index d08aa77ea5e5..0a10e41e51eb 100644 --- a/ext/date/lib/timelib.h +++ b/ext/date/lib/timelib.h @@ -641,6 +641,7 @@ void timelib_strtointerval(const char *s, size_t len, * calculate the right timestamp. */ void timelib_update_ts(timelib_time* time, timelib_tzinfo* tzi); +void timelib_update_ts_first(timelib_time* time, timelib_tzinfo* tzi); /** * Returns the number of days from the y/m/d fields of 'time' since the Unix diff --git a/ext/date/lib/tm2unixtime.c b/ext/date/lib/tm2unixtime.c index 4c0252e244dd..cf7eb7af8412 100644 --- a/ext/date/lib/tm2unixtime.c +++ b/ext/date/lib/tm2unixtime.c @@ -338,7 +338,44 @@ static void do_adjust_special_early(timelib_time* time) timelib_do_normalize(time); } -static void do_adjust_timezone(timelib_time *tz, timelib_tzinfo *tzi) +static bool timelib_adjust_timezone_to_earlier(timelib_time *tz, timelib_tzinfo *tzi) +{ + timelib_sll earlier_sse = INT64_MAX; + timelib_sll second_earliest_sse = INT64_MAX; + uint64_t type_count = tzi->bit64.typecnt; + + for (uint64_t i = 0; i < type_count; i++) { + int32_t offset = tzi->type[i].offset; + int32_t actual_offset; + + if ((offset > 0 && tz->sse < INT64_MIN + offset) || (offset < 0 && tz->sse > INT64_MAX + offset)) { + continue; + } + + timelib_sll candidate_sse = tz->sse - offset; + if (!timelib_get_time_zone_offset_info(candidate_sse, tzi, &actual_offset, NULL, NULL) || actual_offset != offset) { + continue; + } + + if (candidate_sse < earlier_sse) { + second_earliest_sse = earlier_sse; + earlier_sse = candidate_sse; + } else if (candidate_sse > earlier_sse && candidate_sse < second_earliest_sse) { + second_earliest_sse = candidate_sse; + } + } + + if (second_earliest_sse == INT64_MAX) { + return false; + } + + tz->sse = earlier_sse; + tz->is_localtime = 1; + timelib_set_timezone(tz, tzi); + return true; +} + +static void do_adjust_timezone(timelib_time *tz, timelib_tzinfo *tzi, bool prefer_earlier) { switch (tz->zone_type) { case TIMELIB_ZONETYPE_OFFSET: @@ -373,33 +410,31 @@ static void do_adjust_timezone(timelib_time *tz, timelib_tzinfo *tzi) if (!tzi) { return; } + if (prefer_earlier && timelib_adjust_timezone_to_earlier(tz, tzi)) { + return; + } timelib_get_time_zone_offset_info(tz->sse, tzi, ¤t_offset, ¤t_transition_time, ¤t_is_dst); timelib_get_time_zone_offset_info(tz->sse - current_offset, tzi, &after_offset, &after_transition_time, NULL); actual_offset = after_offset; actual_transition_time = after_transition_time; if (current_offset == after_offset && tz->have_zone) { - /* Make sure we're not missing a DST change because we don't know the actual offset yet */ if (current_offset >= 0 && tz->dst && !current_is_dst) { - /* Timezone or its DST at or east of UTC, so the local time, interpreted as UTC, leaves DST (as defined in the actual timezone) before the actual local time */ - int32_t earlier_offset; - timelib_sll earlier_transition_time; - timelib_get_time_zone_offset_info(tz->sse - current_offset - 7200, tzi, &earlier_offset, &earlier_transition_time, NULL); - if ((earlier_offset != after_offset) && (tz->sse - earlier_offset < after_transition_time)) { - /* Looking behind a bit clarified the actual offset to use */ - actual_offset = earlier_offset; - actual_transition_time = earlier_transition_time; - } + int32_t earlier_offset; + timelib_sll earlier_transition_time; + timelib_get_time_zone_offset_info(tz->sse - current_offset - 7200, tzi, &earlier_offset, &earlier_transition_time, NULL); + if ((earlier_offset != after_offset) && (tz->sse - earlier_offset < after_transition_time)) { + actual_offset = earlier_offset; + actual_transition_time = earlier_transition_time; + } } else if (current_offset <= 0 && current_is_dst && !tz->dst) { - /* Timezone west of UTC, so the local time, interpreted as UTC, leaves DST (as defined in the actual timezone) after the actual local time */ - int32_t later_offset; - timelib_sll later_transition_time; - timelib_get_time_zone_offset_info(tz->sse - current_offset + 7200, tzi, &later_offset, &later_transition_time, NULL); - if ((later_offset != after_offset) && (tz->sse - later_offset >= later_transition_time)) { - /* Looking ahead a bit clarified the actual offset to use */ - actual_offset = later_offset; - actual_transition_time = later_transition_time; - } + int32_t later_offset; + timelib_sll later_transition_time; + timelib_get_time_zone_offset_info(tz->sse - current_offset + 7200, tzi, &later_offset, &later_transition_time, NULL); + if ((later_offset != after_offset) && (tz->sse - later_offset >= later_transition_time)) { + actual_offset = later_offset; + actual_transition_time = later_transition_time; + } } } @@ -439,7 +474,7 @@ timelib_sll timelib_epoch_days_from_time(timelib_time *time) return era * DAYS_PER_ERA + day_of_era - HINNANT_EPOCH_SHIFT; } -void timelib_update_ts(timelib_time* time, timelib_tzinfo* tzi) +static void timelib_update_ts_internal(timelib_time* time, timelib_tzinfo* tzi, bool prefer_earlier) { do_adjust_special_early(time); do_adjust_relative(time); @@ -456,12 +491,22 @@ void timelib_update_ts(timelib_time* time, timelib_tzinfo* tzi) time->sse += timelib_epoch_days_from_time(time) * (SECS_PER_DAY / 2); // This modifies time->sse, if needed - do_adjust_timezone(time, tzi); + do_adjust_timezone(time, tzi, prefer_earlier); time->sse_uptodate = 1; time->have_relative = time->relative.have_weekday_relative = time->relative.have_special_relative = time->relative.first_last_day_of = 0; } +void timelib_update_ts(timelib_time* time, timelib_tzinfo* tzi) +{ + timelib_update_ts_internal(time, tzi, false); +} + +void timelib_update_ts_first(timelib_time* time, timelib_tzinfo* tzi) +{ + timelib_update_ts_internal(time, tzi, true); +} + #if 0 int main(void) { diff --git a/ext/date/php_date.c b/ext/date/php_date.c index 33a6c5b3ccfc..c924082d06e4 100644 --- a/ext/date/php_date.c +++ b/ext/date/php_date.c @@ -3332,7 +3332,7 @@ static bool php_date_modify(zval *object, char *modify, size_t modify_len) /* {{ timelib_time_dtor(tmp_time); - timelib_update_ts(dateobj->time, NULL); + timelib_update_ts_first(dateobj->time, NULL); timelib_update_from_sse(dateobj->time); dateobj->time->have_relative = 0; memset(&dateobj->time->relative, 0, sizeof(dateobj->time->relative)); @@ -3691,7 +3691,7 @@ static void php_date_time_set(zval *object, zend_long h, zend_long i, zend_long dateobj->time->i = i; dateobj->time->s = s; dateobj->time->us = ms; - timelib_update_ts(dateobj->time, NULL); + timelib_update_ts_first(dateobj->time, NULL); timelib_update_from_sse(dateobj->time); } /* }}} */ diff --git a/ext/date/tests/DateTime_setTime_dst_overlap.phpt b/ext/date/tests/DateTime_setTime_dst_overlap.phpt new file mode 100644 index 000000000000..ab84a7de36c3 --- /dev/null +++ b/ext/date/tests/DateTime_setTime_dst_overlap.phpt @@ -0,0 +1,37 @@ +--TEST-- +DateTime::setTime() selects the first occurrence of an overlapping local time +--FILE-- +setTimezone(new DateTimeZone($timezone)); + $result = $date->setTime($hour, $minute, 59); + printf("%s %s %+d\n", $timezone, $result->format('H:i:s T'), $result->getTimestamp() - $date->getTimestamp()); +} + +$date = new DateTime('2026-10-25 01:10:30 UTC'); +$date->setTimezone(new DateTimeZone('Europe/Dublin')); +$date->setTime(1, 10, 59); +printf("%s %+d\n", $date->format('H:i:s T'), $date->getTimestamp() - strtotime('2026-10-25 01:10:30 UTC')); + +$date = new DateTimeImmutable('2026-10-25 00:10:30 UTC'); +$date = $date->setTimezone(new DateTimeZone('Europe/Dublin')); +$result = $date->modify('+29 seconds'); +printf("%s %+d\n", $result->format('H:i:s T'), $result->getTimestamp() - $date->getTimestamp()); + +?> +--EXPECT-- +Europe/Dublin 01:10:59 IST +29 +Europe/London 01:10:59 BST +29 +Europe/Amsterdam 02:10:59 CEST +29 +America/New_York 01:10:59 EDT +29 +Australia/Lord_Howe 01:45:59 +11 +29 +01:10:59 IST -3571 +01:10:59 IST +29 From 0a069d69d247ab1f5d2275979d57cd5471bbbbd5 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Tue, 27 Oct 2026 15:08:25 +0100 Subject: [PATCH 2/3] Preserve timezone offset in repeated local times --- ext/date/lib/tm2unixtime.c | 19 +++++++++++++------ .../tests/DateTime_setTime_dst_overlap.phpt | 16 ++++++++++++++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/ext/date/lib/tm2unixtime.c b/ext/date/lib/tm2unixtime.c index cf7eb7af8412..9df9dbd1391e 100644 --- a/ext/date/lib/tm2unixtime.c +++ b/ext/date/lib/tm2unixtime.c @@ -338,10 +338,12 @@ static void do_adjust_special_early(timelib_time* time) timelib_do_normalize(time); } -static bool timelib_adjust_timezone_to_earlier(timelib_time *tz, timelib_tzinfo *tzi) +static bool timelib_adjust_timezone_for_overlap(timelib_time *tz, timelib_tzinfo *tzi) { timelib_sll earlier_sse = INT64_MAX; timelib_sll second_earliest_sse = INT64_MAX; + timelib_sll matching_offset_sse = INT64_MAX; + int32_t preferred_offset = tz->z; uint64_t type_count = tzi->bit64.typecnt; for (uint64_t i = 0; i < type_count; i++) { @@ -363,19 +365,24 @@ static bool timelib_adjust_timezone_to_earlier(timelib_time *tz, timelib_tzinfo } else if (candidate_sse > earlier_sse && candidate_sse < second_earliest_sse) { second_earliest_sse = candidate_sse; } + + if (offset == preferred_offset && candidate_sse < matching_offset_sse) { + matching_offset_sse = candidate_sse; + } } if (second_earliest_sse == INT64_MAX) { return false; } - tz->sse = earlier_sse; + /* Keep the previous offset if it is valid for the new local time. */ + tz->sse = matching_offset_sse != INT64_MAX ? matching_offset_sse : earlier_sse; tz->is_localtime = 1; timelib_set_timezone(tz, tzi); return true; } -static void do_adjust_timezone(timelib_time *tz, timelib_tzinfo *tzi, bool prefer_earlier) +static void do_adjust_timezone(timelib_time *tz, timelib_tzinfo *tzi, bool resolve_overlap) { switch (tz->zone_type) { case TIMELIB_ZONETYPE_OFFSET: @@ -410,7 +417,7 @@ static void do_adjust_timezone(timelib_time *tz, timelib_tzinfo *tzi, bool prefe if (!tzi) { return; } - if (prefer_earlier && timelib_adjust_timezone_to_earlier(tz, tzi)) { + if (resolve_overlap && timelib_adjust_timezone_for_overlap(tz, tzi)) { return; } @@ -474,7 +481,7 @@ timelib_sll timelib_epoch_days_from_time(timelib_time *time) return era * DAYS_PER_ERA + day_of_era - HINNANT_EPOCH_SHIFT; } -static void timelib_update_ts_internal(timelib_time* time, timelib_tzinfo* tzi, bool prefer_earlier) +static void timelib_update_ts_internal(timelib_time* time, timelib_tzinfo* tzi, bool resolve_overlap) { do_adjust_special_early(time); do_adjust_relative(time); @@ -491,7 +498,7 @@ static void timelib_update_ts_internal(timelib_time* time, timelib_tzinfo* tzi, time->sse += timelib_epoch_days_from_time(time) * (SECS_PER_DAY / 2); // This modifies time->sse, if needed - do_adjust_timezone(time, tzi, prefer_earlier); + do_adjust_timezone(time, tzi, resolve_overlap); time->sse_uptodate = 1; time->have_relative = time->relative.have_weekday_relative = time->relative.have_special_relative = time->relative.first_last_day_of = 0; diff --git a/ext/date/tests/DateTime_setTime_dst_overlap.phpt b/ext/date/tests/DateTime_setTime_dst_overlap.phpt index ab84a7de36c3..ecf3d3f62034 100644 --- a/ext/date/tests/DateTime_setTime_dst_overlap.phpt +++ b/ext/date/tests/DateTime_setTime_dst_overlap.phpt @@ -1,5 +1,5 @@ --TEST-- -DateTime::setTime() selects the first occurrence of an overlapping local time +DateTime::setTime() preserves the offset in an overlapping local time --FILE-- setTimezone(new DateTimeZone('Europe/Dublin')); $date->setTime(1, 10, 59); printf("%s %+d\n", $date->format('H:i:s T'), $date->getTimestamp() - strtotime('2026-10-25 01:10:30 UTC')); +$date = new DateTimeImmutable('2026-10-25 01:10:30 UTC'); +$date = $date->setTimezone(new DateTimeZone('Europe/Dublin')); +$result = $date->setTime(1, 10, 59); +printf("%s %+d\n", $result->format('H:i:s T'), $result->getTimestamp() - $date->getTimestamp()); + +$date = new DateTimeImmutable('2026-10-25 01:10:30 UTC'); +$date = $date->setTimezone(new DateTimeZone('Europe/London')); +$result = $date->modify('+29 seconds'); +printf("%s %+d\n", $result->format('H:i:s T'), $result->getTimestamp() - $date->getTimestamp()); + $date = new DateTimeImmutable('2026-10-25 00:10:30 UTC'); $date = $date->setTimezone(new DateTimeZone('Europe/Dublin')); $result = $date->modify('+29 seconds'); @@ -33,5 +43,7 @@ Europe/London 01:10:59 BST +29 Europe/Amsterdam 02:10:59 CEST +29 America/New_York 01:10:59 EDT +29 Australia/Lord_Howe 01:45:59 +11 +29 -01:10:59 IST -3571 +01:10:59 GMT +29 +01:10:59 GMT +29 +01:10:59 IST +29 01:10:59 IST +29 From 0def4f7b766c0136d37a8212d8164c75a043b805 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Tue, 27 Oct 2026 19:34:25 +0100 Subject: [PATCH 3/3] Preserve timezone offset when modifying dates --- ext/date/lib/tm2unixtime.c | 14 +++++++++----- ext/date/php_date.c | 3 +++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ext/date/lib/tm2unixtime.c b/ext/date/lib/tm2unixtime.c index 9df9dbd1391e..adf8595ba113 100644 --- a/ext/date/lib/tm2unixtime.c +++ b/ext/date/lib/tm2unixtime.c @@ -338,12 +338,11 @@ static void do_adjust_special_early(timelib_time* time) timelib_do_normalize(time); } -static bool timelib_adjust_timezone_for_overlap(timelib_time *tz, timelib_tzinfo *tzi) +static bool timelib_adjust_timezone_for_overlap(timelib_time *tz, timelib_tzinfo *tzi, int32_t preferred_offset) { timelib_sll earlier_sse = INT64_MAX; timelib_sll second_earliest_sse = INT64_MAX; timelib_sll matching_offset_sse = INT64_MAX; - int32_t preferred_offset = tz->z; uint64_t type_count = tzi->bit64.typecnt; for (uint64_t i = 0; i < type_count; i++) { @@ -382,7 +381,7 @@ static bool timelib_adjust_timezone_for_overlap(timelib_time *tz, timelib_tzinfo return true; } -static void do_adjust_timezone(timelib_time *tz, timelib_tzinfo *tzi, bool resolve_overlap) +static void do_adjust_timezone(timelib_time *tz, timelib_tzinfo *tzi, bool resolve_overlap, int32_t preferred_offset) { switch (tz->zone_type) { case TIMELIB_ZONETYPE_OFFSET: @@ -417,7 +416,7 @@ static void do_adjust_timezone(timelib_time *tz, timelib_tzinfo *tzi, bool resol if (!tzi) { return; } - if (resolve_overlap && timelib_adjust_timezone_for_overlap(tz, tzi)) { + if (resolve_overlap && timelib_adjust_timezone_for_overlap(tz, tzi, preferred_offset)) { return; } @@ -483,6 +482,11 @@ timelib_sll timelib_epoch_days_from_time(timelib_time *time) static void timelib_update_ts_internal(timelib_time* time, timelib_tzinfo* tzi, bool resolve_overlap) { + int32_t preferred_offset = time->z; + if (time->sse_uptodate && time->zone_type == TIMELIB_ZONETYPE_ID && time->tz_info) { + timelib_get_time_zone_offset_info(time->sse, time->tz_info, &preferred_offset, NULL, NULL); + } + do_adjust_special_early(time); do_adjust_relative(time); do_adjust_special(time); @@ -498,7 +502,7 @@ static void timelib_update_ts_internal(timelib_time* time, timelib_tzinfo* tzi, time->sse += timelib_epoch_days_from_time(time) * (SECS_PER_DAY / 2); // This modifies time->sse, if needed - do_adjust_timezone(time, tzi, resolve_overlap); + do_adjust_timezone(time, tzi, resolve_overlap, preferred_offset); time->sse_uptodate = 1; time->have_relative = time->relative.have_weekday_relative = time->relative.have_special_relative = time->relative.first_last_day_of = 0; diff --git a/ext/date/php_date.c b/ext/date/php_date.c index c924082d06e4..7d62d69614e7 100644 --- a/ext/date/php_date.c +++ b/ext/date/php_date.c @@ -3289,6 +3289,9 @@ static bool php_date_modify(zval *object, char *modify, size_t modify_len) /* {{ memcpy(&dateobj->time->relative, &tmp_time->relative, sizeof(timelib_rel_time)); dateobj->time->have_relative = tmp_time->have_relative; + if (dateobj->time->sse_uptodate && dateobj->time->zone_type == TIMELIB_ZONETYPE_ID && dateobj->time->tz_info) { + timelib_get_time_zone_offset_info(dateobj->time->sse, dateobj->time->tz_info, &dateobj->time->z, NULL, NULL); + } dateobj->time->sse_uptodate = 0; if (tmp_time->y != TIMELIB_UNSET) {