Commit 913e01c1598 for php

commit 913e01c1598ae7dadc6a0ff4fc06a4784fd23a11
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Fri Sep 25 04:16:57 2026 -0400

    ext/intl: Use byte offsets in IntlDateFormatter parsing

    IntlDateFormatter::parse(), localtime() and parseToCalendar() passed the
    PHP byte offset to ICU, which expects a UTF-16 code-unit position, and
    returned ICU's position unchanged. With multibyte text before the date,
    parsing started at the wrong place and the returned offset was too
    small. Convert the offset in both directions as GH-23094 did for
    NumberFormatter, and reject an offset that splits a UTF-8 sequence. A
    returned position inside a surrogate pair, which a partially matched
    literal can produce, maps to the start of that character. The offset
    helpers move into intl_convert.c and NumberFormatter now uses them too.

    Closes GH-24126

diff --git a/NEWS b/NEWS
index 9ccefd5fddb..86683d7b62a 100644
--- a/NEWS
+++ b/NEWS
@@ -67,6 +67,9 @@ PHP                                                                        NEWS
     (DanielEScherzer, vapier)

 - Intl:
+  . Fixed IntlDateFormatter::parse(), localtime() and parseToCalendar()
+    treating the offset as UTF-16 code units instead of bytes.
+    (Ilia Alshanetsky)
   . Fixed cloning IntlDateFormatter and MessageFormatter losing PHP-side state
     such as dateType, timeType, calendar and the message pattern.
     (Ilia Alshanetsky)
diff --git a/ext/intl/dateformat/dateformat_parse.c b/ext/intl/dateformat/dateformat_parse.c
index 2bdde08bcac..2199da36915 100644
--- a/ext/intl/dateformat/dateformat_parse.c
+++ b/ext/intl/dateformat/dateformat_parse.c
@@ -41,10 +41,19 @@ static void internal_parse_to_timestamp(IntlDateFormatter_object *dfo, char* tex
 	/* Convert timezone to UTF-16. */
 	intl_convert_utf8_to_utf16(&text_utf16, &text_utf16_len, text_to_parse, text_len, &INTL_DATA_ERROR_CODE(dfo));
 	INTL_METHOD_CHECK_STATUS(dfo, "Error converting timezone to UTF-16" );
+	if (parse_pos && !intl_convert_utf8_offset_to_utf16(text_to_parse, text_len, parse_pos, &INTL_DATA_ERROR_CODE(dfo))) {
+		if (text_utf16) {
+			efree(text_utf16);
+		}
+		INTL_METHOD_CHECK_STATUS(dfo, "Invalid UTF-8 offset" );
+	}

 	if (UNEXPECTED(update_calendar)) {
 		UCalendar *parsed_calendar = (UCalendar *)udat_getCalendar(DATE_FORMAT_OBJECT(dfo));
 		udat_parseCalendar(DATE_FORMAT_OBJECT(dfo), parsed_calendar, text_utf16, text_utf16_len, parse_pos, &INTL_DATA_ERROR_CODE(dfo));
+		if (parse_pos) {
+			*parse_pos = intl_convert_utf16_offset_to_utf8(text_utf16, text_utf16_len, *parse_pos);
+		}
 		if (text_utf16) {
 			efree(text_utf16);
 		}
@@ -52,6 +61,9 @@ static void internal_parse_to_timestamp(IntlDateFormatter_object *dfo, char* tex
 		timestamp = ucal_getMillis( parsed_calendar, &INTL_DATA_ERROR_CODE(dfo));
 	} else {
 		timestamp = udat_parse(DATE_FORMAT_OBJECT(dfo), text_utf16, text_utf16_len, parse_pos, &INTL_DATA_ERROR_CODE(dfo));
+		if (parse_pos) {
+			*parse_pos = intl_convert_utf16_offset_to_utf8(text_utf16, text_utf16_len, *parse_pos);
+		}
 		if (text_utf16) {
 			efree(text_utf16);
 		}
@@ -95,9 +107,18 @@ static void internal_parse_to_localtime(IntlDateFormatter_object *dfo, char* tex
 	/* Convert timezone to UTF-16. */
 	intl_convert_utf8_to_utf16(&text_utf16, &text_utf16_len, text_to_parse, text_len, &INTL_DATA_ERROR_CODE(dfo));
 	INTL_METHOD_CHECK_STATUS(dfo, "Error converting timezone to UTF-16" );
+	if (parse_pos && !intl_convert_utf8_offset_to_utf16(text_to_parse, text_len, parse_pos, &INTL_DATA_ERROR_CODE(dfo))) {
+		if (text_utf16) {
+			efree(text_utf16);
+		}
+		INTL_METHOD_CHECK_STATUS(dfo, "Invalid UTF-8 offset" );
+	}

 	parsed_calendar = (UCalendar *)udat_getCalendar(DATE_FORMAT_OBJECT(dfo));
 	udat_parseCalendar( DATE_FORMAT_OBJECT(dfo), parsed_calendar, text_utf16, text_utf16_len, parse_pos, &INTL_DATA_ERROR_CODE(dfo));
+	if (parse_pos) {
+		*parse_pos = intl_convert_utf16_offset_to_utf8(text_utf16, text_utf16_len, *parse_pos);
+	}

 	if (text_utf16) {
 		efree(text_utf16);
diff --git a/ext/intl/formatter/formatter_parse.c b/ext/intl/formatter/formatter_parse.c
index 2c5ac3222f3..04372c7890e 100644
--- a/ext/intl/formatter/formatter_parse.c
+++ b/ext/intl/formatter/formatter_parse.c
@@ -27,42 +27,6 @@

 #define ICU_LOCALE_BUG 1

-static bool numfmt_utf8_offset_to_utf16(const char *str, size_t str_len, int32_t *position, UErrorCode *status)
-{
-	int32_t utf16_position;
-
-	if (*position < 0 || (size_t) *position > str_len) {
-		return true;
-	}
-
-	*status = U_ZERO_ERROR;
-	u_strFromUTF8(NULL, 0, &utf16_position, str, *position, status);
-	if (*status != U_BUFFER_OVERFLOW_ERROR && U_FAILURE(*status)) {
-		return false;
-	}
-	*status = U_ZERO_ERROR;
-
-	*position = utf16_position;
-	return true;
-}
-
-static int32_t numfmt_utf16_offset_to_utf8(const UChar *str, int32_t str_len, int32_t position)
-{
-	int32_t utf8_position;
-	UErrorCode status = U_ZERO_ERROR;
-
-	if (position < 0 || position > str_len) {
-		return position;
-	}
-
-	u_strToUTF8(NULL, 0, &utf8_position, str, position, &status);
-	if (status != U_BUFFER_OVERFLOW_ERROR && U_FAILURE(status)) {
-		return position;
-	}
-
-	return utf8_position;
-}
-
 /* {{{ Parse a number. */
 PHP_FUNCTION( numfmt_parse )
 {
@@ -97,7 +61,7 @@ PHP_FUNCTION( numfmt_parse )
 	/* Convert given string to UTF-16. */
 	intl_convert_utf8_to_utf16(&sstr, &sstr_len, str, str_len, &INTL_DATA_ERROR_CODE(nfo));
 	INTL_METHOD_CHECK_STATUS( nfo, "String conversion to UTF-16 failed" );
-	if (zposition && !numfmt_utf8_offset_to_utf16(str, str_len, &position, &INTL_DATA_ERROR_CODE(nfo))) {
+	if (zposition && !intl_convert_utf8_offset_to_utf16(str, str_len, &position, &INTL_DATA_ERROR_CODE(nfo))) {
 		efree(sstr);
 		INTL_METHOD_CHECK_STATUS(nfo, "Invalid UTF-8 offset");
 	}
@@ -141,7 +105,7 @@ PHP_FUNCTION( numfmt_parse )
 	}

 	if (zposition) {
-		position = numfmt_utf16_offset_to_utf8(sstr, sstr_len, position);
+		position = intl_convert_utf16_offset_to_utf8(sstr, sstr_len, position);
 		ZEND_TRY_ASSIGN_REF_LONG(zposition, position);
 	}

@@ -191,7 +155,7 @@ PHP_FUNCTION( numfmt_parse_currency )

 	if(zposition) {
 		position = (int32_t) zval_get_long(zposition);
-		if (!numfmt_utf8_offset_to_utf16(str, str_len, &position, &INTL_DATA_ERROR_CODE(nfo))) {
+		if (!intl_convert_utf8_offset_to_utf16(str, str_len, &position, &INTL_DATA_ERROR_CODE(nfo))) {
 			efree(sstr);
 			INTL_METHOD_CHECK_STATUS(nfo, "Invalid UTF-8 offset");
 		}
@@ -200,7 +164,7 @@ PHP_FUNCTION( numfmt_parse_currency )

 	number = unum_parseDoubleCurrency(FORMATTER_OBJECT(nfo), sstr, sstr_len, position_p, currency, &INTL_DATA_ERROR_CODE(nfo));
 	if(zposition) {
-		position = numfmt_utf16_offset_to_utf8(sstr, sstr_len, position);
+		position = intl_convert_utf16_offset_to_utf8(sstr, sstr_len, position);
 		ZEND_TRY_ASSIGN_REF_LONG(zposition, position);
 	}
 	if (sstr) {
diff --git a/ext/intl/intl_convert.c b/ext/intl/intl_convert.c
index 3514b81ffe7..eacf4b1713b 100644
--- a/ext/intl/intl_convert.c
+++ b/ext/intl/intl_convert.c
@@ -22,6 +22,8 @@
 #include "intl_common.h"
 #include "intl_convert.h"

+#include <unicode/utf16.h>
+
 /* {{{ intl_convert_utf8_to_utf16
  * Convert given string from UTF-8 to UTF-16 to *target buffer.
  *
@@ -150,3 +152,43 @@ zend_string* intl_convert_utf16_to_utf8(
 	return dst;
 }
 /* }}} */
+
+bool intl_convert_utf8_offset_to_utf16(const char *str, size_t str_len, int32_t *position, UErrorCode *status)
+{
+	int32_t utf16_position;
+
+	if (*position < 0 || (size_t) *position > str_len) {
+		return true;
+	}
+
+	*status = U_ZERO_ERROR;
+	u_strFromUTF8(NULL, 0, &utf16_position, str, *position, status);
+	if (*status != U_BUFFER_OVERFLOW_ERROR && U_FAILURE(*status)) {
+		return false;
+	}
+	*status = U_ZERO_ERROR;
+
+	*position = utf16_position;
+	return true;
+}
+
+int32_t intl_convert_utf16_offset_to_utf8(const UChar *str, int32_t str_len, int32_t position)
+{
+	int32_t utf8_position;
+	UErrorCode status = U_ZERO_ERROR;
+
+	if (position < 0 || position > str_len) {
+		return position;
+	}
+
+	if (position > 0 && position < str_len && U16_IS_LEAD(str[position - 1]) && U16_IS_TRAIL(str[position])) {
+		position--;
+	}
+
+	u_strToUTF8(NULL, 0, &utf8_position, str, position, &status);
+	if (status != U_BUFFER_OVERFLOW_ERROR && U_FAILURE(status)) {
+		return position;
+	}
+
+	return utf8_position;
+}
diff --git a/ext/intl/intl_convert.h b/ext/intl/intl_convert.h
index 5ea4adbe579..ad7d6534de6 100644
--- a/ext/intl/intl_convert.h
+++ b/ext/intl/intl_convert.h
@@ -27,4 +27,12 @@ zend_string* intl_convert_utf16_to_utf8(
 	const UChar* src,    int32_t  src_len,
 	UErrorCode*  status );

+bool intl_convert_utf8_offset_to_utf16(
+	const char* str, size_t   str_len,
+	int32_t*    position, UErrorCode* status );
+
+int32_t intl_convert_utf16_offset_to_utf8(
+	const UChar* str, int32_t  str_len,
+	int32_t      position );
+
 #endif // INTL_CONVERT_H
diff --git a/ext/intl/tests/dateformat_parse_utf8_offset.phpt b/ext/intl/tests/dateformat_parse_utf8_offset.phpt
new file mode 100644
index 00000000000..9c0fef9fa94
--- /dev/null
+++ b/ext/intl/tests/dateformat_parse_utf8_offset.phpt
@@ -0,0 +1,68 @@
+--TEST--
+IntlDateFormatter parsing uses UTF-8 byte offsets
+--EXTENSIONS--
+intl
+--FILE--
+<?php
+$formatter = new IntlDateFormatter('en_US', IntlDateFormatter::NONE, IntlDateFormatter::NONE, 'UTC', IntlDateFormatter::GREGORIAN, 'yyyy-MM-dd');
+$prefix = "\u{1F600}";
+$text = $prefix . '2017-10-12 tail';
+
+echo "parse():\n";
+$offset = strlen($prefix);
+var_dump(gmdate('Y-m-d', $formatter->parse($text, $offset)), $offset);
+
+echo "localtime():\n";
+$offset = strlen($prefix);
+$tm = $formatter->localtime($text, $offset);
+var_dump($tm['tm_year'] + 1900, $tm['tm_mon'] + 1, $tm['tm_mday'], $offset);
+
+echo "parseToCalendar():\n";
+$offset = strlen($prefix);
+var_dump(gmdate('Y-m-d', $formatter->parseToCalendar($text, $offset)), $offset);
+
+echo "failed parse:\n";
+$offset = strlen($prefix);
+var_dump($formatter->parse($prefix . '2017-xx-12', $offset), $offset);
+
+echo "offset inside a character:\n";
+$offset = 1;
+var_dump($formatter->parse($text, $offset), $offset, intl_get_error_message());
+
+echo "partial match inside a surrogate pair:\n";
+$formatter = new IntlDateFormatter('en_US', IntlDateFormatter::NONE, IntlDateFormatter::NONE, 'UTC', IntlDateFormatter::GREGORIAN, "\u{20AC}\u{1F600}yyyy-MM-dd");
+$offset = 0;
+var_dump($formatter->parse("\u{20AC}\u{1F601}2017-10-12", $offset), $offset);
+$formatter = new IntlDateFormatter('en_US', IntlDateFormatter::NONE, IntlDateFormatter::NONE, 'UTC', IntlDateFormatter::GREGORIAN, "yyyy-MM-dd\u{1F600}");
+foreach (['parse', 'localtime', 'parseToCalendar'] as $method) {
+    $offset = 0;
+    $formatter->$method("2017-10-12\u{1F601}", $offset);
+    echo "$method(): ";
+    var_dump($offset);
+}
+?>
+--EXPECT--
+parse():
+string(10) "2017-10-12"
+int(14)
+localtime():
+int(2017)
+int(10)
+int(12)
+int(14)
+parseToCalendar():
+string(10) "2017-10-12"
+int(14)
+failed parse:
+bool(false)
+int(9)
+offset inside a character:
+bool(false)
+int(1)
+string(42) "Invalid UTF-8 offset: U_INVALID_CHAR_FOUND"
+partial match inside a surrogate pair:
+bool(false)
+int(3)
+parse(): int(10)
+localtime(): int(10)
+parseToCalendar(): int(10)