diff --git a/NEWS b/NEWS index 190c6c16a935..9807e07945df 100644 --- a/NEWS +++ b/NEWS @@ -14,6 +14,12 @@ PHP NEWS . Fixed bug GH-22602 (gregoriantojd() and juliantojd() integer overflow with INT_MAX year). (arshidkv12) +- Curl: + . Added CURLOPT_SEEKFUNCTION and the CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL + and CURL_SEEKFUNC_CANTSEEK constants, letting libcurl rewind a streamed + request body to resend it on a redirect, multi-pass authentication or a + retried reused connection. (GrahamCampbell) + - Date: . Update timelib to 2022.17. (Derick) . Fixed bug GH-19803 (Parsing a string with a single white space does create @@ -35,6 +41,8 @@ PHP NEWS returning the first entity or notation. (Weilin Du) . Fixed bug GH-22623 (use after free with namespace nodes from XSLTProcessor::registerFunctions())/ (David Carlier) + . Fixed bug GH-22554 (use-after-free with XPath callback returning a node + from a foreign document). (David Carlier) - Exif: . Fixed bug GH-11020 (exif_read_data() emits a spurious "Illegal IFD size" @@ -52,6 +60,9 @@ PHP NEWS IntlDateFormatter::localtime()/datefmt_localtime() now raise TypeError when the offset argument is not of type int. (Weilin Du) +- JSON: + . Report unterminated JSON strings as syntax errors. (timwolla) + - Opcache: . Fixed bug GH-21770 (Infinite recursion in property hook getter in opcache preloaded trait). (iliaal) @@ -91,6 +102,12 @@ PHP NEWS . Fixed bug GH-21468 (Segfault in file_get_contents w/ a https URL and a proxy set). (CVE-2026-12184) (ndossche) +- URI: + . Fixed bug GH-22628 (Percent-encoding of caret in WHATWG URL paths is not + performed). (kocsismate) + . Fixed bug GH-22629 (WHATWG Validation error incorrect with empty host and + non-empty userinfo). (kocsismate) + - Zip: . Fixed bug GH-22649 (ZipArchive::setCommentName() and setCommentIndex() could crash after overwriting an entry and resetting its inherited diff --git a/UPGRADING b/UPGRADING index 031b6751d853..a809687d1961 100644 --- a/UPGRADING +++ b/UPGRADING @@ -238,6 +238,12 @@ PHP 8.6 UPGRADE NOTES This value can also be obtained by passing CURLINFO_SIZE_DELIVERED as the $option parameter. Requires libcurl 8.20.0 or later. + . Added CURLOPT_SEEKFUNCTION to register a callback that repositions a + streamed request body so libcurl can rewind and resend it on a redirect, + multi-pass authentication, or a retried reused connection instead of + failing with CURLE_SEND_FAIL_REWIND. The callback receives the CurlHandle, + offset and origin, and must return one of CURL_SEEKFUNC_OK, + CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK. - Fileinfo: . finfo_file() now works with remote streams. @@ -467,6 +473,10 @@ PHP 8.6 UPGRADE NOTES - Curl: . CURLINFO_SIZE_DELIVERED (libcurl >= 8.20.0). + . CURLOPT_SEEKFUNCTION. + . CURL_SEEKFUNC_OK. + . CURL_SEEKFUNC_FAIL. + . CURL_SEEKFUNC_CANTSEEK. - Sockets: . TCP_USER_TIMEOUT (Linux only). diff --git a/UPGRADING.INTERNALS b/UPGRADING.INTERNALS index bdf874b382d0..7775c9316040 100644 --- a/UPGRADING.INTERNALS +++ b/UPGRADING.INTERNALS @@ -173,6 +173,14 @@ PHP 8.6 INTERNALS UPGRADE NOTES . php_idate() now returns the result state, and moves the return value into an out parameter. +- ext/intl: + . Added intl_icu_compat.h with helpers and feature macros for ICU + version-specific API differences. Code in ext/intl should use the + intl_icu_compat_* helpers and INTL_ICU_HAS_* macros instead of adding + direct U_ICU_VERSION_* guards for supported ICU API variants. + . The internal grapheme_get_break_iterator() helper no longer accepts a + stack buffer argument; pass only the UErrorCode* status argument. + - ext/mbstring: . Added GB18030-2022 to default encoding list for zh-CN. diff --git a/ext/curl/curl.stub.php b/ext/curl/curl.stub.php index d3bd58510e40..70e87cc9b146 100644 --- a/ext/curl/curl.stub.php +++ b/ext/curl/curl.stub.php @@ -343,6 +343,11 @@ * @cvalue CURLOPT_RETURNTRANSFER */ const CURLOPT_RETURNTRANSFER = UNKNOWN; +/** + * @var int + * @cvalue CURLOPT_SEEKFUNCTION + */ +const CURLOPT_SEEKFUNCTION = UNKNOWN; /** * @var int * @cvalue CURLOPT_SHARE @@ -1788,6 +1793,21 @@ * @cvalue CURL_READFUNC_PAUSE */ const CURL_READFUNC_PAUSE = UNKNOWN; +/** + * @var int + * @cvalue CURL_SEEKFUNC_OK + */ +const CURL_SEEKFUNC_OK = UNKNOWN; +/** + * @var int + * @cvalue CURL_SEEKFUNC_FAIL + */ +const CURL_SEEKFUNC_FAIL = UNKNOWN; +/** + * @var int + * @cvalue CURL_SEEKFUNC_CANTSEEK + */ +const CURL_SEEKFUNC_CANTSEEK = UNKNOWN; /** * @var int * @cvalue CURL_WRITEFUNC_PAUSE diff --git a/ext/curl/curl_arginfo.h b/ext/curl/curl_arginfo.h index 6a05b73572cb..f2929f60c4e2 100644 --- a/ext/curl/curl_arginfo.h +++ b/ext/curl/curl_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit curl.stub.php instead. - * Stub hash: 1ecf692ba8494e7b486986c0170686c33768deb5 */ + * Stub hash: d55adb230c533f4dde05e95759477dd9e1dd6efb */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_curl_close, 0, 1, IS_VOID, 0) ZEND_ARG_OBJ_INFO(0, handle, CurlHandle, 0) @@ -294,6 +294,7 @@ static void register_curl_symbols(int module_number) REGISTER_LONG_CONSTANT("CURLOPT_REFERER", CURLOPT_REFERER, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLOPT_RESUME_FROM", CURLOPT_RESUME_FROM, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLOPT_RETURNTRANSFER", CURLOPT_RETURNTRANSFER, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("CURLOPT_SEEKFUNCTION", CURLOPT_SEEKFUNCTION, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLOPT_SHARE", CURLOPT_SHARE, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLOPT_SSLCERT", CURLOPT_SSLCERT, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLOPT_SSLCERTPASSWD", CURLOPT_SSLCERTPASSWD, CONST_PERSISTENT); @@ -574,6 +575,9 @@ static void register_curl_symbols(int module_number) REGISTER_LONG_CONSTANT("CURLPAUSE_SEND", CURLPAUSE_SEND, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLPAUSE_SEND_CONT", CURLPAUSE_SEND_CONT, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURL_READFUNC_PAUSE", CURL_READFUNC_PAUSE, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("CURL_SEEKFUNC_OK", CURL_SEEKFUNC_OK, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("CURL_SEEKFUNC_FAIL", CURL_SEEKFUNC_FAIL, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("CURL_SEEKFUNC_CANTSEEK", CURL_SEEKFUNC_CANTSEEK, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURL_WRITEFUNC_PAUSE", CURL_WRITEFUNC_PAUSE, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLPROXY_SOCKS4A", CURLPROXY_SOCKS4A, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLPROXY_SOCKS5_HOSTNAME", CURLPROXY_SOCKS5_HOSTNAME, CONST_PERSISTENT); diff --git a/ext/curl/curl_private.h b/ext/curl/curl_private.h index 77b0628ee42a..25cc37b6e458 100644 --- a/ext/curl/curl_private.h +++ b/ext/curl/curl_private.h @@ -74,6 +74,7 @@ typedef struct { php_curl_write *write_header; php_curl_read *read; zval std_err; + zend_fcall_info_cache seek; zend_fcall_info_cache progress; zend_fcall_info_cache xferinfo; zend_fcall_info_cache fnmatch; diff --git a/ext/curl/interface.c b/ext/curl/interface.c index 09c7009068b6..b7ec015abf62 100644 --- a/ext/curl/interface.c +++ b/ext/curl/interface.c @@ -456,6 +456,10 @@ static HashTable *curl_get_gc(zend_object *object, zval **table, int *n) zend_get_gc_buffer_add_zval(gc_buffer, &curl->handlers.write_header->stream); } + if (ZEND_FCC_INITIALIZED(curl->handlers.seek)) { + zend_get_gc_buffer_add_fcc(gc_buffer, &curl->handlers.seek); + } + if (ZEND_FCC_INITIALIZED(curl->handlers.progress)) { zend_get_gc_buffer_add_fcc(gc_buffer, &curl->handlers.progress); } @@ -831,6 +835,52 @@ static size_t curl_read(char *data, size_t size, size_t nmemb, void *ctx) } /* }}} */ +/* {{{ curl_seek */ +static int curl_seek(void *clientp, curl_off_t offset, int origin) +{ + php_curl *ch = (php_curl *)clientp; + int rval = CURL_SEEKFUNC_CANTSEEK; /* safe default if unset or the callback misbehaves */ + +#if PHP_CURL_DEBUG + fprintf(stderr, "curl_seek() called\n"); + fprintf(stderr, "clientp = %x, offset = %ld, origin = %d\n", clientp, offset, origin); +#endif + if (!ZEND_FCC_INITIALIZED(ch->handlers.seek)) { + return rval; + } + + zval args[3]; + zval retval; + + GC_ADDREF(&ch->std); + ZVAL_OBJ(&args[0], &ch->std); + ZVAL_LONG(&args[1], offset); + ZVAL_LONG(&args[2], origin); + + ch->in_callback = true; + zend_call_known_fcc(&ch->handlers.seek, &retval, /* param_count */ 3, args, /* named_params */ NULL); + ch->in_callback = false; + + if (!Z_ISUNDEF(retval)) { + _php_curl_verify_handlers(ch, /* reporterror */ true); + if (Z_TYPE(retval) == IS_LONG) { + zend_long retval_long = Z_LVAL(retval); + if (retval_long == CURL_SEEKFUNC_OK || retval_long == CURL_SEEKFUNC_FAIL || retval_long == CURL_SEEKFUNC_CANTSEEK) { + rval = retval_long; + } else { + zend_value_error("The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK"); + } + } else { + zend_type_error("The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK"); + } + zval_ptr_dtor(&retval); + } + + zval_ptr_dtor(&args[0]); + return rval; +} +/* }}} */ + /* {{{ curl_write_header */ static size_t curl_write_header(char *data, size_t size, size_t nmemb, void *ctx) { @@ -1038,6 +1088,7 @@ void init_curl_handle(php_curl *ch) ch->handlers.write = ecalloc(1, sizeof(php_curl_write)); ch->handlers.write_header = ecalloc(1, sizeof(php_curl_write)); ch->handlers.read = ecalloc(1, sizeof(php_curl_read)); + ch->handlers.seek = empty_fcall_info_cache; ch->handlers.progress = empty_fcall_info_cache; ch->handlers.xferinfo = empty_fcall_info_cache; ch->handlers.fnmatch = empty_fcall_info_cache; @@ -1208,6 +1259,7 @@ void _php_setup_easy_copy_handlers(php_curl *ch, php_curl *source) curl_easy_setopt(ch->cp, CURLOPT_WRITEHEADER, (void *) ch); curl_easy_setopt(ch->cp, CURLOPT_DEBUGDATA, (void *) ch); + php_curl_copy_fcc_with_option(ch, CURLOPT_SEEKDATA, &ch->handlers.seek, &source->handlers.seek); php_curl_copy_fcc_with_option(ch, CURLOPT_PROGRESSDATA, &ch->handlers.progress, &source->handlers.progress); php_curl_copy_fcc_with_option(ch, CURLOPT_XFERINFODATA, &ch->handlers.xferinfo, &source->handlers.xferinfo); php_curl_copy_fcc_with_option(ch, CURLOPT_FNMATCH_DATA, &ch->handlers.fnmatch, &source->handlers.fnmatch); @@ -1577,6 +1629,7 @@ static zend_result _php_curl_setopt(php_curl *ch, zend_long option, zval *zvalue HANDLE_CURL_OPTION_CALLABLE_PHP_CURL_USER(ch, CURLOPT_HEADER, write_header, PHP_CURL_IGNORE); HANDLE_CURL_OPTION_CALLABLE_PHP_CURL_USER(ch, CURLOPT_READ, read, PHP_CURL_DIRECT); + HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_SEEK, handlers.seek, curl_seek); HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_PROGRESS, handlers.progress, curl_progress); HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_XFERINFO, handlers.xferinfo, curl_xferinfo); HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_FNMATCH_, handlers.fnmatch, curl_fnmatch); @@ -2794,6 +2847,9 @@ static void curl_free_obj(zend_object *object) efree(ch->handlers.write_header); efree(ch->handlers.read); + if (ZEND_FCC_INITIALIZED(ch->handlers.seek)) { + zend_fcc_dtor(&ch->handlers.seek); + } if (ZEND_FCC_INITIALIZED(ch->handlers.progress)) { zend_fcc_dtor(&ch->handlers.progress); } @@ -2878,6 +2934,10 @@ static void _php_curl_reset_handlers(php_curl *ch) ZVAL_UNDEF(&ch->handlers.std_err); } + if (ZEND_FCC_INITIALIZED(ch->handlers.seek)) { + zend_fcc_dtor(&ch->handlers.seek); + } + if (ZEND_FCC_INITIALIZED(ch->handlers.progress)) { zend_fcc_dtor(&ch->handlers.progress); } diff --git a/ext/curl/tests/curl_copy_handle_seek.phpt b/ext/curl/tests/curl_copy_handle_seek.phpt new file mode 100644 index 000000000000..c2a2f37bbe13 --- /dev/null +++ b/ext/curl/tests/curl_copy_handle_seek.phpt @@ -0,0 +1,44 @@ +--TEST-- +Test curl_copy_handle() with CURLOPT_SEEKFUNCTION +--EXTENSIONS-- +curl +--FILE-- + 0); +var_dump(str_contains($response, $body)); +?> +--EXPECT-- +bool(true) +bool(true) diff --git a/ext/curl/tests/curl_seekfunction.phpt b/ext/curl/tests/curl_seekfunction.phpt new file mode 100644 index 000000000000..d2daa1e7d8dc --- /dev/null +++ b/ext/curl/tests/curl_seekfunction.phpt @@ -0,0 +1,51 @@ +--TEST-- +CURLOPT_SEEKFUNCTION is called to rewind a streamed upload across a redirect +--EXTENSIONS-- +curl +--FILE-- + 0); +var_dump(str_contains($response, $body)); +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/ext/curl/tests/curl_seekfunction_error.phpt b/ext/curl/tests/curl_seekfunction_error.phpt new file mode 100644 index 000000000000..134e8115dc5f --- /dev/null +++ b/ext/curl/tests/curl_seekfunction_error.phpt @@ -0,0 +1,77 @@ +--TEST-- +CURLOPT_SEEKFUNCTION callback error handling and option validation +--EXTENSIONS-- +curl +--FILE-- + 'not an int'); +} catch (\TypeError $e) { + echo $e->getMessage(), "\n"; +} + +echo "\nReturning an out-of-range int:\n"; +try { + run_upload($host, fn($ch, $offset, $origin) => 42); +} catch (\ValueError $e) { + echo $e->getMessage(), "\n"; +} + +echo "\nThrowing from the callback:\n"; +try { + run_upload($host, function ($ch, $offset, $origin) { + throw new \RuntimeException('boom from seek'); + }); +} catch (\RuntimeException $e) { + echo $e->getMessage(), "\n"; +} + +echo "\nSetting the callback to null:\n"; +var_dump(curl_setopt(curl_init(), CURLOPT_SEEKFUNCTION, null)); + +echo "\nSetting a non-callable scalar:\n"; +try { + curl_setopt(curl_init(), CURLOPT_SEEKFUNCTION, 42); +} catch (\TypeError $e) { + echo $e->getMessage(), "\n"; +} +?> +--EXPECT-- +Returning a non-int: +The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK + +Returning an out-of-range int: +The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK + +Throwing from the callback: +boom from seek + +Setting the callback to null: +bool(true) + +Setting a non-callable scalar: +curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_SEEKFUNCTION, no array or string given diff --git a/ext/curl/tests/curl_setopt_callables.phpt b/ext/curl/tests/curl_setopt_callables.phpt index aaa83102afac..9de8d6705710 100644 --- a/ext/curl/tests/curl_setopt_callables.phpt +++ b/ext/curl/tests/curl_setopt_callables.phpt @@ -27,6 +27,7 @@ testOption($ch, CURLOPT_FNMATCH_FUNCTION); testOption($ch, CURLOPT_WRITEFUNCTION); testOption($ch, CURLOPT_HEADERFUNCTION); testOption($ch, CURLOPT_READFUNCTION); +testOption($ch, CURLOPT_SEEKFUNCTION); ?> --EXPECT-- @@ -42,3 +43,5 @@ TypeError: curl_setopt(): Argument #3 ($value) must be a valid callback for opti TypeError: curl_setopt_array(): Argument #2 ($options) must be a valid callback for option CURLOPT_HEADERFUNCTION, function "undefined" not found or invalid function name TypeError: curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_READFUNCTION, function "undefined" not found or invalid function name TypeError: curl_setopt_array(): Argument #2 ($options) must be a valid callback for option CURLOPT_READFUNCTION, function "undefined" not found or invalid function name +TypeError: curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_SEEKFUNCTION, function "undefined" not found or invalid function name +TypeError: curl_setopt_array(): Argument #2 ($options) must be a valid callback for option CURLOPT_SEEKFUNCTION, function "undefined" not found or invalid function name diff --git a/ext/curl/tests/responder/get.inc b/ext/curl/tests/responder/get.inc index c139c8c7d43a..2ef1e4a89dd3 100644 --- a/ext/curl/tests/responder/get.inc +++ b/ext/curl/tests/responder/get.inc @@ -46,6 +46,11 @@ case 'method': echo $_SERVER['REQUEST_METHOD']; break; + case 'redirect': + // A 307 preserves the method and body, so libcurl must rewind the upload + // (via CURLOPT_SEEKFUNCTION) before resending it to the new location. + header('Location: /get.inc?test=input', true, 307); + break; default: echo "Hello World!\n"; echo "Hello World!"; diff --git a/ext/dom/tests/gh22554.phpt b/ext/dom/tests/gh22554.phpt new file mode 100644 index 000000000000..c8db1d87390b --- /dev/null +++ b/ext/dom/tests/gh22554.phpt @@ -0,0 +1,38 @@ +--TEST-- +GH-22554 (Use-after-free when an XPath callback returns a node from a document created inside the callback) +--CREDITS-- +waseem-cve +--EXTENSIONS-- +dom +--FILE-- +loadXML(''); + +$xp = new DOMXPath($doc); +$xp->registerNamespace('my', 'my.ns'); + +$xp->registerPHPFunctionNS('my.ns', 'include', function () { + $d = new DOMDocument; + $d->loadXML(''); + + return $d->documentElement; +}); + +$xp->registerPHPFunctionNS('my.ns', 'process', function ($arg) { + echo "process arg: ", get_class($arg[0]), " ", $arg[0]->nodeName, "\n"; + return 'x'; +}); + +$result = $xp->query('my:process(my:include()/uaf)'); +var_dump($result->length); +unset($xp); + +echo "Done\n"; + +?> +--EXPECT-- +process arg: DOMElement uaf +int(0) +Done diff --git a/ext/dom/xpath.c b/ext/dom/xpath.c index bd9947b4ad10..dc9e1b852fae 100644 --- a/ext/dom/xpath.c +++ b/ext/dom/xpath.c @@ -77,7 +77,8 @@ static void dom_xpath_proxy_factory(xmlNodePtr node, zval *child, dom_object *in ZEND_ASSERT(node->type != XML_NAMESPACE_DECL); - php_dom_create_object(node, child, intern); + dom_xpath_object *xobj = php_xpath_obj_from_obj(&intern->std); + php_dom_create_object(node, child, dom_xpath_intern_for_doc(xobj, node->doc)); } static dom_xpath_object *dom_xpath_ext_fetch_intern(xmlXPathParserContextPtr ctxt) diff --git a/ext/json/json_scanner.re b/ext/json/json_scanner.re index e4d25009132a..0c64a6423baf 100644 --- a/ext/json/json_scanner.re +++ b/ext/json/json_scanner.re @@ -262,7 +262,14 @@ std: s->errcode = PHP_JSON_ERROR_UTF8; return PHP_JSON_T_ERROR; } - + EOI { + if (s->limit < s->cursor) { + s->errcode = PHP_JSON_ERROR_SYNTAX; + } else { + s->errcode = PHP_JSON_ERROR_CTRL_CHAR; + } + return PHP_JSON_T_ERROR; + } CTRL { s->errcode = PHP_JSON_ERROR_CTRL_CHAR; return PHP_JSON_T_ERROR; diff --git a/ext/json/tests/gh22527.phpt b/ext/json/tests/gh22527.phpt new file mode 100644 index 000000000000..cb0dd982d50b --- /dev/null +++ b/ext/json/tests/gh22527.phpt @@ -0,0 +1,31 @@ +--TEST-- +GH-22527: Unterminated JSON strings are misleadingly reported as “Control character error” +--FILE-- + +--EXPECT-- +NULL +int(4) +string(30) "Syntax error near location 1:1" +NULL +int(4) +string(30) "Syntax error near location 1:1" +NULL +int(4) +string(30) "Syntax error near location 1:9" +NULL +int(3) +string(71) "Control character error, possibly incorrectly encoded near location 1:1" diff --git a/ext/json/tests/json_last_error_msg_error_location_001.phpt b/ext/json/tests/json_last_error_msg_error_location_001.phpt index e0553f9f7d65..72cb1c42f23f 100644 --- a/ext/json/tests/json_last_error_msg_error_location_001.phpt +++ b/ext/json/tests/json_last_error_msg_error_location_001.phpt @@ -66,8 +66,8 @@ string(30) "Syntax error near location 1:1" Error at position 1:10: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:10" +int(4) +string(31) "Syntax error near location 1:10" Error at position 1:9: bool(false) @@ -118,4 +118,3 @@ Error at position 1:10: bool(false) int(3) string(72) "Control character error, possibly incorrectly encoded near location 1:10" - diff --git a/ext/json/tests/json_last_error_msg_error_location_002.phpt b/ext/json/tests/json_last_error_msg_error_location_002.phpt index df7fc981ccba..31438255b31a 100644 --- a/ext/json/tests/json_last_error_msg_error_location_002.phpt +++ b/ext/json/tests/json_last_error_msg_error_location_002.phpt @@ -53,51 +53,50 @@ Testing error locations with Unicode UTF-8 characters Error after Japanese characters: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:12" +int(4) +string(31) "Syntax error near location 1:12" Error after Russian characters: bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:9" +int(4) +string(30) "Syntax error near location 1:9" Error after Chinese characters: bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:8" +int(4) +string(30) "Syntax error near location 1:8" Error after Arabic characters: bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:9" +int(4) +string(30) "Syntax error near location 1:9" Error after Emoji: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:11" +int(4) +string(31) "Syntax error near location 1:11" Error in mixed ASCII and UTF-8: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:27" +int(4) +string(31) "Syntax error near location 1:27" Error with UTF-8 escaped sequences: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:10" +int(4) +string(31) "Syntax error near location 1:10" Error in object with multiple UTF-8 keys: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:22" +int(4) +string(31) "Syntax error near location 1:22" Error in array with UTF-8 strings: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:18" +int(4) +string(31) "Syntax error near location 1:18" Error in nested object with UTF-8: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:15" - +int(4) +string(31) "Syntax error near location 1:15" diff --git a/ext/json/tests/json_last_error_msg_error_location_004.phpt b/ext/json/tests/json_last_error_msg_error_location_004.phpt index 165449600fb3..fcde3faa38a3 100644 --- a/ext/json/tests/json_last_error_msg_error_location_004.phpt +++ b/ext/json/tests/json_last_error_msg_error_location_004.phpt @@ -53,8 +53,8 @@ Testing error locations in deeply nested structures Error in deeply nested object: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:31" +int(4) +string(31) "Syntax error near location 1:31" Error in deeply nested array: bool(true) @@ -78,16 +78,15 @@ string(31) "Syntax error near location 1:21" Error in complex structure: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:93" +int(4) +string(31) "Syntax error near location 1:93" Error in array of objects: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:68" +int(4) +string(31) "Syntax error near location 1:68" Error in object with array values: bool(false) int(2) string(61) "State mismatch (invalid or malformed JSON) near location 1:82" - diff --git a/ext/json/tests/json_last_error_msg_error_location_005.phpt b/ext/json/tests/json_last_error_msg_error_location_005.phpt index d12ce387e73e..ee1800eb8273 100644 --- a/ext/json/tests/json_last_error_msg_error_location_005.phpt +++ b/ext/json/tests/json_last_error_msg_error_location_005.phpt @@ -53,51 +53,50 @@ Testing error locations with UTF-16 surrogate pairs and escape sequences Error after UTF-16 escaped emoji: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:11" +int(4) +string(31) "Syntax error near location 1:11" Error after multiple UTF-16 pairs: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:10" +int(4) +string(31) "Syntax error near location 1:10" Error with mixed UTF-8 and UTF-16: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:11" +int(4) +string(31) "Syntax error near location 1:11" Error with UTF-16 in key: bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:9" +int(4) +string(30) "Syntax error near location 1:9" Error with multiple UTF-16 keys: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:22" +int(4) +string(31) "Syntax error near location 1:22" Error with BMP characters: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:10" +int(4) +string(31) "Syntax error near location 1:10" Error with supplementary plane: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:11" +int(4) +string(31) "Syntax error near location 1:11" Error in array with UTF-16: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:12" +int(4) +string(31) "Syntax error near location 1:12" Error in nested structure with UTF-16: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:18" +int(4) +string(31) "Syntax error near location 1:18" Error with UTF-16 and control chars: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:10" - +int(4) +string(31) "Syntax error near location 1:10" diff --git a/ext/json/tests/json_last_error_msg_error_location_006.phpt b/ext/json/tests/json_last_error_msg_error_location_006.phpt index e6aab1af8f27..4a6c221c8f92 100644 --- a/ext/json/tests/json_last_error_msg_error_location_006.phpt +++ b/ext/json/tests/json_last_error_msg_error_location_006.phpt @@ -107,8 +107,8 @@ string(33) "Syntax error near location 1:1011" Error with very long key: bool(false) -int(3) -string(73) "Control character error, possibly incorrectly encoded near location 1:506" +int(4) +string(32) "Syntax error near location 1:506" Error after empty object: bool(false) @@ -149,4 +149,3 @@ Error with mixed whitespace: bool(false) int(3) string(71) "Control character error, possibly incorrectly encoded near location 3:2" - diff --git a/ext/json/tests/json_last_error_msg_error_location_007.phpt b/ext/json/tests/json_last_error_msg_error_location_007.phpt index 0e24889bbbbe..168afc4dbfe2 100644 --- a/ext/json/tests/json_last_error_msg_error_location_007.phpt +++ b/ext/json/tests/json_last_error_msg_error_location_007.phpt @@ -118,8 +118,8 @@ string(30) "Syntax error near location 1:9" Unclosed string: bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:9" +int(4) +string(30) "Syntax error near location 1:9" Invalid escape sequence: bool(false) @@ -175,4 +175,3 @@ Missing comma between object properties: bool(false) int(4) string(30) "Syntax error near location 1:9" - diff --git a/ext/json/tests/json_last_error_msg_error_location_008.phpt b/ext/json/tests/json_last_error_msg_error_location_008.phpt index 4d8a1012316b..60dd9513472b 100644 --- a/ext/json/tests/json_last_error_msg_error_location_008.phpt +++ b/ext/json/tests/json_last_error_msg_error_location_008.phpt @@ -82,101 +82,100 @@ Testing error locations with various UTF-8 multi-byte character widths Error with 2-byte UTF-8 (Latin Extended): bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:10" +int(4) +string(31) "Syntax error near location 1:10" Error with 2-byte UTF-8 (Greek): bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:14" +int(4) +string(31) "Syntax error near location 1:14" Error with 2-byte UTF-8 (Cyrillic): bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:12" +int(4) +string(31) "Syntax error near location 1:12" Error with 3-byte UTF-8 (Chinese): bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:8" +int(4) +string(30) "Syntax error near location 1:8" Error with 3-byte UTF-8 (Japanese Hiragana): bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:10" +int(4) +string(31) "Syntax error near location 1:10" Error with 3-byte UTF-8 (Japanese Katakana): bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:10" +int(4) +string(31) "Syntax error near location 1:10" Error with 3-byte UTF-8 (Korean): bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:8" +int(4) +string(30) "Syntax error near location 1:8" Error with 4-byte UTF-8 (Emoji faces): bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:11" +int(4) +string(31) "Syntax error near location 1:11" Error with 4-byte UTF-8 (Emoji objects): bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:13" +int(4) +string(31) "Syntax error near location 1:13" Error with 4-byte UTF-8 (Mathematical symbols): bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:10" +int(4) +string(31) "Syntax error near location 1:10" Error with mixed 1-2-3 byte UTF-8: bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:9" +int(4) +string(30) "Syntax error near location 1:9" Error with mixed 2-3-4 byte UTF-8: bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:9" +int(4) +string(30) "Syntax error near location 1:9" Error with all byte widths: bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:9" +int(4) +string(30) "Syntax error near location 1:9" Error with UTF-8 key at start: bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:7" +int(4) +string(30) "Syntax error near location 1:7" Error with multiple UTF-8 keys: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:35" +int(4) +string(31) "Syntax error near location 1:35" Error in array with mixed UTF-8: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:25" +int(4) +string(31) "Syntax error near location 1:25" Error in nested structure with various UTF-8: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:22" +int(4) +string(31) "Syntax error near location 1:22" Error with combining diacritical marks: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:10" +int(4) +string(31) "Syntax error near location 1:10" Error with Hebrew: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:10" +int(4) +string(31) "Syntax error near location 1:10" Error with Arabic with diacritics: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:11" - +int(4) +string(31) "Syntax error near location 1:11" diff --git a/ext/json/tests/json_last_error_msg_error_location_009.phpt b/ext/json/tests/json_last_error_msg_error_location_009.phpt index 406179693ef6..9d4403838f63 100644 --- a/ext/json/tests/json_last_error_msg_error_location_009.phpt +++ b/ext/json/tests/json_last_error_msg_error_location_009.phpt @@ -75,18 +75,18 @@ string(46) "Maximum stack depth exceeded near location 1:7" Syntax error at deep nesting level: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:31" +int(4) +string(31) "Syntax error near location 1:31" Syntax error in deep array: bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:6" +int(4) +string(30) "Syntax error near location 1:6" Error after valid deep structure: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:48" +int(4) +string(31) "Syntax error near location 1:48" Error in middle of nested structure: bool(false) @@ -95,16 +95,15 @@ string(31) "Syntax error near location 1:29" Error in array with nested objects: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:30" +int(4) +string(31) "Syntax error near location 1:30" Error in deep UTF-8 structure: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:16" +int(4) +string(31) "Syntax error near location 1:16" Valid deep structure within limit: bool(true) int(0) string(8) "No error" - diff --git a/ext/json/tests/json_last_error_msg_error_location_010.phpt b/ext/json/tests/json_last_error_msg_error_location_010.phpt index 108570205838..d187afff86cc 100644 --- a/ext/json/tests/json_last_error_msg_error_location_010.phpt +++ b/ext/json/tests/json_last_error_msg_error_location_010.phpt @@ -119,13 +119,13 @@ string(30) "Syntax error near location 4:2" Error in string with spaces: bool(false) -int(3) -string(71) "Control character error, possibly incorrectly encoded near location 1:9" +int(4) +string(30) "Syntax error near location 1:9" Error with whitespace around colon: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:12" +int(4) +string(31) "Syntax error near location 1:12" Error with whitespace around comma: bool(true) @@ -154,11 +154,10 @@ string(72) "Control character error, possibly incorrectly encoded near location Error in compact JSON: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:22" +int(4) +string(31) "Syntax error near location 1:22" Error with regular spaces: bool(false) -int(3) -string(72) "Control character error, possibly incorrectly encoded near location 1:10" - +int(4) +string(31) "Syntax error near location 1:10" diff --git a/ext/lexbor/lexbor/url/url.c b/ext/lexbor/lexbor/url/url.c index 1fd044bbf950..8099c12089bb 100644 --- a/ext/lexbor/lexbor/url/url.c +++ b/ext/lexbor/lexbor/url/url.c @@ -159,7 +159,7 @@ static const uint8_t lxb_url_map[256] = LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5b ([) */ LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5c (\) */ LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5d (]) */ - LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5e (^) */ + LXB_URL_MAP_USERINFO|LXB_URL_MAP_PATH|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5e (^) */ LXB_URL_MAP_UNDEF, /* 0x5f (_) */ LXB_URL_MAP_PATH|LXB_URL_MAP_FRAGMENT|LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x60 (`) */ LXB_URL_MAP_UNDEF, /* 0x61 (a) */ @@ -1814,7 +1814,7 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url, if (at_sign) { if (begin == p || begin == p - 1) { status = lxb_url_log_append(parser, p, - LXB_URL_ERROR_TYPE_INVALID_CREDENTIALS); + LXB_URL_ERROR_TYPE_HOST_MISSING); if (status != LXB_STATUS_OK) { lxb_url_parse_return(orig_data, buf, status); } diff --git a/ext/lexbor/patches/0001-Expose-line-and-column-information-for-use-in-PHP.patch b/ext/lexbor/patches/0001-Expose-line-and-column-information-for-use-in-PHP.patch index e106b413c22c..f23ec0f5034d 100644 --- a/ext/lexbor/patches/0001-Expose-line-and-column-information-for-use-in-PHP.patch +++ b/ext/lexbor/patches/0001-Expose-line-and-column-information-for-use-in-PHP.patch @@ -1,7 +1,7 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+nielsdos@users.noreply.github.com> Date: Sat, 26 Aug 2023 15:08:59 +0200 -Subject: [PATCH 1/9] Expose line and column information for use in PHP +Subject: [PATCH 01/11] Expose line and column information for use in PHP --- source/lexbor/dom/interfaces/node.h | 2 ++ diff --git a/ext/lexbor/patches/0002-Track-implied-added-nodes-for-options-use-in-PHP.patch b/ext/lexbor/patches/0002-Track-implied-added-nodes-for-options-use-in-PHP.patch index 9e83700198e1..8758c09a2e8f 100644 --- a/ext/lexbor/patches/0002-Track-implied-added-nodes-for-options-use-in-PHP.patch +++ b/ext/lexbor/patches/0002-Track-implied-added-nodes-for-options-use-in-PHP.patch @@ -1,7 +1,7 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+nielsdos@users.noreply.github.com> Date: Mon, 14 Aug 2023 20:18:51 +0200 -Subject: [PATCH 2/9] Track implied added nodes for options use in PHP +Subject: [PATCH 02/11] Track implied added nodes for options use in PHP --- source/lexbor/html/tree.h | 3 +++ diff --git a/ext/lexbor/patches/0003-Patch-utilities-and-data-structure-to-be-able-to-gen.patch b/ext/lexbor/patches/0003-Patch-utilities-and-data-structure-to-be-able-to-gen.patch index 9ed36fda109d..56458a49deea 100644 --- a/ext/lexbor/patches/0003-Patch-utilities-and-data-structure-to-be-able-to-gen.patch +++ b/ext/lexbor/patches/0003-Patch-utilities-and-data-structure-to-be-able-to-gen.patch @@ -1,8 +1,8 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+nielsdos@users.noreply.github.com> Date: Thu, 24 Aug 2023 22:57:48 +0200 -Subject: [PATCH 3/9] Patch utilities and data structure to be able to generate - smaller lookup tables +Subject: [PATCH 03/11] Patch utilities and data structure to be able to + generate smaller lookup tables Changed the generation script to check if everything fits in 32-bits. And change the actual field types to 32-bits. This decreases the hash diff --git a/ext/lexbor/patches/0004-Remove-unused-upper-case-tag-static-data.patch b/ext/lexbor/patches/0004-Remove-unused-upper-case-tag-static-data.patch index 9e9fcaf8db7a..f4fe2050998d 100644 --- a/ext/lexbor/patches/0004-Remove-unused-upper-case-tag-static-data.patch +++ b/ext/lexbor/patches/0004-Remove-unused-upper-case-tag-static-data.patch @@ -1,7 +1,7 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+nielsdos@users.noreply.github.com> Date: Wed, 29 Nov 2023 21:26:47 +0100 -Subject: [PATCH 4/9] Remove unused upper case tag static data +Subject: [PATCH 04/11] Remove unused upper case tag static data --- source/lexbor/tag/res.h | 2 ++ diff --git a/ext/lexbor/patches/0005-Shrink-size-of-static-binary-search-tree.patch b/ext/lexbor/patches/0005-Shrink-size-of-static-binary-search-tree.patch index ce3448169395..f36a2d758181 100644 --- a/ext/lexbor/patches/0005-Shrink-size-of-static-binary-search-tree.patch +++ b/ext/lexbor/patches/0005-Shrink-size-of-static-binary-search-tree.patch @@ -1,7 +1,7 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+nielsdos@users.noreply.github.com> Date: Wed, 29 Nov 2023 21:29:31 +0100 -Subject: [PATCH 5/9] Shrink size of static binary search tree +Subject: [PATCH 05/11] Shrink size of static binary search tree This also makes it more efficient on the data cache. --- diff --git a/ext/lexbor/patches/0006-Patch-out-unused-CSS-style-code.patch b/ext/lexbor/patches/0006-Patch-out-unused-CSS-style-code.patch index c8f485f34807..7c6e1beebf46 100644 --- a/ext/lexbor/patches/0006-Patch-out-unused-CSS-style-code.patch +++ b/ext/lexbor/patches/0006-Patch-out-unused-CSS-style-code.patch @@ -1,7 +1,7 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Niels Dossche <7771979+nielsdos@users.noreply.github.com> Date: Sun, 7 Jan 2024 21:59:28 +0100 -Subject: [PATCH 6/9] Patch out unused CSS style code +Subject: [PATCH 06/11] Patch out unused CSS style code --- source/lexbor/css/rule.h | 2 ++ diff --git a/ext/lexbor/patches/0007-Add-lxb_url_is_special-to-the-public-API-362.patch b/ext/lexbor/patches/0007-Add-lxb_url_is_special-to-the-public-API-362.patch index b56a1d1f06ab..50bdc2397ba5 100644 --- a/ext/lexbor/patches/0007-Add-lxb_url_is_special-to-the-public-API-362.patch +++ b/ext/lexbor/patches/0007-Add-lxb_url_is_special-to-the-public-API-362.patch @@ -1,7 +1,7 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Sun, 17 May 2026 22:17:14 +0200 -Subject: [PATCH 7/9] Add lxb_url_is_special() to the public API (#362) +Subject: [PATCH 07/11] Add lxb_url_is_special() to the public API (#362) As https://wiki.php.net/rfc/uri_followup#uri_type_detection relies on this information. --- diff --git a/ext/lexbor/patches/0008-URL-fixed-setters-for-empty-hosts.patch b/ext/lexbor/patches/0008-URL-fixed-setters-for-empty-hosts.patch index de66962e7cca..4218461c3bad 100644 --- a/ext/lexbor/patches/0008-URL-fixed-setters-for-empty-hosts.patch +++ b/ext/lexbor/patches/0008-URL-fixed-setters-for-empty-hosts.patch @@ -1,7 +1,7 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Alexander Borisov Date: Fri, 26 Jun 2026 18:55:56 +0300 -Subject: [PATCH 8/9] URL: fixed setters for empty hosts. +Subject: [PATCH 08/11] URL: fixed setters for empty hosts. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diff --git a/ext/lexbor/patches/0009-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch b/ext/lexbor/patches/0009-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch index 0fde094eefdf..91e78a899f44 100644 --- a/ext/lexbor/patches/0009-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch +++ b/ext/lexbor/patches/0009-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch @@ -1,7 +1,7 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Alexander Borisov Date: Fri, 5 Jun 2026 22:13:32 +0300 -Subject: [PATCH 9/9] URL: fixed uninitialized memory in the path buffer +Subject: [PATCH 09/11] URL: fixed uninitialized memory in the path buffer growth. When a path was long enough to outgrow the on-stack buffer, the first diff --git a/ext/lexbor/patches/0010-Fix-parsing-for-URL-containing-empty-host-and-userin.patch b/ext/lexbor/patches/0010-Fix-parsing-for-URL-containing-empty-host-and-userin.patch new file mode 100644 index 000000000000..9cbf3e0094ed --- /dev/null +++ b/ext/lexbor/patches/0010-Fix-parsing-for-URL-containing-empty-host-and-userin.patch @@ -0,0 +1,48 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= +Date: Thu, 9 Jul 2026 21:51:05 +0200 +Subject: [PATCH 10/11] Fix parsing for URL containing empty host and userinfo + +The returned error code (LXB_URL_ERROR_TYPE_INVALID_CREDENTIALS) apparently contradicts the specification: + +"If atSignSeen is true and buffer is the empty string, host-missing validation error, return failure." +--- + source/lexbor/url/url.c | 2 +- + test/files/lexbor/url/username_password.ton | 8 +++++++- + 2 files changed, 8 insertions(+), 2 deletions(-) + +diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c +index 1fd044b..aed468a 100644 +--- a/source/lexbor/url/url.c ++++ b/source/lexbor/url/url.c +@@ -1814,7 +1814,7 @@ again: + if (at_sign) { + if (begin == p || begin == p - 1) { + status = lxb_url_log_append(parser, p, +- LXB_URL_ERROR_TYPE_INVALID_CREDENTIALS); ++ LXB_URL_ERROR_TYPE_HOST_MISSING); + if (status != LXB_STATUS_OK) { + lxb_url_parse_return(orig_data, buf, status); + } +diff --git a/test/files/lexbor/url/username_password.ton b/test/files/lexbor/url/username_password.ton +index 28a27fd..5a5e63e 100644 +--- a/test/files/lexbor/url/username_password.ton ++++ b/test/files/lexbor/url/username_password.ton +@@ -1,5 +1,5 @@ + [ +- /* Test count: 11 */ ++ /* Test count: 12 */ + /* 1 */ + { + "url": "https://user:password@lexbor.com", +@@ -124,4 +124,10 @@ + "failed": false, + "encoding": "utf-8" + } ++ /* 12 */ ++ { ++ "url": "https://user:pass@", ++ "failed": true, ++ "encoding": "utf-8" ++ } + ] diff --git a/ext/lexbor/patches/0011-Percent-encode-the-caret-in-the-path.patch b/ext/lexbor/patches/0011-Percent-encode-the-caret-in-the-path.patch new file mode 100644 index 000000000000..2781fad5bcb9 --- /dev/null +++ b/ext/lexbor/patches/0011-Percent-encode-the-caret-in-the-path.patch @@ -0,0 +1,29 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= +Date: Fri, 10 Jul 2026 22:31:16 +0200 +Subject: [PATCH 11/11] Percent-encode the caret in the path + +The caret (^) is part of the path percent-encode set: + +"The path percent-encode set is a percent-encode set consisting of the query percent-encode set and U+003F (?), U+005E (^), U+0060 (`), U+007B ({), and U+007D (})." + +Until now, this character wasn't percent-encoded in the path likely due to a copy-paste error. This is mistake is fixed by adding LXB_URL_MAP_PATH to the lxb_url_map entry for the caret. + +Originally reported at https://github.com/php/php-src/issues/22628 +--- + source/lexbor/url/url.c | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c +index aed468a..8099c12 100644 +--- a/source/lexbor/url/url.c ++++ b/source/lexbor/url/url.c +@@ -159,7 +159,7 @@ static const uint8_t lxb_url_map[256] = + LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5b ([) */ + LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5c (\) */ + LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5d (]) */ +- LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5e (^) */ ++ LXB_URL_MAP_USERINFO|LXB_URL_MAP_PATH|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5e (^) */ + LXB_URL_MAP_UNDEF, /* 0x5f (_) */ + LXB_URL_MAP_PATH|LXB_URL_MAP_FRAGMENT|LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x60 (`) */ + LXB_URL_MAP_UNDEF, /* 0x61 (a) */ diff --git a/ext/lexbor/patches/update-lexbor.sh b/ext/lexbor/patches/update-lexbor.sh index ae288285453d..92ef8aa58f66 100755 --- a/ext/lexbor/patches/update-lexbor.sh +++ b/ext/lexbor/patches/update-lexbor.sh @@ -15,7 +15,10 @@ git clone "$LEXBOR_REPO" "$LEXBOR_TMP_DIR" (cd "$LEXBOR_TMP_DIR" && git checkout "$LEXBOR_REF") # Apply patches -mapfile -t patches < <(ls "$PATCHES_DIR"/*.patch) +patches=() +for f in "$PATCHES_DIR"/*.patch; do + [ -e "$f" ] && patches+=("$f") +done cd "$LEXBOR_TMP_DIR" for patch in "${patches[@]}"; do if ! git am -3 "$patch"; then diff --git a/ext/openssl/tests/stream_socket_get_crypto_status_handshake.phpt b/ext/openssl/tests/stream_socket_get_crypto_status_handshake.phpt index 2a1c554a79d0..7e0f660c980d 100644 --- a/ext/openssl/tests/stream_socket_get_crypto_status_handshake.phpt +++ b/ext/openssl/tests/stream_socket_get_crypto_status_handshake.phpt @@ -27,6 +27,46 @@ $serverCode = <<<'CODE' CODE; $serverCode = sprintf($serverCode, $certFile); +/* Plain TCP proxy that forwards the server handshake flight in fragments, so the client's + * non-blocking handshake sees a partial TLS record and reports WANT_READ instead of completing + * in a single call (which can otherwise happen depending on timing). */ +$proxyCode = <<<'CODE' + $upstream = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 30, STREAM_CLIENT_CONNECT); + stream_set_blocking($upstream, false); + + $flags = STREAM_SERVER_BIND|STREAM_SERVER_LISTEN; + $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr, $flags); + phpt_notify_server_start($server); + + $conn = stream_socket_accept($server); + stream_set_blocking($conn, false); + + $read = [$upstream, $conn]; + while (stream_select($read, $write, $except, 1)) { + foreach ($read as $fp) { + $data = stream_get_contents($fp); + if ($data === '') { + continue; + } + if ($fp === $conn) { + fwrite($upstream, $data); + } else { + /* Fragment server -> client to force a partial TLS record. */ + foreach (str_split($data, (int) ceil(strlen($data) / 3)) as $part) { + fwrite($conn, $part); + usleep(50000); + } + } + } + if (feof($upstream) || feof($conn)) { + break; + } + $read = [$upstream, $conn]; + } + + phpt_wait(); +CODE; + /* Client connects over plain TCP, then completes the TLS handshake in non-blocking mode, using * the reported crypto status to select the right direction to wait on. */ $clientCode = <<<'CODE' @@ -73,7 +113,8 @@ $clientCode = <<<'CODE' stream_set_blocking($client, true); echo trim(fgets($client)), "\n"; - phpt_notify(); + phpt_notify('server'); + phpt_notify('proxy'); fclose($client); CODE; $clientCode = sprintf($clientCode, $peerName); @@ -83,7 +124,10 @@ $certificateGenerator = new CertificateGenerator(); $certificateGenerator->saveNewCertAsFileWithKey($peerName, $certFile); include 'ServerClientTestCase.inc'; -ServerClientTestCase::getInstance()->run($clientCode, $serverCode); +ServerClientTestCase::getInstance()->run($clientCode, [ + 'server' => $serverCode, + 'proxy' => $proxyCode, +]); ?> --CLEAN-- +#ifdef __APPLE__ +#include +#endif #define USE_POSIX_SPAWN -/* The non-_np variant is in macOS 26 (and _np deprecated) */ -#ifdef HAVE_POSIX_SPAWN_FILE_ACTIONS_ADDCHDIR +/* The non-_np variant is in macOS 26 (and _np deprecated). On Apple, it has to be selected by the + * deployment target rather than the configure check: the link check passes whenever the SDK + * exports the symbol even if the running system is older, in which case the weakly linked + * reference resolves to NULL at runtime. */ +#if defined(__APPLE__) && MAC_OS_X_VERSION_MIN_REQUIRED >= 260000 +#define POSIX_SPAWN_FILE_ACTIONS_ADDCHDIR posix_spawn_file_actions_addchdir +#elif defined(__APPLE__) +#define POSIX_SPAWN_FILE_ACTIONS_ADDCHDIR posix_spawn_file_actions_addchdir_np +#elif defined(HAVE_POSIX_SPAWN_FILE_ACTIONS_ADDCHDIR) #define POSIX_SPAWN_FILE_ACTIONS_ADDCHDIR posix_spawn_file_actions_addchdir #else #define POSIX_SPAWN_FILE_ACTIONS_ADDCHDIR posix_spawn_file_actions_addchdir_np diff --git a/ext/standard/tests/poll/poll.inc b/ext/standard/tests/poll/poll.inc index a1ca01fc7d4a..ce001220bbac 100644 --- a/ext/standard/tests/poll/poll.inc +++ b/ext/standard/tests/poll/poll.inc @@ -106,7 +106,12 @@ function pt_write_sleep($stream, $data, $delay = 10000): int|false { function pt_event_array_to_string(array $events): string { $names = []; foreach ($events as $event) { - $names[] = $event->name; + if (is_array($event)) { + // Optional slot, rendered in brackets. + $names[] = '[' . pt_event_array_to_string($event) . ']'; + } else { + $names[] = $event->name; + } } return empty($names) ? 'NONE' : implode('|', $names); } @@ -242,17 +247,48 @@ function pt_events_equal($actual, $expected): bool { return false; } - if (count($actual) !== count($expected)) { - return false; + // An expected item that is itself an array marks an optional slot: it may be + // absent, or filled by exactly one of the events it lists. Plain enums are + // required. This lets a backend accept e.g. Write with an optional HangUp + // that may not have propagated yet, without weakening the required events. + $required = []; + $optional_groups = []; + foreach ($expected as $item) { + if (is_array($item)) { + $optional_groups[] = array_map(fn($e) => $e->name, $item); + } else { + $required[] = $item->name; + } } - // Sort both arrays by event name for comparison $actual_names = array_map(fn($e) => $e->name, $actual); - $expected_names = array_map(fn($e) => $e->name, $expected); - sort($actual_names); - sort($expected_names); - return $actual_names === $expected_names; + // Every required event must be present. + foreach ($required as $name) { + $idx = array_search($name, $actual_names, true); + if ($idx === false) { + return false; + } + unset($actual_names[$idx]); + } + + // Each leftover actual event must be accounted for by a distinct optional + // slot that lists it. + foreach ($actual_names as $name) { + $matched = false; + foreach ($optional_groups as $gi => $group) { + if (in_array($name, $group, true)) { + unset($optional_groups[$gi]); + $matched = true; + break; + } + } + if (!$matched) { + return false; + } + } + + return true; } function pt_resolve_backend_specific_value($backend_map, $current_backend) { @@ -319,3 +355,68 @@ function pt_print_mismatched_events($actual_watchers, $expected_watchers, $match echo $match_status . "\n"; } } + +/* + * Self-test for the pt_* event-matching helpers. + * + * This is deliberately NOT a .phpt: the CI only runs .phpt files, never .inc, + * so this never executes there. It only runs when poll.inc is executed as the + * main script, which lets the (fiddly) optional-slot matching logic be verified + * by hand: + * + * sapi/cli/php ext/standard/tests/poll/poll.inc + */ +function pt_run_self_test(): void { + if (!enum_exists('Io\\Poll\\Event')) { + echo "Io\\Poll\\Event not available; build with poll support to run the self-test\n"; + return; + } + + $R = Io\Poll\Event::Read; + $W = Io\Poll\Event::Write; + $E = Io\Poll\Event::Error; + $H = Io\Poll\Event::HangUp; + + $failures = 0; + $check = function (string $label, bool $cond) use (&$failures) { + if ($cond) { + echo "ok - $label\n"; + } else { + echo "FAIL - $label\n"; + ++$failures; + } + }; + + // Plain required sets are order independent and exact. + $check('exact set matches', pt_events_equal([$W, $H], [$H, $W])); + $check('missing required fails', !pt_events_equal([$W], [$W, $H])); + $check('extra unexpected fails', !pt_events_equal([$W, $H], [$W])); + + // Optional slot: [$W, [$H]] means Write required, HangUp allowed but not + // required. + $check('optional present matches', pt_events_equal([$W, $H], [$W, [$H]])); + $check('optional absent matches', pt_events_equal([$W], [$W, [$H]])); + $check('optional cannot satisfy a required event', !pt_events_equal([$H], [$W, [$H]])); + $check('unlisted extra with optional fails', !pt_events_equal([$W, $E], [$W, [$H]])); + + // A single optional slot accepts at most one of its listed events; separate + // slots can each accept one. + $check('optional picks one of group', pt_events_equal([$W, $H], [$W, [$H, $E]])); + $check('optional group may stay empty', pt_events_equal([$W], [$W, [$H, $E]])); + $check('single slot rejects two', !pt_events_equal([$W, $H, $E], [$W, [$H, $E]])); + $check('two slots accept two', pt_events_equal([$W, $H, $E], [$W, [$H], [$E]])); + + // Rendering. + $check('renders plain set', pt_event_array_to_string([$W, $H]) === 'Write|HangUp'); + $check('renders optional slot', pt_event_array_to_string([$W, [$H]]) === 'Write|[HangUp]'); + $check('renders empty as NONE', pt_event_array_to_string([]) === 'NONE'); + + echo $failures === 0 + ? "\nAll self-tests passed\n" + : "\n$failures self-test(s) FAILED\n"; +} + +if (isset($_SERVER['SCRIPT_FILENAME']) + && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) { + pt_run_self_test(); +} diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt index 4edd1619d309..3909dea58637 100644 --- a/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt +++ b/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt @@ -17,7 +17,11 @@ pt_expect_events($poll_ctx->wait(0, 100000), [ [ 'events' => [ 'default' => [Io\Poll\Event::Write, Io\Poll\Event::Error, Io\Poll\Event::HangUp], - 'Kqueue|EventPorts' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp], + 'Kqueue' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp], + // On Solaris event ports the peer close may not be folded into a + // POLLHUP in the same snapshot as the POLLOUT readiness, so HangUp + // is optional here. + 'EventPorts' => [Io\Poll\Event::Write, [Io\Poll\Event::HangUp]], ], 'data' => 'socket2_data' ] diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_max_events.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_max_events.phpt new file mode 100644 index 000000000000..d78d3f4fde35 --- /dev/null +++ b/ext/standard/tests/poll/poll_stream_sock_rw_max_events.phpt @@ -0,0 +1,38 @@ +--TEST-- +Poll stream - socket read/write events with maxEvents equal to the FD count +--FILE-- + [Io\Poll\Event::Read, Io\Poll\Event::Write], + 'data' => "sock$i", + 'read' => "test $i data", + ]; +} + +pt_expect_events($poll_ctx->wait(0, 100000, 8), $expected); + +// All read data was drained above, so only write events remain +$expected = []; +for ($i = 0; $i < 4; $i++) { + $expected[] = ['events' => [Io\Poll\Event::Write], 'data' => "sock$i"]; +} +pt_expect_events($poll_ctx->wait(0, 100000, 8), $expected); + +?> +--EXPECT-- +Events matched - count: 4 +Events matched - count: 4 diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt index 57fc335c26c4..8bc8ea7fcc3d 100644 --- a/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt +++ b/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt @@ -43,7 +43,16 @@ pt_expect_events($poll_ctx->wait(0, 100000), [ fclose($socket1r); pt_expect_events($poll_ctx->wait(0, 100000), [ - ['events' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp], 'data' => 'socket2_data'] + [ + 'events' => [ + 'default' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp], + // On Solaris event ports the peer close may not be folded into a + // POLLHUP in the same snapshot as the POLLOUT readiness, so HangUp + // is optional here. + 'EventPorts' => [Io\Poll\Event::Write, [Io\Poll\Event::HangUp]], + ], + 'data' => 'socket2_data' + ] ], $poll_ctx); fclose($socket1w); diff --git a/ext/standard/tests/poll/poll_stream_tcp_read_max_events.phpt b/ext/standard/tests/poll/poll_stream_tcp_read_max_events.phpt new file mode 100644 index 000000000000..f927e73ed4d3 --- /dev/null +++ b/ext/standard/tests/poll/poll_stream_tcp_read_max_events.phpt @@ -0,0 +1,45 @@ +--TEST-- +Poll stream - TCP read with maxEvents smaller than the number of ready FDs +--FILE-- +wait(0, 100000, 4); + echo "Round $round count: " . count($watchers) . "\n"; + foreach ($watchers as $watcher) { + $data = $watcher->getData(); + if (isset($seen[$data])) { + echo "Duplicate event for $data\n"; + } + $seen[$data] = true; + // Drain so level triggering does not re-report the FD + fread($watcher->getHandle()->getStream(), 1024); + } +} + +ksort($seen); +echo "Seen: " . implode(',', array_keys($seen)) . "\n"; + +pt_expect_events($poll_ctx->wait(0), []); + +?> +--EXPECT-- +Round 1 count: 4 +Round 2 count: 4 +Seen: server0,server1,server2,server3,server4,server5,server6,server7 +Events matched - count: 0 diff --git a/ext/standard/tests/poll/poll_stream_tcp_read_one_shot_max_events.phpt b/ext/standard/tests/poll/poll_stream_tcp_read_one_shot_max_events.phpt new file mode 100644 index 000000000000..d7a2e48be6f4 --- /dev/null +++ b/ext/standard/tests/poll/poll_stream_tcp_read_one_shot_max_events.phpt @@ -0,0 +1,46 @@ +--TEST-- +Poll stream - TCP read oneshot with maxEvents smaller than the number of ready FDs +--FILE-- +wait(0, 100000, 4); + echo "Round $round count: " . count($watchers) . "\n"; + foreach ($watchers as $watcher) { + $data = $watcher->getData(); + if (isset($seen[$data])) { + echo "Duplicate event for $data\n"; + } + $seen[$data] = true; + } +} + +ksort($seen); +echo "Seen: " . implode(',', array_keys($seen)) . "\n"; + +// Every oneshot watcher fired once, so new data must not be reported +pt_write_sleep($clients[0], "more data"); +pt_expect_events($poll_ctx->wait(0, 100000), []); + +?> +--EXPECT-- +Round 1 count: 4 +Round 2 count: 4 +Seen: server0,server1,server2,server3,server4,server5,server6,server7 +Events matched - count: 0 diff --git a/ext/uri/tests/whatwg/modification/path_success_auto_encoded.phpt b/ext/uri/tests/whatwg/modification/path_success_auto_encoded.phpt index 8f34b6db9de6..a871b6614a75 100644 --- a/ext/uri/tests/whatwg/modification/path_success_auto_encoded.phpt +++ b/ext/uri/tests/whatwg/modification/path_success_auto_encoded.phpt @@ -13,5 +13,5 @@ var_dump($url2->toAsciiString()); ?> --EXPECT-- string(1) "/" -string(8) "/p^th%23" -string(27) "https://example.com/p^th%23" +string(10) "/p%5Eth%23" +string(29) "https://example.com/p%5Eth%23" diff --git a/ext/uri/tests/whatwg/parsing/host_error_empty2.phpt b/ext/uri/tests/whatwg/parsing/host_error_empty2.phpt index 934185455641..726241674fae 100644 --- a/ext/uri/tests/whatwg/parsing/host_error_empty2.phpt +++ b/ext/uri/tests/whatwg/parsing/host_error_empty2.phpt @@ -11,4 +11,4 @@ try { ?> --EXPECT-- -Uri\WhatWg\InvalidUrlException: The specified URI is malformed +Uri\WhatWg\InvalidUrlException: The specified URI is malformed (HostMissing) diff --git a/ext/uri/tests/whatwg/parsing/path_success_percent_encode_set2.phpt b/ext/uri/tests/whatwg/parsing/path_success_percent_encode_set2.phpt index 9419c817fbbd..dd82beeff101 100644 --- a/ext/uri/tests/whatwg/parsing/path_success_percent_encode_set2.phpt +++ b/ext/uri/tests/whatwg/parsing/path_success_percent_encode_set2.phpt @@ -22,10 +22,10 @@ object(Uri\WhatWg\Url)#%d (%d) { ["port"]=> NULL ["path"]=> - string(28) "/foo%22/%3Cbar%3E/^%7Bbaz%7D" + string(30) "/foo%22/%3Cbar%3E/%5E%7Bbaz%7D" ["query"]=> NULL ["fragment"]=> NULL } -string(47) "https://example.com/foo%22/%3Cbar%3E/^%7Bbaz%7D" +string(49) "https://example.com/foo%22/%3Cbar%3E/%5E%7Bbaz%7D" diff --git a/main/poll/poll_backend_kqueue.c b/main/poll/poll_backend_kqueue.c index 00ba7abc9ee6..19ff0ad6d228 100644 --- a/main/poll/poll_backend_kqueue.c +++ b/main/poll/poll_backend_kqueue.c @@ -303,149 +303,179 @@ static int kqueue_backend_wait( { kqueue_backend_data_t *backend_data = (kqueue_backend_data_t *) ctx->backend_data; - /* For raw events, we need capacity for max_events. - * For grouped events, kqueue can return up to 2 events per FD, so we need 2x capacity. */ - int required_capacity = ctx->raw_events ? max_events : (max_events * 2); - if (required_capacity > backend_data->events_capacity) { + /* Cap every kevent() request at the entries we can still deliver, like epoll's maxevents. + * Unretrieved events stay pending in the kqueue (EV_ONESHOT deletes and EV_CLEAR resets only + * at retrieval), so nothing is lost and the caller's buffer cannot overflow. */ + if (max_events > backend_data->events_capacity) { struct kevent *new_events = php_poll_realloc( - backend_data->events, required_capacity * sizeof(struct kevent), ctx->persistent); + backend_data->events, max_events * sizeof(struct kevent), ctx->persistent); if (!new_events) { php_poll_set_error(ctx, PHP_POLL_ERR_NOMEM); return -1; } backend_data->events = new_events; - backend_data->events_capacity = required_capacity; + backend_data->events_capacity = max_events; } - int nfds = kevent( - backend_data->kqueue_fd, NULL, 0, backend_data->events, required_capacity, timeout); + if (ctx->raw_events) { + /* Raw events mode - direct 1:1 mapping, no grouping */ + int nfds = kevent( + backend_data->kqueue_fd, NULL, 0, backend_data->events, max_events, timeout); - if (nfds < 0) { - php_poll_set_current_errno_error(ctx); - return -1; + if (nfds < 0) { + php_poll_set_current_errno_error(ctx); + return -1; + } + + for (int i = 0; i < nfds; i++) { + events[i].fd = (int) backend_data->events[i].ident; + events[i].events = 0; + events[i].revents = 0; + events[i].data = backend_data->events[i].udata; + + /* Convert kqueue filter to poll event */ + if (backend_data->events[i].filter == EVFILT_READ) { + events[i].revents |= PHP_POLL_READ; + } else if (backend_data->events[i].filter == EVFILT_WRITE) { + events[i].revents |= PHP_POLL_WRITE; + } + + /* Convert kqueue flags to poll events */ + if (backend_data->events[i].flags & EV_EOF) { + events[i].revents |= PHP_POLL_HUP; + } + if (backend_data->events[i].flags & EV_ERROR) { + events[i].revents |= PHP_POLL_ERROR; + } + } + + return nfds; } - if (nfds > 0) { - if (ctx->raw_events) { - /* Raw events mode - direct 1:1 mapping, no grouping */ - for (int i = 0; i < nfds && i < max_events; i++) { - events[i].fd = (int) backend_data->events[i].ident; - events[i].events = 0; - events[i].revents = 0; - events[i].data = backend_data->events[i].udata; - - /* Convert kqueue filter to poll event */ - if (backend_data->events[i].filter == EVFILT_READ) { - events[i].revents |= PHP_POLL_READ; - } else if (backend_data->events[i].filter == EVFILT_WRITE) { - events[i].revents |= PHP_POLL_WRITE; - } + /* Grouped events mode with improved oneshot tracking. + * + * kqueue reports one event per (FD, filter) pair, so a READ+WRITE pair merges into a single + * entry and one round can fill fewer than max_events entries while more events are pending. + * Top up with zero-timeout rounds, each requesting only the remaining free entries. Both + * FreeBSD and XNU re-enqueue delivered level-triggered events at the tail of the active queue, + * so top-up rounds see not-yet-delivered events first, matching epoll's round-robin fill. */ + int unique_events = 0, garbage_events = 0; + const struct timespec zero_timeout = {0, 0}; + const struct timespec *round_timeout = timeout; + + while (true) { + int slots = max_events - unique_events; + int nfds = kevent( + backend_data->kqueue_fd, NULL, 0, backend_data->events, slots, round_timeout); + + if (nfds < 0) { + if (unique_events > 0) { + /* Deliver what was already gathered */ + break; + } + php_poll_set_current_errno_error(ctx); + return -1; + } - /* Convert kqueue flags to poll events */ - if (backend_data->events[i].flags & EV_EOF) { - events[i].revents |= PHP_POLL_HUP; - } - if (backend_data->events[i].flags & EV_ERROR) { - events[i].revents |= PHP_POLL_ERROR; - } + int new_unique_events = 0; + + for (int i = 0; i < nfds; i++) { + int fd = (int) backend_data->events[i].ident; + uint32_t revents = 0; + void *data = backend_data->events[i].udata; + bool is_oneshot = (backend_data->events[i].flags & EV_ONESHOT) != 0; + + /* Convert this event */ + if (backend_data->events[i].filter == EVFILT_READ) { + revents |= PHP_POLL_READ; + } else if (backend_data->events[i].filter == EVFILT_WRITE) { + revents |= PHP_POLL_WRITE; } - return nfds > max_events ? max_events : nfds; - } else { - /* Grouped events mode with improved oneshot tracking */ - int unique_events = 0, garbage_events = 0, fd; - - for (int i = 0; i < nfds; i++) { - fd = (int) backend_data->events[i].ident; - uint32_t revents = 0; - void *data = backend_data->events[i].udata; - bool is_oneshot = (backend_data->events[i].flags & EV_ONESHOT) != 0; - - /* Convert this event */ - if (backend_data->events[i].filter == EVFILT_READ) { - revents |= PHP_POLL_READ; - } else if (backend_data->events[i].filter == EVFILT_WRITE) { - revents |= PHP_POLL_WRITE; - } - if (backend_data->events[i].flags & EV_EOF) { - revents |= PHP_POLL_HUP; - } - if (backend_data->events[i].flags & EV_ERROR) { - revents |= PHP_POLL_ERROR; - } + if (backend_data->events[i].flags & EV_EOF) { + revents |= PHP_POLL_HUP; + } + if (backend_data->events[i].flags & EV_ERROR) { + revents |= PHP_POLL_ERROR; + } - /* Look for existing event for this FD */ - bool found = false; - for (int j = 0; j < unique_events; j++) { - if (events[j].fd == fd) { - /* Combine with existing event */ - events[j].revents |= revents; - found = true; - break; - } + /* Look for existing event for this FD */ + bool found = false; + for (int j = 0; j < unique_events; j++) { + if (events[j].fd == fd) { + /* Combine with existing event */ + events[j].revents |= revents; + found = true; + break; } + } - if (!found) { - /* New FD, create new event */ - ZEND_ASSERT(unique_events < max_events); - events[unique_events].fd = fd; - events[unique_events].events = 0; - events[unique_events].revents = revents; - events[unique_events].data = data; - unique_events++; - - /* Handle oneshot tracking */ - if (is_oneshot) { - zval *tracking = zend_hash_index_find(backend_data->fd_tracking, fd); - if (tracking && (Z_LVAL_P(tracking) & KQUEUE_FD_ONESHOT_COMPLETE)) { - /* Mark which filter fired for garbage collection */ - zend_long flags = Z_LVAL_P(tracking); - flags &= ~KQUEUE_FD_ONESHOT_COMPLETE; /* Clear complete flag */ - if (revents & PHP_POLL_READ) { - flags |= KQUEUE_FD_GARBAGE_READ; /* Need to clean write */ - } - if (revents & PHP_POLL_WRITE) { - flags |= KQUEUE_FD_GARBAGE_WRITE; /* Need to clean read */ - } - ZVAL_LONG(tracking, flags); - backend_data->fd_count--; - garbage_events++; - } - } - } else if (is_oneshot) { - /* Second filter for same FD fired - clear garbage flags */ + if (!found) { + /* New FD, create new event */ + ZEND_ASSERT(unique_events < max_events); + events[unique_events].fd = fd; + events[unique_events].events = 0; + events[unique_events].revents = revents; + events[unique_events].data = data; + unique_events++; + new_unique_events++; + + /* Handle oneshot tracking */ + if (is_oneshot) { zval *tracking = zend_hash_index_find(backend_data->fd_tracking, fd); - if (tracking) { - /* Remove FD from tracking as it gets deleted from kqueue as well */ - zend_hash_index_del(backend_data->fd_tracking, fd); - garbage_events--; + if (tracking && (Z_LVAL_P(tracking) & KQUEUE_FD_ONESHOT_COMPLETE)) { + /* Mark which filter fired for garbage collection */ + zend_long flags = Z_LVAL_P(tracking); + flags &= ~KQUEUE_FD_ONESHOT_COMPLETE; /* Clear complete flag */ + if (revents & PHP_POLL_READ) { + flags |= KQUEUE_FD_GARBAGE_READ; /* Need to clean write */ + } + if (revents & PHP_POLL_WRITE) { + flags |= KQUEUE_FD_GARBAGE_WRITE; /* Need to clean read */ + } + ZVAL_LONG(tracking, flags); + backend_data->fd_count--; + garbage_events++; } } - } - - if (garbage_events > 0) { - /* Clean up orphaned filters from complete oneshot FDs */ - struct kevent cleanup_change; - ZEND_HASH_FOREACH_NUM_KEY_VAL(backend_data->fd_tracking, zend_ulong fd_key, zval *tracking) - { - zend_long flags = Z_LVAL_P(tracking); - if (flags & KQUEUE_FD_HAS_GARBAGE) { - int filter = (flags & KQUEUE_FD_GARBAGE_READ) ? EVFILT_WRITE : EVFILT_READ; - EV_SET(&cleanup_change, fd_key, filter, EV_DELETE, 0, 0, NULL); - kevent(backend_data->kqueue_fd, &cleanup_change, 1, NULL, 0, NULL); - - /* Remove FD from tracking after cleanup */ - zend_hash_index_del(backend_data->fd_tracking, fd_key); - } + } else if (is_oneshot) { + /* Second filter for same FD fired - clear garbage flags */ + zval *tracking = zend_hash_index_find(backend_data->fd_tracking, fd); + if (tracking) { + /* Remove FD from tracking as it gets deleted from kqueue as well */ + zend_hash_index_del(backend_data->fd_tracking, fd); + garbage_events--; } - ZEND_HASH_FOREACH_END(); } + } - return unique_events; + /* Stop on a partial round (drained or timed out), a full buffer, or a full round with no + * new FDs (kernel re-reported level-triggered events already merged above - don't spin) */ + if (nfds < slots || unique_events == max_events || new_unique_events == 0) { + break; } + round_timeout = &zero_timeout; } - return nfds; + if (garbage_events > 0) { + /* Clean up orphaned filters from complete oneshot FDs */ + struct kevent cleanup_change; + ZEND_HASH_FOREACH_NUM_KEY_VAL(backend_data->fd_tracking, zend_ulong fd_key, zval *tracking) + { + zend_long flags = Z_LVAL_P(tracking); + if (flags & KQUEUE_FD_HAS_GARBAGE) { + int filter = (flags & KQUEUE_FD_GARBAGE_READ) ? EVFILT_WRITE : EVFILT_READ; + EV_SET(&cleanup_change, fd_key, filter, EV_DELETE, 0, 0, NULL); + kevent(backend_data->kqueue_fd, &cleanup_change, 1, NULL, 0, NULL); + + /* Remove FD from tracking after cleanup */ + zend_hash_index_del(backend_data->fd_tracking, fd_key); + } + } + ZEND_HASH_FOREACH_END(); + } + + return unique_events; } static bool kqueue_backend_is_available(void) @@ -466,15 +496,11 @@ static int kqueue_backend_get_suitable_max_events(php_poll_ctx *ctx) return -1; } - if (ctx->raw_events) { - /* For raw events, return the total number of filters */ - int active_filters = backend_data->filter_count; - return active_filters == 0 ? 1 : active_filters; - } else { - /* For grouped events, return the number of unique FDs */ - int active_fds = backend_data->fd_count; - return active_fds == 0 ? 1 : active_fds; - } + /* kqueue reports one event per filter even in grouped mode (events are merged into + * per-FD entries afterwards) and the wait request is capped at max_events, so size + * for the total filter count to allow retrieving all pending events in one call. */ + int active_filters = backend_data->filter_count; + return active_filters == 0 ? 1 : active_filters; } const php_poll_backend_ops php_poll_backend_kqueue_ops = {