From 343cdb6dce9679cd13421ce7a558c5ba058242ad Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+ndossche@users.noreply.github.com> Date: Tue, 23 Dec 2025 00:28:16 +0100 Subject: [PATCH 1/3] Fix timezone offset with seconds losing precision There are two issues: 1. The 'e' formatter doesn't output the seconds of the timezone even if it has seconds. 2. var_dump(), (array) cast, serialization, ... don't include the timezone second offset in the output. This means that, for example, serializing and then unserializing a date object loses the seconds of the timezone. This can be observed by comparing the output of getTimezone() for `$dt` vs the unserialized object in the provided test. --- ext/date/php_date.c | 85 +++++++++++++++++++----------------- ext/date/tests/bug81565.phpt | 2 +- ext/date/tests/gh20764.phpt | 28 ++++++++++++ 3 files changed, 74 insertions(+), 41 deletions(-) create mode 100644 ext/date/tests/gh20764.phpt diff --git a/ext/date/php_date.c b/ext/date/php_date.c index 6d0f0428f08dd..1046561a5cbce 100644 --- a/ext/date/php_date.c +++ b/ext/date/php_date.c @@ -795,13 +795,24 @@ static zend_string *date_format(const char *format, size_t format_len, timelib_t case TIMELIB_ZONETYPE_ABBR: length = slprintf(buffer, sizeof(buffer), "%s", offset->abbr); break; - case TIMELIB_ZONETYPE_OFFSET: - length = slprintf(buffer, sizeof(buffer), "%c%02d:%02d", - ((offset->offset < 0) ? '-' : '+'), - abs(offset->offset / 3600), - abs((offset->offset % 3600) / 60) - ); + case TIMELIB_ZONETYPE_OFFSET: { + int seconds = offset->offset % 60; + if (seconds == 0) { + length = slprintf(buffer, sizeof(buffer), "%c%02d:%02d", + ((offset->offset < 0) ? '-' : '+'), + abs(offset->offset / 3600), + abs((offset->offset % 3600) / 60) + ); + } else { + length = slprintf(buffer, sizeof(buffer), "%c%02d:%02d:%02d", + ((offset->offset < 0) ? '-' : '+'), + abs(offset->offset / 3600), + abs((offset->offset % 3600) / 60), + abs(seconds) + ); + } break; + } } } break; @@ -1930,6 +1941,30 @@ static HashTable *date_object_get_gc_timezone(zend_object *object, zval **table, return zend_std_get_properties(object); } /* }}} */ +static zend_string *date_create_tz_offset_str(timelib_sll offset) +{ + int seconds = offset % 60; + size_t size; + const char *format; + if (seconds == 0) { + size = sizeof("+05:00"); + format = "%c%02d:%02d"; + } else { + size = sizeof("+05:00:01"); + format = "%c%02d:%02d:%02d"; + } + zend_string *tmpstr = zend_string_alloc(size - 1, 0); + + /* Note: if seconds == 0, the seconds argument will be excessive and therefore ignored. */ + ZSTR_LEN(tmpstr) = snprintf(ZSTR_VAL(tmpstr), size, format, + offset < 0 ? '-' : '+', + abs((int)(offset / 3600)), + abs((int)(offset % 3600) / 60), + abs(seconds)); + + return tmpstr; +} + static void date_object_to_hash(php_date_obj *dateobj, HashTable *props) { zval zv; @@ -1947,17 +1982,8 @@ static void date_object_to_hash(php_date_obj *dateobj, HashTable *props) case TIMELIB_ZONETYPE_ID: ZVAL_STRING(&zv, dateobj->time->tz_info->name); break; - case TIMELIB_ZONETYPE_OFFSET: { - zend_string *tmpstr = zend_string_alloc(sizeof("UTC+05:00")-1, 0); - int utc_offset = dateobj->time->z; - - ZSTR_LEN(tmpstr) = snprintf(ZSTR_VAL(tmpstr), sizeof("+05:00"), "%c%02d:%02d", - utc_offset < 0 ? '-' : '+', - abs(utc_offset / 3600), - abs(((utc_offset % 3600) / 60))); - - ZVAL_NEW_STR(&zv, tmpstr); - } + case TIMELIB_ZONETYPE_OFFSET: + ZVAL_NEW_STR(&zv, date_create_tz_offset_str(dateobj->time->z)); break; case TIMELIB_ZONETYPE_ABBR: ZVAL_STRING(&zv, dateobj->time->tz_abbr); @@ -2069,29 +2095,8 @@ static void php_timezone_to_string(php_timezone_obj *tzobj, zval *zv) case TIMELIB_ZONETYPE_ID: ZVAL_STRING(zv, tzobj->tzi.tz->name); break; - case TIMELIB_ZONETYPE_OFFSET: { - timelib_sll utc_offset = tzobj->tzi.utc_offset; - int seconds = utc_offset % 60; - size_t size; - const char *format; - if (seconds == 0) { - size = sizeof("+05:00"); - format = "%c%02d:%02d"; - } else { - size = sizeof("+05:00:01"); - format = "%c%02d:%02d:%02d"; - } - zend_string *tmpstr = zend_string_alloc(size - 1, 0); - - /* Note: if seconds == 0, the seconds argument will be excessive and therefore ignored. */ - ZSTR_LEN(tmpstr) = snprintf(ZSTR_VAL(tmpstr), size, format, - utc_offset < 0 ? '-' : '+', - abs((int)(utc_offset / 3600)), - abs((int)(utc_offset % 3600) / 60), - abs(seconds)); - - ZVAL_NEW_STR(zv, tmpstr); - } + case TIMELIB_ZONETYPE_OFFSET: + ZVAL_NEW_STR(zv, date_create_tz_offset_str(tzobj->tzi.utc_offset)); break; case TIMELIB_ZONETYPE_ABBR: ZVAL_STRING(zv, tzobj->tzi.z.abbr); diff --git a/ext/date/tests/bug81565.phpt b/ext/date/tests/bug81565.phpt index b23e950eafdf6..fff5766c7ffe8 100644 --- a/ext/date/tests/bug81565.phpt +++ b/ext/date/tests/bug81565.phpt @@ -15,6 +15,6 @@ echo "\n", (new DatetimeZone('+01:45:30'))->getName(); \DateTime::__set_state(array( 'date' => '0021-08-21 00:00:00.000000', 'timezone_type' => 1, - 'timezone' => '+00:49', + 'timezone' => '+00:49:56', )) +01:45:30 diff --git a/ext/date/tests/gh20764.phpt b/ext/date/tests/gh20764.phpt new file mode 100644 index 0000000000000..2be8a729ae59d --- /dev/null +++ b/ext/date/tests/gh20764.phpt @@ -0,0 +1,28 @@ +--TEST-- +GH-20764 (Timezone offset with seconds loses precision) +--FILE-- +format('e')); +var_dump($dt); +var_dump(unserialize(serialize($dt))->getTimezone()); + +?> +--EXPECT-- +string(9) "+03:00:30" +object(DateTimeImmutable)#2 (3) { + ["date"]=> + string(26) "2025-04-01 00:00:00.000000" + ["timezone_type"]=> + int(1) + ["timezone"]=> + string(9) "+03:00:30" +} +object(DateTimeZone)#4 (2) { + ["timezone_type"]=> + int(1) + ["timezone"]=> + string(9) "+03:00:30" +} From 303b4ccfd9f6fc7ae8c7a4a25f1e5b4680c8007d Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+ndossche@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:43:22 +0100 Subject: [PATCH 2/3] code style --- ext/date/php_date.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ext/date/php_date.c b/ext/date/php_date.c index 1046561a5cbce..49ad8813c4cdb 100644 --- a/ext/date/php_date.c +++ b/ext/date/php_date.c @@ -1946,6 +1946,7 @@ static zend_string *date_create_tz_offset_str(timelib_sll offset) int seconds = offset % 60; size_t size; const char *format; + if (seconds == 0) { size = sizeof("+05:00"); format = "%c%02d:%02d"; @@ -1953,6 +1954,7 @@ static zend_string *date_create_tz_offset_str(timelib_sll offset) size = sizeof("+05:00:01"); format = "%c%02d:%02d:%02d"; } + zend_string *tmpstr = zend_string_alloc(size - 1, 0); /* Note: if seconds == 0, the seconds argument will be excessive and therefore ignored. */ From b3df6bb9cdf957f6917254de252f4f835ac2b3a6 Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+ndossche@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:45:05 +0100 Subject: [PATCH 3/3] test update --- ext/date/tests/gh20764.phpt | 41 +++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/ext/date/tests/gh20764.phpt b/ext/date/tests/gh20764.phpt index 2be8a729ae59d..33963de91b232 100644 --- a/ext/date/tests/gh20764.phpt +++ b/ext/date/tests/gh20764.phpt @@ -3,16 +3,25 @@ GH-20764 (Timezone offset with seconds loses precision) --FILE-- format('e')); -var_dump($dt); -var_dump(unserialize(serialize($dt))->getTimezone()); +$timezones = [ + '+03:00:30', + '-03:00:30', +]; + +foreach ($timezones as $timezone) { + echo "--- Testing timezone $timezone ---\n"; + $tz = new DateTimeZone($timezone); + $dt = new DateTimeImmutable('2025-04-01', $tz); + var_dump($dt->format('e')); + var_dump($dt); + var_dump(unserialize(serialize($dt))->getTimezone()); +} ?> ---EXPECT-- +--EXPECTF-- +--- Testing timezone +03:00:30 --- string(9) "+03:00:30" -object(DateTimeImmutable)#2 (3) { +object(DateTimeImmutable)#%d (3) { ["date"]=> string(26) "2025-04-01 00:00:00.000000" ["timezone_type"]=> @@ -20,9 +29,25 @@ object(DateTimeImmutable)#2 (3) { ["timezone"]=> string(9) "+03:00:30" } -object(DateTimeZone)#4 (2) { +object(DateTimeZone)#%d (2) { ["timezone_type"]=> int(1) ["timezone"]=> string(9) "+03:00:30" } +--- Testing timezone -03:00:30 --- +string(9) "-03:00:30" +object(DateTimeImmutable)#%d (3) { + ["date"]=> + string(26) "2025-04-01 00:00:00.000000" + ["timezone_type"]=> + int(1) + ["timezone"]=> + string(9) "-03:00:30" +} +object(DateTimeZone)#%d (2) { + ["timezone_type"]=> + int(1) + ["timezone"]=> + string(9) "-03:00:30" +}