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)