Commit 19ac30f99bd for php.net

commit 19ac30f99bd0cabc4445f40c840c003ee39f7a42
Author: ColumbusLabs <287001685+ColumbusLabs@users.noreply.github.com>
Date:   Tue Aug 18 17:45:56 2026 +0800

    Fix GH-23094: Use byte offsets in NumberFormatter parsing (#23318)

    PHP exposes NumberFormatter parsing offsets as UTF-8 byte offsets, while
    ICU expects UTF-16 code-unit positions. Convert the input offset before
    parsing and the returned offset afterward for both parse() and
    parseCurrency(). Reject offsets that split a UTF-8 sequence, set the
    formatter error state, and leave the referenced offset unchanged.

    Closes #23318

diff --git a/NEWS b/NEWS
index 8f462c66a26..53bb8f7b1fb 100644
--- a/NEWS
+++ b/NEWS
@@ -13,6 +13,8 @@ PHP                                                                        NEWS
 - Intl:
   . Fixed a double-free when IntlGregorianCalendar construction fails after
     the ICU constructor adopts the TimeZone. (iliaal)
+  . Fixed bug GH-23094 (NumberFormatter parsing offsets use UTF-16 positions
+    for UTF-8 strings). (ColumbusLabs)

 - Opcache:
   . Fixed opcache.protect_memory race under ZTS. (realFlowControl)
diff --git a/ext/intl/formatter/formatter_parse.c b/ext/intl/formatter/formatter_parse.c
index 99399006504..2c5ac3222f3 100644
--- a/ext/intl/formatter/formatter_parse.c
+++ b/ext/intl/formatter/formatter_parse.c
@@ -27,6 +27,42 @@

 #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 )
 {
@@ -61,6 +97,10 @@ 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))) {
+		efree(sstr);
+		INTL_METHOD_CHECK_STATUS(nfo, "Invalid UTF-8 offset");
+	}

 #if ICU_LOCALE_BUG && defined(LC_NUMERIC)
 	/* need to copy here since setlocale may change it later */
@@ -101,6 +141,7 @@ PHP_FUNCTION( numfmt_parse )
 	}

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

@@ -150,11 +191,16 @@ 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))) {
+			efree(sstr);
+			INTL_METHOD_CHECK_STATUS(nfo, "Invalid UTF-8 offset");
+		}
 		position_p = &position;
 	}

 	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);
 		ZEND_TRY_ASSIGN_REF_LONG(zposition, position);
 	}
 	if (sstr) {
diff --git a/ext/intl/tests/gh23094.phpt b/ext/intl/tests/gh23094.phpt
new file mode 100644
index 00000000000..9ace16fa481
--- /dev/null
+++ b/ext/intl/tests/gh23094.phpt
@@ -0,0 +1,47 @@
+--TEST--
+GH-23094 NumberFormatter parse offsets use UTF-8 byte positions
+--EXTENSIONS--
+intl
+--FILE--
+<?php
+
+$prefix = "\u{1F600}";
+
+$formatter = new NumberFormatter('en_US', NumberFormatter::DECIMAL);
+$offset = strlen($prefix);
+var_dump($formatter->parse($prefix . '123', NumberFormatter::TYPE_INT32, $offset));
+var_dump($offset);
+
+$offset = 1;
+var_dump($formatter->parse("\u{00E9}123", NumberFormatter::TYPE_INT32, $offset));
+var_dump($offset);
+var_dump(intl_is_failure($formatter->getErrorCode()));
+
+$formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY);
+$offset = strlen($prefix);
+$currency = null;
+var_dump($formatter->parseCurrency($prefix . '$123.45', $currency, $offset));
+var_dump($currency);
+var_dump($offset);
+
+$offset = 1;
+$currency = null;
+var_dump($formatter->parseCurrency("\u{00E9}$123.45", $currency, $offset));
+var_dump($currency);
+var_dump($offset);
+var_dump(intl_is_failure($formatter->getErrorCode()));
+
+?>
+--EXPECT--
+int(123)
+int(7)
+bool(false)
+int(1)
+bool(true)
+float(123.45)
+string(3) "USD"
+int(11)
+bool(false)
+NULL
+int(1)
+bool(true)