diff --git a/ext/date/php_date.c b/ext/date/php_date.c index acdd612d04c82..c0ae4301ace6c 100644 --- a/ext/date/php_date.c +++ b/ext/date/php_date.c @@ -3288,6 +3288,7 @@ static bool php_date_modify(zval *object, char *modify, size_t modify_len) /* {{ php_date_obj *dateobj; timelib_time *tmp_time; timelib_error_container *err = NULL; + timelib_sll rel_h, rel_i, rel_s, rel_us; dateobj = Z_PHPDATE_P(object); @@ -3356,8 +3357,36 @@ static bool php_date_modify(zval *object, char *modify, size_t modify_len) /* {{ timelib_time_dtor(tmp_time); + /* do_adjust_relative() applies h/i/s as wall-clock, which breaks across + * DST. Strip them before timelib_update_ts and re-apply via SSE below. */ + rel_h = dateobj->time->relative.h; + rel_i = dateobj->time->relative.i; + rel_s = dateobj->time->relative.s; + rel_us = dateobj->time->relative.us; + dateobj->time->relative.h = 0; + dateobj->time->relative.i = 0; + dateobj->time->relative.s = 0; + dateobj->time->relative.us = 0; + timelib_update_ts(dateobj->time, NULL); timelib_update_from_sse(dateobj->time); + + /* Normalize microseconds: fold full seconds into rel_s, keep rel_us >= 0 */ + rel_s += rel_us / 1000000; + rel_us = rel_us % 1000000; + if (rel_us < 0) { + rel_s--; + rel_us += 1000000; + } + + dateobj->time->sse += timelib_hms_to_seconds(rel_h, rel_i, rel_s); + dateobj->time->us += rel_us; + if (dateobj->time->us >= 1000000) { + dateobj->time->us -= 1000000; + dateobj->time->sse++; + } + timelib_update_from_sse(dateobj->time); + dateobj->time->have_relative = 0; memset(&dateobj->time->relative, 0, sizeof(dateobj->time->relative)); diff --git a/ext/date/tests/date_modify-1.phpt b/ext/date/tests/date_modify-1.phpt index 665c899b23fbe..a002dd266ef27 100644 --- a/ext/date/tests/date_modify-1.phpt +++ b/ext/date/tests/date_modify-1.phpt @@ -25,4 +25,4 @@ Sun, 22 Aug 1993 00:00:00 +12 Sun, 27 Mar 2005 01:59:59 CET Sun, 27 Mar 2005 03:00:00 CEST Sun, 30 Oct 2005 01:59:59 CEST -Sun, 30 Oct 2005 03:00:00 CET +Sun, 30 Oct 2005 02:00:00 CET diff --git a/ext/date/tests/gh15880.phpt b/ext/date/tests/gh15880.phpt new file mode 100644 index 0000000000000..bbf508311ee82 --- /dev/null +++ b/ext/date/tests/gh15880.phpt @@ -0,0 +1,28 @@ +--TEST-- +Bug GH-15880 (DateTime::modify('+72 hours') incorrect across DST boundary) +--FILE-- + 01:00 CST. + * +72 hours from midnight Nov 1 must land at Nov 3 23:00 CST, not Nov 4 00:00. */ +date_default_timezone_set('America/Chicago'); +$tz = new DateTimeZone('America/Chicago'); +$start = new DateTimeImmutable('2024-11-01 00:00:00', $tz); + +/* modify and add must agree */ +echo $start->modify('+72 hours')->format('Y-m-d H:i:s T U'), "\n"; +echo $start->add(new DateInterval('PT72H'))->format('Y-m-d H:i:s T U'), "\n"; + +/* +3 days is calendar arithmetic -- it should land on midnight Nov 4 */ +echo $start->modify('+3 days')->format('Y-m-d H:i:s T U'), "\n"; + +/* -72 hours backward through fall-back: 73 real hours separate Nov 1 00:00 CDT + * from Nov 4 00:00 CST (the extra hour is the repeated hour), so -72h lands 1h + * ahead of Nov 1 midnight */ +$end = new DateTimeImmutable('2024-11-04 00:00:00', $tz); +echo $end->modify('-72 hours')->format('Y-m-d H:i:s T U'), "\n"; +?> +--EXPECT-- +2024-11-03 23:00:00 CST 1730696400 +2024-11-03 23:00:00 CST 1730696400 +2024-11-04 00:00:00 CST 1730700000 +2024-11-01 01:00:00 CDT 1730440800 diff --git a/ext/date/tests/gh21616.phpt b/ext/date/tests/gh21616.phpt new file mode 100644 index 0000000000000..08befe27b09ab --- /dev/null +++ b/ext/date/tests/gh21616.phpt @@ -0,0 +1,42 @@ +--TEST-- +Bug GH-21616 (DateTime::modify() does not respect DST transitions) +--FILE-- + 02:00 BST */ +$tz = new DateTimeZone('Europe/London'); + +/* +1s then -1s must round-trip */ +$dt = new DateTime('2025-03-30 00:59:59', $tz); +$dt->modify('+1 second'); +$dt->modify('-1 second'); +echo $dt->format('Y-m-d H:i:s T U'), "\n"; + +/* -1s from 02:00 BST must land at 00:59:59 GMT, not 02:59:59 BST */ +$dt2 = new DateTime('2025-03-30 02:00:00', $tz); +echo $dt2->modify('-1 second')->format('Y-m-d H:i:s T U'), "\n"; + +/* month + hours: +1 month lands before the DST boundary, so +1 hour is plain GMT */ +$dt3 = new DateTime('2025-02-28 00:30:00', $tz); +echo $dt3->modify('+1 month +1 hour')->format('Y-m-d H:i:s T U'), "\n"; + +/* first/last day of must still work */ +$base = new DateTimeImmutable('2025-03-15 10:00:00', $tz); +echo $base->modify('first day of next month')->format('Y-m-d H:i:s T'), "\n"; +echo $base->modify('last day of this month')->format('Y-m-d H:i:s T'), "\n"; + +/* +61 minutes from just before the gap -- minutes must also count as elapsed time */ +$dt4 = new DateTime('2025-03-30 00:59:00', $tz); +echo $dt4->modify('+61 minutes')->format('Y-m-d H:i:s T U'), "\n"; + +/* DateTimeImmutable must behave the same as mutable DateTime */ +$dt5 = new DateTimeImmutable('2025-03-30 02:00:00', $tz); +echo $dt5->modify('-1 second')->format('Y-m-d H:i:s T U'), "\n"; +?> +--EXPECT-- +2025-03-30 00:59:59 GMT 1743296399 +2025-03-30 00:59:59 GMT 1743296399 +2025-03-28 01:30:00 GMT 1743125400 +2025-04-01 10:00:00 BST +2025-03-31 10:00:00 BST +2025-03-30 03:00:00 BST 1743300000 +2025-03-30 00:59:59 GMT 1743296399