From 8ce99bc2cec1bb3ff47b5569d573151395bf1fc6 Mon Sep 17 00:00:00 2001 From: arshidkv12 Date: Sun, 12 Jul 2026 18:13:05 +0200 Subject: [PATCH 01/11] Fix GH-21720: macOS posix_spawn_file_actions_addchdir availability handling On Apple, select the addchdir variant by deployment target instead of the configure check: the link check passes whenever the SDK exports the symbol even if the running system is older, leaving a weakly linked reference that resolves to NULL at runtime and crashing proc_open() when $cwd is used. Closes GH-21722 --- ext/standard/proc_open.c | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ext/standard/proc_open.c b/ext/standard/proc_open.c index 29744018a16d..beeb157e53a5 100644 --- a/ext/standard/proc_open.c +++ b/ext/standard/proc_open.c @@ -42,10 +42,20 @@ * to be really buggy. */ #include +#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 From 93fa5f3c882e5b51342fa0674d25d5d29d7a0546 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 5 Jun 2026 15:51:50 +0100 Subject: [PATCH 02/11] ext/curl: add CURLOPT_SEEKFUNCTION --- NEWS | 6 ++ UPGRADING | 10 +++ ext/curl/curl.stub.php | 20 ++++++ ext/curl/curl_arginfo.h | 6 +- ext/curl/curl_private.h | 1 + ext/curl/interface.c | 60 ++++++++++++++++ ext/curl/tests/curl_copy_handle_seek.phpt | 44 ++++++++++++ ext/curl/tests/curl_seekfunction.phpt | 51 ++++++++++++++ ext/curl/tests/curl_seekfunction_error.phpt | 77 +++++++++++++++++++++ ext/curl/tests/curl_setopt_callables.phpt | 3 + ext/curl/tests/responder/get.inc | 5 ++ 11 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 ext/curl/tests/curl_copy_handle_seek.phpt create mode 100644 ext/curl/tests/curl_seekfunction.phpt create mode 100644 ext/curl/tests/curl_seekfunction_error.phpt diff --git a/NEWS b/NEWS index 190c6c16a935..4be8d1e554c5 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 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/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!"; From f40c8f2b982143e1e3ec135ce3ebb6453fdd2113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Sun, 12 Jul 2026 19:50:36 +0200 Subject: [PATCH 03/11] Fix macOS compatibility of update-lexbor.sh [Tim: Extracted this as a new commit from GH-22699] --- ext/dom/lexbor/patches/update-lexbor.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ext/dom/lexbor/patches/update-lexbor.sh b/ext/dom/lexbor/patches/update-lexbor.sh index b5eff3846e11..6645f5d0e1e5 100755 --- a/ext/dom/lexbor/patches/update-lexbor.sh +++ b/ext/dom/lexbor/patches/update-lexbor.sh @@ -19,7 +19,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 From 2014eb2ccabf579a688f50d0216ca4fd7899b442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Sun, 12 Jul 2026 19:54:52 +0200 Subject: [PATCH 04/11] ext/uri: Fix GH-22629 WHATWG validation error incorrect with empty host and non-empty userinfo (#22699) 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." --- NEWS | 2 + ext/lexbor/lexbor/url/url.c | 2 +- ...nd-column-information-for-use-in-PHP.patch | 2 +- ...d-added-nodes-for-options-use-in-PHP.patch | 2 +- ...and-data-structure-to-be-able-to-gen.patch | 2 +- ...ve-unused-upper-case-tag-static-data.patch | 2 +- ...nk-size-of-static-binary-search-tree.patch | 2 +- ...0006-Patch-out-unused-CSS-style-code.patch | 2 +- ...07-URL-fixed-setters-for-empty-hosts.patch | 2 +- ...ialized-memory-in-the-path-buffer-gr.patch | 2 +- ...URL-containing-empty-host-and-userin.patch | 48 +++++++++++++++++++ .../whatwg/parsing/host_error_empty2.phpt | 2 +- 12 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 ext/lexbor/patches/0009-Fix-parsing-for-URL-containing-empty-host-and-userin.patch diff --git a/NEWS b/NEWS index ecd2c1267112..bf4e4d2bdb50 100644 --- a/NEWS +++ b/NEWS @@ -104,6 +104,8 @@ PHP NEWS - URI: . Fixed behavior of Uri\WhatWg\Url wither methods with regards to empty opaque hosts. (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() diff --git a/ext/lexbor/lexbor/url/url.c b/ext/lexbor/lexbor/url/url.c index b6c0a1e8f65e..19ec23829a23 100644 --- a/ext/lexbor/lexbor/url/url.c +++ b/ext/lexbor/lexbor/url/url.c @@ -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 04136b29f58f..e106b413c22c 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/8] Expose line and column information for use in PHP +Subject: [PATCH 1/9] 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 615655d7f2ec..9e83700198e1 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/8] Track implied added nodes for options use in PHP +Subject: [PATCH 2/9] 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 73c5afa19e12..9ed36fda109d 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,7 +1,7 @@ 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/8] Patch utilities and data structure to be able to generate +Subject: [PATCH 3/9] 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. 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 cc0a65a7cd5c..9e9fcaf8db7a 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/8] Remove unused upper case tag static data +Subject: [PATCH 4/9] 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 b84120bf6c8c..ce3448169395 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/8] Shrink size of static binary search tree +Subject: [PATCH 5/9] 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 196a5a8a62de..c8f485f34807 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/8] Patch out unused CSS style code +Subject: [PATCH 6/9] Patch out unused CSS style code --- source/lexbor/css/rule.h | 2 ++ diff --git a/ext/lexbor/patches/0007-URL-fixed-setters-for-empty-hosts.patch b/ext/lexbor/patches/0007-URL-fixed-setters-for-empty-hosts.patch index 2592372c6b02..56e206f63d2f 100644 --- a/ext/lexbor/patches/0007-URL-fixed-setters-for-empty-hosts.patch +++ b/ext/lexbor/patches/0007-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 7/8] URL: fixed setters for empty hosts. +Subject: [PATCH 7/9] 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/0008-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch b/ext/lexbor/patches/0008-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch index 243053e87fa4..c8cd2332eeff 100644 --- a/ext/lexbor/patches/0008-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch +++ b/ext/lexbor/patches/0008-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 8/8] URL: fixed uninitialized memory in the path buffer +Subject: [PATCH 8/9] 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/0009-Fix-parsing-for-URL-containing-empty-host-and-userin.patch b/ext/lexbor/patches/0009-Fix-parsing-for-URL-containing-empty-host-and-userin.patch new file mode 100644 index 000000000000..ef7743e61539 --- /dev/null +++ b/ext/lexbor/patches/0009-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 9/9] 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 b6c0a1e..19ec238 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/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) From c6c8db5a0061c352a9b208d0c00124978afb96b5 Mon Sep 17 00:00:00 2001 From: Jakub Zelenka Date: Sun, 12 Jul 2026 20:20:53 +0200 Subject: [PATCH 05/11] main/poll: Fix kqueue event buffer overflow in grouped mode (#22606) Cap each kevent() request at the number of entries that can still be delivered instead of requesting twice the buffer size. Events that are not retrieved (including oneshot and edge-triggered ones) stay pending in the kqueue and are delivered by the next wait, matching the epoll behavior. Merged read+write events can under-fill the buffer, so top up with zero timeout rounds, and base the suitable max events on the filter count so a default sized wait retrieves everything in one call. --- .../poll/poll_stream_sock_rw_max_events.phpt | 38 +++ .../poll/poll_stream_tcp_read_max_events.phpt | 45 +++ ...l_stream_tcp_read_one_shot_max_events.phpt | 46 +++ main/poll/poll_backend_kqueue.c | 278 ++++++++++-------- 4 files changed, 281 insertions(+), 126 deletions(-) create mode 100644 ext/standard/tests/poll/poll_stream_sock_rw_max_events.phpt create mode 100644 ext/standard/tests/poll/poll_stream_tcp_read_max_events.phpt create mode 100644 ext/standard/tests/poll/poll_stream_tcp_read_one_shot_max_events.phpt 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_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/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 = { From c025bc47dbbd47c687d06e11c622d32e21f283e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Sun, 12 Jul 2026 20:50:11 +0200 Subject: [PATCH 06/11] ext/uri: Fix GH-22628 Percent-encoding of caret in WHATWG URL paths is not performed (#22700) 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 mistake is fixed by adding LXB_URL_MAP_PATH to the lxb_url_map entry for the caret. --- NEWS | 2 ++ ext/lexbor/lexbor/url/url.c | 2 +- ...nd-column-information-for-use-in-PHP.patch | 2 +- ...d-added-nodes-for-options-use-in-PHP.patch | 2 +- ...and-data-structure-to-be-able-to-gen.patch | 4 +-- ...ve-unused-upper-case-tag-static-data.patch | 2 +- ...nk-size-of-static-binary-search-tree.patch | 2 +- ...0006-Patch-out-unused-CSS-style-code.patch | 2 +- ...07-URL-fixed-setters-for-empty-hosts.patch | 2 +- ...ialized-memory-in-the-path-buffer-gr.patch | 2 +- ...URL-containing-empty-host-and-userin.patch | 2 +- ...Percent-encode-the-caret-in-the-path.patch | 29 +++++++++++++++++++ .../path_success_auto_encoded.phpt | 4 +-- .../path_success_percent_encode_set2.phpt | 4 +-- 14 files changed, 46 insertions(+), 15 deletions(-) create mode 100644 ext/lexbor/patches/0010-Percent-encode-the-caret-in-the-path.patch diff --git a/NEWS b/NEWS index bf4e4d2bdb50..a4f1a24223ec 100644 --- a/NEWS +++ b/NEWS @@ -104,6 +104,8 @@ PHP NEWS - URI: . Fixed behavior of Uri\WhatWg\Url wither methods with regards to empty opaque hosts. (kocsismate) + . 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) diff --git a/ext/lexbor/lexbor/url/url.c b/ext/lexbor/lexbor/url/url.c index 19ec23829a23..de19239936a1 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) */ 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..533598837822 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/10] 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..8814d5955354 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/10] 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..aa4802320491 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/10] 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..1a28b21ccdc5 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/10] 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..a1dda1fcd112 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/10] 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..57f1e0e92fcb 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/10] Patch out unused CSS style code --- source/lexbor/css/rule.h | 2 ++ diff --git a/ext/lexbor/patches/0007-URL-fixed-setters-for-empty-hosts.patch b/ext/lexbor/patches/0007-URL-fixed-setters-for-empty-hosts.patch index 56e206f63d2f..b55f5aac5894 100644 --- a/ext/lexbor/patches/0007-URL-fixed-setters-for-empty-hosts.patch +++ b/ext/lexbor/patches/0007-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 7/9] URL: fixed setters for empty hosts. +Subject: [PATCH 07/10] 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/0008-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch b/ext/lexbor/patches/0008-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch index c8cd2332eeff..d967c8ca4eb9 100644 --- a/ext/lexbor/patches/0008-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch +++ b/ext/lexbor/patches/0008-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 8/9] URL: fixed uninitialized memory in the path buffer +Subject: [PATCH 08/10] 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/0009-Fix-parsing-for-URL-containing-empty-host-and-userin.patch b/ext/lexbor/patches/0009-Fix-parsing-for-URL-containing-empty-host-and-userin.patch index ef7743e61539..1e7aa4d1087d 100644 --- a/ext/lexbor/patches/0009-Fix-parsing-for-URL-containing-empty-host-and-userin.patch +++ b/ext/lexbor/patches/0009-Fix-parsing-for-URL-containing-empty-host-and-userin.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: Thu, 9 Jul 2026 21:51:05 +0200 -Subject: [PATCH 9/9] Fix parsing for URL containing empty host and userinfo +Subject: [PATCH 09/10] Fix parsing for URL containing empty host and userinfo The returned error code (LXB_URL_ERROR_TYPE_INVALID_CREDENTIALS) apparently contradicts the specification: diff --git a/ext/lexbor/patches/0010-Percent-encode-the-caret-in-the-path.patch b/ext/lexbor/patches/0010-Percent-encode-the-caret-in-the-path.patch new file mode 100644 index 000000000000..04d91bda68a9 --- /dev/null +++ b/ext/lexbor/patches/0010-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 10/10] 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 19ec238..de19239 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/uri/tests/whatwg/modification/path_success_auto_encoded.phpt b/ext/uri/tests/whatwg/modification/path_success_auto_encoded.phpt index 59f539124c68..8a168b928820 100644 --- a/ext/uri/tests/whatwg/modification/path_success_auto_encoded.phpt +++ b/ext/uri/tests/whatwg/modification/path_success_auto_encoded.phpt @@ -15,5 +15,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/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" From 2a712fb55775da4cdee2334c15ad391ff8852b20 Mon Sep 17 00:00:00 2001 From: Jakub Zelenka Date: Sun, 12 Jul 2026 21:02:45 +0200 Subject: [PATCH 07/11] Fix flaky stream_socket_get_crypto_status handshake test (#22707) The test could intermittently fail because a non-blocking TLS handshake can complete in a single stream_socket_enable_crypto() call, so WANT_READ/WANT_WRITE was never observed and $sawWant stayed false. Route the connection through a plain TCP proxy that forwards the server handshake flight in fragments, forcing the client to see a partial TLS record and report WANT_READ at least once. --- ...am_socket_get_crypto_status_handshake.phpt | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) 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-- Date: Sun, 12 Jul 2026 21:05:09 +0200 Subject: [PATCH 08/11] json: Report unterminated JSON strings as syntax errors (#22528) Fixes php/php-src#22527. --- NEWS | 3 + ext/json/json_scanner.re | 9 ++- ext/json/tests/gh22527.phpt | 31 +++++++ ...son_last_error_msg_error_location_001.phpt | 5 +- ...son_last_error_msg_error_location_002.phpt | 41 +++++----- ...son_last_error_msg_error_location_004.phpt | 13 ++- ...son_last_error_msg_error_location_005.phpt | 41 +++++----- ...son_last_error_msg_error_location_006.phpt | 5 +- ...son_last_error_msg_error_location_007.phpt | 5 +- ...son_last_error_msg_error_location_008.phpt | 81 +++++++++---------- ...son_last_error_msg_error_location_009.phpt | 21 +++-- ...son_last_error_msg_error_location_010.phpt | 17 ++-- 12 files changed, 152 insertions(+), 120 deletions(-) create mode 100644 ext/json/tests/gh22527.phpt diff --git a/NEWS b/NEWS index 48b13b5bc90a..0459373092ff 100644 --- a/NEWS +++ b/NEWS @@ -58,6 +58,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) 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" From dbf6d1f82e6b97bb861a1536af657e09e7a7dc15 Mon Sep 17 00:00:00 2001 From: Weilin Du Date: Mon, 13 Jul 2026 03:56:22 +0800 Subject: [PATCH 09/11] [skip ci] Update UPGRADING.INTERNALS with ext/intl changes #22446 Added compatibility helpers for ICU version differences in ext/intl. --- UPGRADING.INTERNALS | 8 ++++++++ 1 file changed, 8 insertions(+) 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. From 3eaa3387c25d442b7dfc87a25fa8592ea99c6b26 Mon Sep 17 00:00:00 2001 From: Jakub Zelenka Date: Sun, 12 Jul 2026 22:16:06 +0200 Subject: [PATCH 10/11] Fix flaky poll socket close tests on Solaris event ports (#22708) After the peer closes, Solaris event ports fire a one-shot snapshot as soon as the socket is writable and may not yet have folded the peer close into a POLLHUP, so the wait() sometimes reports Write without HangUp. Make HangUp optional for the EventPorts backend in the affected tests while keeping Write (and all other backends) strict. The pt_events_equal() helper now treats a nested array in the expected events as an optional slot, and pt_event_array_to_string() renders it in brackets. A manual self-test for this matching logic is included in poll.inc (runs only when the file is executed directly, never in CI). --- ext/standard/tests/poll/poll.inc | 117 ++++++++++++++++-- .../tests/poll/poll_stream_sock_rw_close.phpt | 6 +- .../poll/poll_stream_sock_rw_multi_level.phpt | 11 +- 3 files changed, 124 insertions(+), 10 deletions(-) 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_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); From f36660d64507e0d5d9fce73739031faac8139ded Mon Sep 17 00:00:00 2001 From: David Carlier Date: Thu, 2 Jul 2026 20:10:35 +0100 Subject: [PATCH 11/11] ext/dom: fix use-after-free with XPath callback returning foreign-document node. Fix GH-22554 A PHP XPath callback that returns a node belonging to a document created inside the callback (e.g. $d->documentElement of a throwaway DOMDocument) parks that node in the DOMXPath node_list to keep it alive. When a sibling callback consumes a node navigated into that foreign document, the proxy object was created with the DOMXPath's own dom as parent, so it took a reference on the wrong document and none on the foreign one. On teardown the foreign document could be freed while the proxy still referenced it. Route the proxy factory through dom_xpath_intern_for_doc() so the created object shares the ref_obj of the node's actual document, mirroring the query-result path. close GH-22562 --- NEWS | 2 ++ ext/dom/tests/gh22554.phpt | 38 ++++++++++++++++++++++++++++++++++++++ ext/dom/xpath.c | 3 ++- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 ext/dom/tests/gh22554.phpt diff --git a/NEWS b/NEWS index 0459373092ff..9807e07945df 100644 --- a/NEWS +++ b/NEWS @@ -41,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" 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)