Commit ee26417b58d for php.net

commit ee26417b58d0d9f664151f27b3f89f795bc0786e
Author: Niels Dossche <7771979+ndossche@users.noreply.github.com>
Date:   Tue Dec 23 00:28:16 2025 +0100

    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.

    Closes GH-20764.

diff --git a/NEWS b/NEWS
index dd9fd9a2686..ab6d95471bd 100644
--- a/NEWS
+++ b/NEWS
@@ -18,6 +18,7 @@ PHP                                                                        NEWS
 - Date:
   . Fixed bug GH-20936 (DatePeriod::__set_state() cannot handle null start).
     (ndossche)
+  . Fix timezone offset with seconds losing precision. (ndossche)

 - DOM:
   . Fixed bug GH-21077 (Accessing Dom\Node::baseURI can throw TypeError).
diff --git a/ext/date/php_date.c b/ext/date/php_date.c
index a67a58bcf92..acdd612d04c 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,32 @@ 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 +1984,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 +2097,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 b23e950eafd..fff5766c7ff 100644
--- a/ext/date/tests/bug81565.phpt
+++ b/ext/date/tests/bug81565.phpt
@@ -15,6 +15,6 @@
 \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 00000000000..33963de91b2
--- /dev/null
+++ b/ext/date/tests/gh20764.phpt
@@ -0,0 +1,53 @@
+--TEST--
+GH-20764 (Timezone offset with seconds loses precision)
+--FILE--
+<?php
+
+$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());
+}
+
+?>
+--EXPECTF--
+--- 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"
+}
+--- 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"
+}