[php-src] PHP-8.5: Fix GH-23094: Use byte offsets in NumberFormatter parsing (#23318)

ColumbusLabs via Weilin Du <[email protected]>
Newsgroups gmane.comp.php.cvs.general
Message-ID <[email protected]>
Author: ColumbusLabs (ColumbusLabs)
Committer: Weilin Du (LamentXU123)
Date: 2026-08-18T17:45:56+08:00

Commit: https://github.com/php/php-src/commit/19ac30f99bd0cabc4445f40c840c003ee39f7a42
Raw diff: https://github.com/php/php-src/commit/19ac30f99bd0cabc4445f40c840c003ee39f7a42.diff

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

Changed paths:
  A  ext/intl/tests/gh23094.phpt
  M  NEWS
  M  ext/intl/formatter/formatter_parse.c


Diff:

diff --git a/NEWS b/NEWS
index 8f462c66a26b..53bb8f7b1fb2 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 993990065040..2c5ac3222f3d 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 000000000000..9ace16fa481f
--- /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)
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.